diff web/SyncedGraph.ts @ 2376:4556eebe5d73

topdir reorgs; let pdm have its src/ dir; separate vite area from light9/
author drewp@bigasterisk.com
date Sun, 12 May 2024 19:02:10 -0700
parents light9/web/SyncedGraph.ts@855f1abf5c66
children ac55319a2eac
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/SyncedGraph.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,404 @@
+import debug from "debug";
+import * as N3 from "n3";
+import { Quad, Quad_Object, Quad_Predicate, Quad_Subject } from "n3";
+import { sortBy, unique } from "underscore";
+import { AutoDependencies, HandlerFunc } from "./AutoDependencies";
+import { Patch, patchToDeleteEntireGraph } from "./patch";
+import { RdfDbClient } from "./rdfdbclient";
+
+const log = debug("graph");
+
+const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
+
+export class SyncedGraph {
+  private autoDeps: AutoDependencies;
+  private client: RdfDbClient;
+  private graph: N3.Store;
+  private cachedFloatValues: Map<string, number> = new Map();
+  private cachedUriValues: Map<string, N3.NamedNode> = new Map();
+  private prefixFuncs: (prefix: string) => N3.PrefixedToIri;
+  private serial: any;
+  private nextNumber: any;
+  // Main graph object for a browser to use. Consider using RdfdbSyncedGraph element to create & own
+  // one of these. Syncs both ways with rdfdb. Meant to hide the choice of RDF lib, so we can change it
+  // later.
+  //
+  // Note that _applyPatch is the only method to write to the graph, so
+  // it can fire subscriptions.
+
+  constructor(
+    // The /syncedGraph path of an rdfdb server.
+    patchSenderUrl: string,
+    // prefixes can be used in Uri(curie) calls. This mapping may grow during loadTrig calls.
+    public prefixes: Map<string, string>,
+    private setStatus: (status: string) => void
+  ) {
+    this.prefixFuncs = this.rebuildPrefixFuncs(prefixes);
+    this.graph = new N3.Store();
+    this.autoDeps = new AutoDependencies(this);
+    this.autoDeps.graphError.subscribe((e) => {
+      log("graph learned of error - reconnecting", e);
+      this.client.disconnect("graph error");
+    });
+    this.clearGraph();
+
+    this.client = new RdfDbClient(patchSenderUrl, this._clearGraphOnNewConnection.bind(this), this._applyPatch.bind(this), this.setStatus);
+  }
+
+  clearGraph() {
+    // must not try send a patch to the server!
+    // just deletes the statements; watchers are unaffected.
+    this.cachedFloatValues = new Map(); // s + '|' + p -> number
+    this.cachedUriValues = new Map(); // s + '|' + p -> Uri
+
+    const p = patchToDeleteEntireGraph(this.graph);
+    if (!p.isEmpty()) {
+      this._applyPatch(p);
+    }
+    // if we had a Store already, this lets N3.Store free all its indices/etc
+    this.graph = new N3.Store();
+    this.rebuildPrefixFuncs(this.prefixes);
+  }
+
+  _clearGraphOnNewConnection() {
+    // must not try send a patch to the server
+
+    log("clearGraphOnNewConnection");
+    this.clearGraph();
+    log("clearGraphOnNewConnection done");
+  }
+
+  private rebuildPrefixFuncs(prefixes: Map<string, string>) {
+    const p = Object.create(null);
+    prefixes.forEach((v: string, k: string) => (p[k] = v));
+
+    this.prefixFuncs = N3.Util.prefixes(p);
+    return this.prefixFuncs;
+  }
+
+  U() {
+    // just a shorthand
+    return this.Uri.bind(this);
+  }
+
+  Uri(curie: string) {
+    if (curie == null) {
+      throw new Error("no uri");
+    }
+    if (curie.match(/^http/)) {
+      return N3.DataFactory.namedNode(curie);
+    }
+    const part = curie.split(":");
+    return this.prefixFuncs(part[0])(part[1]);
+  }
+
+  // Uri(shorten(u)).value==u
+  shorten(uri: N3.NamedNode): string {
+    for (let row of [
+      { sh: "dev", lo: "http://light9.bigasterisk.com/theater/vet/device/" },
+      { sh: "effect", lo: "http://light9.bigasterisk.com/effect/" },
+      { sh: "", lo: "http://light9.bigasterisk.com/" },
+      { sh: "rdfs", lo: "http://www.w3.org/2000/01/rdf-schema#" },
+      { sh: "xsd", lo: "http://www.w3.org/2001/XMLSchema#" },
+    ]) {
+      if (uri.value.startsWith(row.lo)) {
+        return row.sh + ":" + uri.value.substring(row.lo.length);
+      }
+    }
+    return uri.value;
+  }
+
+  Literal(jsValue: string | number) {
+    return N3.DataFactory.literal(jsValue);
+  }
+
+  LiteralRoundedFloat(f: number) {
+    return N3.DataFactory.literal(f.toPrecision(3), this.Uri("http://www.w3.org/2001/XMLSchema#decimal"));
+  }
+
+  Quad(s: any, p: any, o: any, g: any) {
+    return N3.DataFactory.quad(s, p, o, g);
+  }
+
+  toJs(literal: { value: any }) {
+    // incomplete
+    return parseFloat(literal.value);
+  }
+
+  loadTrig(trig: any, cb: () => any) {
+    // for debugging
+    const adds: Quad[] = [];
+    const parser = new N3.Parser();
+    parser.parse(trig, (error: any, quad: any, prefixes: any) => {
+      if (error) {
+        throw new Error(error);
+      }
+      if (quad) {
+        adds.push(quad);
+      } else {
+        this._applyPatch(new Patch([], adds));
+        // todo: here, add those prefixes to our known set
+        if (cb) {
+          cb();
+        }
+      }
+    });
+  }
+
+  quads(): any {
+    // for debugging
+    return Array.from(this.graph.getQuads(null, null, null, null)).map((q: Quad) => [q.subject, q.predicate, q.object, q.graph]);
+  }
+
+  applyAndSendPatch(patch: Patch) {
+    console.time("applyAndSendPatch");
+    if (!this.client) {
+      log("not connected-- dropping patch");
+      return;
+    }
+    if (!patch.isEmpty()) {
+      this._applyPatch(patch);
+      // // chaos delay
+      //       setTimeout(()=>{
+      if (this.client) {
+        log("sending patch:\n", patch.dump());
+        this.client.sendPatch(patch);
+      }
+      // },300*Math.random())
+    }
+    console.timeEnd("applyAndSendPatch");
+  }
+
+  _applyPatch(patch: Patch) {
+    // In most cases you want applyAndSendPatch.
+    //
+    // This is the only method that writes to this.graph!
+    if (patch.isEmpty()) throw "dont send empty patches here";
+    log("_applyPatch [1] \n", patch.dump());
+    this.cachedFloatValues.clear();
+    this.cachedUriValues.clear();
+    patch.applyToGraph(this.graph);
+    if (false) {
+      log("applied patch locally", patch.summary());
+    } else {
+      log("applied patch locally:\n" + patch.dump());
+    }
+    this.autoDeps.graphChanged(patch);
+  }
+
+  getObjectPatch(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object | null, g: N3.NamedNode): Patch {
+    // make a patch which removes existing values for (s,p,*,c) and
+    // adds (s,p,newObject,c). Values in other graphs are not affected.
+    const existing = this.graph.getQuads(s, p, null, g);
+    return new Patch(existing, newObject !== null ? [this.Quad(s, p, newObject, g)] : []);
+  }
+
+  patchObject(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object | null, g: N3.NamedNode) {
+    this.applyAndSendPatch(this.getObjectPatch(s, p, newObject, g));
+  }
+
+  clearObjects(s: N3.NamedNode, p: N3.NamedNode, g: N3.NamedNode) {
+    this.applyAndSendPatch(new Patch(this.graph.getQuads(s, p, null, g), []));
+  }
+
+  public runHandler(func: HandlerFunc, label: string) {
+    // runs your func once, tracking graph calls. if a future patch
+    // matches what you queried, we runHandler your func again (and
+    // forget your queries from the first time).
+
+    // helps with memleak? not sure yet. The point was if two matching
+    // labels get puushed on, we should run only one. So maybe
+    // appending a serial number is backwards.
+    if (!this.serial) {
+      this.serial = 1;
+    }
+    this.serial += 1;
+    //label = label + @serial
+
+    this.autoDeps.runHandler(func, label);
+  }
+
+  _singleValue(s: Quad_Subject, p: Quad_Predicate) {
+    this.autoDeps.askedFor(s, p, null, null);
+    const quads = this.graph.getQuads(s, p, null, null);
+    const objs = new Set(Array.from(quads).map((q: Quad) => q.object));
+
+    switch (objs.size) {
+      case 0:
+        throw new Error("no value for " + s.value + " " + p.value);
+      case 1:
+        var obj = objs.values().next().value;
+        return obj;
+      default:
+        throw new Error("too many different values: " + JSON.stringify(quads));
+    }
+  }
+
+  floatValue(s: Quad_Subject, p: Quad_Predicate) {
+    const key = s.value + "|" + p.value;
+    const hit = this.cachedFloatValues.get(key);
+    if (hit !== undefined) {
+      return hit;
+    }
+    //log('float miss', s, p)
+
+    const v = this._singleValue(s, p).value;
+    const ret = parseFloat(v);
+    if (isNaN(ret)) {
+      throw new Error(`${s.value} ${p.value} -> ${v} not a float`);
+    }
+    this.cachedFloatValues.set(key, ret);
+    return ret;
+  }
+
+  stringValue(s: any, p: any) {
+    return this._singleValue(s, p).value;
+  }
+
+  uriValue(s: Quad_Subject, p: Quad_Predicate) {
+    const key = s.value + "|" + p.value;
+    const hit = this.cachedUriValues.get(key);
+    if (hit !== undefined) {
+      return hit;
+    }
+
+    const ret = this._singleValue(s, p);
+    this.cachedUriValues.set(key, ret);
+    return ret;
+  }
+
+  labelOrTail(uri: { value: { split: (arg0: string) => any } }) {
+    let ret: any;
+    try {
+      ret = this.stringValue(uri, this.Uri("rdfs:label"));
+    } catch (error) {
+      const words = uri.value.split("/");
+      ret = words[words.length - 1];
+    }
+    if (!ret) {
+      ret = uri.value;
+    }
+    return ret;
+  }
+
+  objects(s: any, p: any): Quad_Object[] {
+    this.autoDeps.askedFor(s, p, null, null);
+    const quads = this.graph.getQuads(s, p, null, null);
+    return Array.from(quads).map((q: { object: any }) => q.object);
+  }
+
+  subjects(p: any, o: any): Quad_Subject[] {
+    this.autoDeps.askedFor(null, p, o, null);
+    const quads = this.graph.getQuads(null, p, o, null);
+    return Array.from(quads).map((q: { subject: any }) => q.subject);
+  }
+
+  subjectStatements(s: Quad_Subject): Quad[] {
+    this.autoDeps.askedFor(s, null, null, null);
+    const quads = this.graph.getQuads(s, null, null, null);
+    return quads;
+  }
+
+  items(list: any) {
+    const out = [];
+    let current = list;
+    while (true) {
+      if (current.value === RDF + "nil") {
+        break;
+      }
+
+      this.autoDeps.askedFor(current, null, null, null); // a little loose
+
+      const firsts = this.graph.getQuads(current, RDF + "first", null, null);
+      const rests = this.graph.getQuads(current, RDF + "rest", null, null);
+      if (firsts.length !== 1) {
+        throw new Error(`list node ${current} has ${firsts.length} rdf:first edges`);
+      }
+      out.push(firsts[0].object);
+
+      if (rests.length !== 1) {
+        throw new Error(`list node ${current} has ${rests.length} rdf:rest edges`);
+      }
+      current = rests[0].object;
+    }
+
+    return out;
+  }
+
+  contains(s: any, p: any, o: any): boolean {
+    this.autoDeps.askedFor(s, p, o, null);
+    // Sure this is a nice warning to remind me to rewrite, but the graph.size call itself was taking 80% of the time in here
+    // log("contains calling getQuads when graph has ", this.graph.size);
+    return this.graph.getQuads(s, p, o, null).length > 0;
+  }
+
+  nextNumberedResources(base: { id: any }, howMany: number) {
+    // base is NamedNode or string
+    // Note this is unsafe before we're synced with the graph. It'll
+    // always return 'name0'.
+    if (base.id) {
+      base = base.id;
+    }
+    const results = [];
+
+    // @contains is really slow.
+    if (this.nextNumber == null) {
+      this.nextNumber = new Map();
+    }
+    let start = this.nextNumber.get(base);
+    if (start === undefined) {
+      start = 0;
+    }
+
+    for (let serial = start, asc = start <= 1000; asc ? serial <= 1000 : serial >= 1000; asc ? serial++ : serial--) {
+      const uri = this.Uri(`${base}${serial}`);
+      if (!this.contains(uri, null, null)) {
+        results.push(uri);
+        log("nextNumberedResources", `picked ${uri}`);
+        this.nextNumber.set(base, serial + 1);
+        if (results.length >= howMany) {
+          return results;
+        }
+      }
+    }
+    throw new Error(`can't make sequential uri with base ${base}`);
+  }
+
+  nextNumberedResource(base: any) {
+    return this.nextNumberedResources(base, 1)[0];
+  }
+
+  contextsWithPattern(s: Quad_Subject | null, p: Quad_Predicate | null, o: Quad_Object | null): N3.NamedNode[] {
+    this.autoDeps.askedFor(s, p, o, null);
+    const ctxs: N3.NamedNode[] = [];
+    for (let q of Array.from(this.graph.getQuads(s, p, o, null))) {
+      if (q.graph.termType != "NamedNode") throw `context was ${q.graph.id}`;
+      ctxs.push(q.graph);
+    }
+    return unique(ctxs);
+  }
+
+  sortKey(uri: N3.NamedNode) {
+    const parts = uri.value.split(/([0-9]+)/);
+    const expanded = parts.map(function (p: string) {
+      const f = parseInt(p);
+      if (isNaN(f)) {
+        return p;
+      }
+      return p.padStart(8, "0");
+    });
+    return expanded.join("");
+  }
+
+  sortedUris(uris: any) {
+    return sortBy(uris, this.sortKey);
+  }
+
+  prettyLiteral(x: any) {
+    if (typeof x === "number") {
+      return this.LiteralRoundedFloat(x);
+    } else {
+      return this.Literal(x);
+    }
+  }
+}