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 = new Map(); private cachedUriValues: Map = 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, 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) { 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); } } }