changeset 2071:56a9eaf5e882

rough ports from coffee to ts. untested
author drewp@bigasterisk.com
date Sun, 22 May 2022 03:00:37 -0700
parents 2951a690f1ba
children d5f1cc9615af
files light9/web/graph.coffee light9/web/graph.ts light9/web/patch.ts light9/web/rdfdbclient.coffee light9/web/rdfdbclient.ts package.json pnpm-lock.yaml
diffstat 7 files changed, 894 insertions(+), 588 deletions(-) [+]
line wrap: on
line diff
--- a/light9/web/graph.coffee	Sat May 21 19:16:34 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,430 +0,0 @@
-log = debug('graph')
-
-# Patch is {addQuads: <quads>, delQuads: <quads>}
-# <quads> are made with Quad(s,p,o,g)
-
-# for mocha
-if require?
-  `window = {}`
-  `_ = require('./lib/underscore/underscore-min.js')`
-  `N3 = require('../../node_modules/n3/n3-browser.js')`
-  `d3 = require('../../node_modules/d3/dist/d3.min.js')`
-  `RdfDbClient = require('./rdfdbclient.js').RdfDbClient`
-  module.exports = window
-
-RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
-
-patchSizeSummary = (patch) ->
-  '-' + patch.delQuads.length + ' +' + patch.addQuads.length
-
-# (sloppily shared to rdfdbclient.coffee too)
-window.patchSizeSummary = patchSizeSummary
-
-patchContainsPreds = (patch, preds) ->
-  if patch._allPreds == undefined
-    patch._allPreds = new Set()
-    for qq in [patch.addQuads, patch.delQuads]
-      for q in qq
-        patch._allPreds.add(q.predicate.value)
-
-  for p in preds
-    if patch._allPreds.has(p.value)
-      return true
-  return false
-
-allPatchSubjs = (patch) ->   # returns subjs as Set of strings
-  out = new Set()
-  if patch._allSubjs == undefined
-    patch._allSubjs = new Set()
-    for qq in [patch.addQuads, patch.delQuads]
-      for q in qq
-        patch._allSubjs.add(q.subject.value)
-
-  return patch._allSubjs
-
-class Handler
-  # a function and the quad patterns it cared about
-  constructor: (@func, @label) ->
-    @patterns = [] # s,p,o,g quads that should trigger the next run
-    @innerHandlers = [] # Handlers requested while this one was running
-  
-class AutoDependencies
-  constructor: () ->
-    # tree of all known Handlers (at least those with non-empty
-    # patterns). Top node is not a handler.
-    @handlers = new Handler(null)
-    @handlerStack = [@handlers] # currently running
-
-  runHandler: (func, label) ->
-    # what if we have this func already? duplicate is safe?
-
-    if not label?
-      throw new Error("missing label")
-
-    h = new Handler(func, label)
-    tailChildren = @handlerStack[@handlerStack.length - 1].innerHandlers
-    matchingLabel = _.filter(tailChildren, ((c) -> c.label == label)).length
-    # ohno, something depends on some handlers getting run twice :(
-    if matchingLabel < 2
-      tailChildren.push(h)
-    #console.time("handler #{label}")
-    @_rerunHandler(h, null)
-    #console.timeEnd("handler #{label}")
-    #@_logHandlerTree()
-    
-  _rerunHandler: (handler, patch) ->
-    handler.patterns = []
-    @handlerStack.push(handler)
-    try
-      handler.func(patch)
-    catch e
-      log('error running handler: ', e)
-      # assuming here it didn't get to do all its queries, we could
-      # add a *,*,*,* handler to call for sure the next time?
-    finally
-      #log('done. got: ', handler.patterns)
-      @handlerStack.pop()
-    # handler might have no watches, in which case we could forget about it
-
-  _logHandlerTree: ->
-    log('handler tree:')
-    prn = (h, depth) ->
-      indent = ''
-      for i in [0...depth]
-        indent += '  '
-      log("#{indent} \"#{h.label}\" #{h.patterns.length} pats")
-      for c in h.innerHandlers
-        prn(c, depth + 1)
-    prn(@handlers, 0)
-    
-  _handlerIsAffected: (child, patchSubjs) ->
-    if patchSubjs == null
-      return true
-    if not child.patterns.length
-      return false
-      
-    for stmt in child.patterns
-      if stmt[0] == null # wildcard on subject
-        return true
-      if patchSubjs.has(stmt[0].value)
-        return true
-
-    return false
-            
-  graphChanged: (patch) ->
-    # SyncedGraph is telling us this patch just got applied to the graph.
-
-    subjs = allPatchSubjs(patch)
-    
-    rerunInners = (cur) =>
-      toRun = cur.innerHandlers.slice()
-      for child in toRun
-        #match = @_handlerIsAffected(child, subjs)
-        #continue if not match
-        #log('match', child.label, match)
-        #child.innerHandlers = [] # let all children get called again
-        
-        @_rerunHandler(child, patch)
-        rerunInners(child)
-    rerunInners(@handlers)
-
-  askedFor: (s, p, o, g) ->
-    # SyncedGraph is telling us someone did a query that depended on
-    # quads in the given pattern.
-    current = @handlerStack[@handlerStack.length - 1]
-    if current? and current != @handlers
-      current.patterns.push([s, p, o, g])
-      #log('push', s,p,o,g)
-    #else
-    #  console.trace('read outside runHandler')
-
-class window.SyncedGraph
-  # Main graph object for a browser to use. 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: (@patchSenderUrl, @prefixes, @setStatus, @clearCb) ->
-    # patchSenderUrl is the /syncedGraph path of an rdfdb server.
-    # prefixes can be used in Uri(curie) calls.
-    @_autoDeps = new AutoDependencies() # replaces GraphWatchers
-    @clearGraph()
-
-    if @patchSenderUrl
-      @_client = new RdfDbClient(@patchSenderUrl,
-                                 @_clearGraphOnNewConnection.bind(@),
-                                 @_applyPatch.bind(@),
-                                 @setStatus)
-    
-  clearGraph: ->
-    # just deletes the statements; watchers are unaffected.
-    if @graph?
-      @_applyPatch({addQuads: [], delQuads: @graph.getQuads()})
-
-    # if we had a Store already, this lets N3.Store free all its indices/etc
-    @graph = N3.Store()
-    @_addPrefixes(@prefixes)
-    @cachedFloatValues = new Map() # s + '|' + p -> number
-    @cachedUriValues = new Map() # s + '|' + p -> Uri
-
-  _clearGraphOnNewConnection: -> # must not send a patch to the server!
-    log('graph: clearGraphOnNewConnection')
-    @clearGraph()
-    log('graph: clearGraphOnNewConnection done')
-    @clearCb() if @clearCb?
-      
-  _addPrefixes: (prefixes) ->
-    for k in (prefixes or {})
-      @prefixes[k] = prefixes[k]
-    @prefixFuncs = N3.Util.prefixes(@prefixes)
-        
-  Uri: (curie) ->
-    if not curie?
-      throw new Error("no uri")
-    if curie.match(/^http/)
-      return N3.DataFactory.namedNode(curie)
-    part = curie.split(':')
-    return @prefixFuncs(part[0])(part[1])
-
-  Literal: (jsValue) ->
-    N3.DataFactory.literal(jsValue)
-
-  LiteralRoundedFloat: (f) ->
-    N3.DataFactory.literal(d3.format(".3f")(f),
-                          @Uri("http://www.w3.org/2001/XMLSchema#double"))
-
-  Quad: (s, p, o, g) -> N3.DataFactory.quad(s, p, o, g)
-
-  toJs: (literal) ->
-    # incomplete
-    parseFloat(literal.value)
-
-  loadTrig: (trig, cb) -> # for debugging
-    patch = {delQuads: [], addQuads: []}
-    parser = N3.Parser()
-    parser.parse trig, (error, quad, prefixes) =>
-      if error
-        throw new Error(error)
-      if (quad)
-        patch.addQuads.push(quad)
-      else
-        @_applyPatch(patch)
-        @_addPrefixes(prefixes)
-        cb() if cb
-                    
-  quads: () -> # for debugging
-    [q.subject, q.predicate, q.object, q.graph] for q in @graph.getQuads()
-
-  applyAndSendPatch: (patch) ->
-    console.time('applyAndSendPatch')
-    if not @_client
-      log('not connected-- dropping patch')
-      return
-    if !Array.isArray(patch.addQuads) || !Array.isArray(patch.delQuads)
-      console.timeEnd('applyAndSendPatch')
-      log('corrupt patch')
-      throw new Error("corrupt patch: #{JSON.stringify(patch)}")
-
-    @_validatePatch(patch)
-
-    @_applyPatch(patch)
-    @_client.sendPatch(patch) if @_client
-    console.timeEnd('applyAndSendPatch')
-
-  _validatePatch: (patch) ->
-    for qs in [patch.addQuads, patch.delQuads]
-      for q in qs
-        if not q.equals
-          throw new Error("doesn't look like a proper Quad")
-        if not q.subject.id or not q.graph.id? or not q.predicate.id?
-          throw new Error("corrupt patch: #{JSON.stringify(q)}")
-    
-  _applyPatch: (patch) ->
-    # In most cases you want applyAndSendPatch.
-    #
-    # This is the only method that writes to @graph!
-    @cachedFloatValues.clear()
-    @cachedUriValues.clear()
-    for quad in patch.delQuads
-      #log("remove #{JSON.stringify(quad)}")
-      did = @graph.removeQuad(quad)
-      #log("removed: #{did}")
-    for quad in patch.addQuads
-      @graph.addQuad(quad)
-    #log('applied patch locally', patchSizeSummary(patch))
-    @_autoDeps.graphChanged(patch)
-
-  getObjectPatch: (s, p, newObject, g) ->
-    # make a patch which removes existing values for (s,p,*,c) and
-    # adds (s,p,newObject,c). Values in other graphs are not affected.
-    existing = @graph.getQuads(s, p, null, g)
-    return {
-      delQuads: existing,
-      addQuads: [@Quad(s, p, newObject, g)]
-    }
-
-  patchObject: (s, p, newObject, g) ->
-    @applyAndSendPatch(@getObjectPatch(s, p, newObject, g))
-
-  clearObjects: (s, p, g) ->
-    @applyAndSendPatch({
-      delQuads: @graph.getQuads(s, p, null, g),
-      addQuads: []
-    })
-  
-  runHandler: (func, label) ->
-    # 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.
-    @serial = 1 if not @serial
-    @serial += 1
-    #label = label + @serial
-    
-    @_autoDeps.runHandler(func, label)
-
-  _singleValue: (s, p) ->
-    @_autoDeps.askedFor(s, p, null, null)
-    quads = @graph.getQuads(s, p)
-    objs = new Set(q.object for q in quads)
-    
-    switch objs.size
-      when 0
-        throw new Error("no value for "+s.value+" "+p.value)
-      when 1
-        obj = objs.values().next().value
-        return obj
-      else
-        throw new Error("too many different values: " + JSON.stringify(quads))
-
-  floatValue: (s, p) ->
-    key = s.value + '|' + p.value
-    hit = @cachedFloatValues.get(key)
-    return hit if hit != undefined
-    #log('float miss', s, p)
-
-    v = @_singleValue(s, p).value
-    ret = parseFloat(v)
-    if isNaN(ret)
-      throw new Error("#{s.value} #{p.value} -> #{v} not a float")
-    @cachedFloatValues.set(key, ret)
-    return ret
-    
-  stringValue: (s, p) ->
-    @_singleValue(s, p).value
-    
-  uriValue: (s, p) ->
-    key = s.value + '|' + p.value
-    hit = @cachedUriValues.get(key)
-    return hit if hit != undefined
-
-    ret = @_singleValue(s, p)
-    @cachedUriValues.set(key, ret)
-    return ret
-
-  labelOrTail: (uri) ->
-    try
-      ret = @stringValue(uri, @Uri('rdfs:label'))
-    catch
-      words = uri.value.split('/')
-      ret = words[words.length-1]
-    if not ret
-      ret = uri.value
-    return ret
-
-  objects: (s, p) ->
-    @_autoDeps.askedFor(s, p, null, null)
-    quads = @graph.getQuads(s, p)
-    return (q.object for q in quads)
-
-  subjects: (p, o) ->
-    @_autoDeps.askedFor(null, p, o, null)
-    quads = @graph.getQuads(null, p, o)
-    return (q.subject for q in quads)
-
-  items: (list) ->
-    out = []
-    current = list
-    while true
-      if current == RDF + 'nil'
-        break
-        
-      @_autoDeps.askedFor(current, null, null, null) # a little loose
-
-      firsts = @graph.getQuads(current, RDF + 'first', null)
-      rests = @graph.getQuads(current, RDF + 'rest', 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, p, o) ->
-    @_autoDeps.askedFor(s, p, o, null)
-    log('contains calling getQuads when graph has ', @graph.size)
-    return @graph.getQuads(s, p, o).length > 0
-
-  nextNumberedResources: (base, howMany) ->
-    # base is NamedNode or string
-    # Note this is unsafe before we're synced with the graph. It'll
-    # always return 'name0'.
-    base = base.id if base.id
-    results = []
-
-    # @contains is really slow.
-    @_nextNumber = new Map() unless @_nextNumber?
-    start = @_nextNumber.get(base)
-    if start == undefined
-      start = 0
-      
-    for serial in [start..1000]
-      uri = @Uri("#{base}#{serial}")
-      if not @contains(uri, null, null)
-        results.push(uri)
-        log('nextNumberedResources', "picked #{uri}")
-        @_nextNumber.set(base, serial + 1)
-        if results.length >= howMany
-          return results
-    throw new Error("can't make sequential uri with base #{base}")
-
-  nextNumberedResource: (base) ->
-    @nextNumberedResources(base, 1)[0]
-
-  contextsWithPattern: (s, p, o) ->
-    @_autoDeps.askedFor(s, p, o, null)
-    ctxs = []
-    for q in @graph.getQuads(s, p, o)
-      ctxs.push(q.graph)
-    return _.unique(ctxs)
-
-  sortKey: (uri) ->
-    parts = uri.value.split(/([0-9]+)/)
-    expanded = parts.map (p) ->
-      f = parseInt(p)
-      return p if isNaN(f)
-      return p.padStart(8, '0')
-    return expanded.join('')
-
-  sortedUris: (uris) ->
-    _.sortBy uris, @sortKey
-
-  # temporary optimization since autodeps calls too often
-  @patchContainsPreds: (patch, preds) ->
-    patchContainsPreds(patch, preds)
-
-  prettyLiteral: (x) ->
-    if typeof(x) == 'number'
-      @LiteralRoundedFloat(x)
-    else
-      @Literal(x)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/graph.ts	Sun May 22 03:00:37 2022 -0700
@@ -0,0 +1,531 @@
+import * as d3 from "d3";
+import debug from "debug";
+import * as N3 from "n3";
+import { Quad, Quad_Subject, Quad_Predicate, Quad_Object, Quad_Graph } from "n3";
+import { filter, sortBy, unique } from "underscore";
+import { allPatchSubjs, Patch } from "./patch";
+import { RdfDbClient } from "./rdfdbclient";
+const log = debug("graph");
+
+const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
+
+interface QuadPattern {
+  subject: Quad_Subject | null;
+  predicate: Quad_Predicate | null;
+  object: Quad_Object | null;
+  graph: Quad_Graph | null;
+}
+
+class Handler {
+  patterns: QuadPattern[];
+  innerHandlers: Handler[];
+  // a function and the quad patterns it cared about
+  constructor(public func: ((p: Patch) => void) | null, public label: string) {
+    this.patterns = []; // s,p,o,g quads that should trigger the next run
+    this.innerHandlers = []; // Handlers requested while this one was running
+  }
+}
+
+class AutoDependencies {
+  handlers: Handler;
+  handlerStack: Handler[];
+  constructor() {
+    // tree of all known Handlers (at least those with non-empty
+    // patterns). Top node is not a handler.
+    this.handlers = new Handler(null, "root");
+    this.handlerStack = [this.handlers]; // currently running
+  }
+
+  runHandler(func: any, label: any) {
+    // what if we have this func already? duplicate is safe?
+
+    if (label == null) {
+      throw new Error("missing label");
+    }
+
+    const h = new Handler(func, label);
+    const tailChildren = this.handlerStack[this.handlerStack.length - 1].innerHandlers;
+    const matchingLabel = filter(tailChildren, (c: { label: any }) => c.label === label).length;
+    // ohno, something depends on some handlers getting run twice :(
+    if (matchingLabel < 2) {
+      tailChildren.push(h);
+    }
+    //console.time("handler #{label}")
+    return this._rerunHandler(h, null);
+  }
+  //console.timeEnd("handler #{label}")
+  //@_logHandlerTree()
+
+  _rerunHandler(handler: Handler, patch: any) {
+    handler.patterns = [];
+    this.handlerStack.push(handler);
+    try {
+      if (handler.func === null) {
+        throw new Error("tried to rerun root");
+      }
+      return handler.func(patch);
+    } catch (e) {
+      return log("error running handler: ", e);
+    } finally {
+      // assuming here it didn't get to do all its queries, we could
+      // add a *,*,*,* handler to call for sure the next time?
+      //log('done. got: ', handler.patterns)
+      this.handlerStack.pop();
+    }
+  }
+  // handler might have no watches, in which case we could forget about it
+
+  _logHandlerTree() {
+    log("handler tree:");
+    var prn = function (h: Handler, depth: number) {
+      let indent = "";
+      for (let i = 0, end = depth, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {
+        indent += "  ";
+      }
+      log(`${indent} \"${h.label}\" ${h.patterns.length} pats`);
+      return Array.from(h.innerHandlers).map((c: any) => prn(c, depth + 1));
+    };
+    return prn(this.handlers, 0);
+  }
+
+  _handlerIsAffected(child: Handler, patchSubjs: Set<string>) {
+    if (patchSubjs === null) {
+      return true;
+    }
+    if (!child.patterns.length) {
+      return false;
+    }
+
+    for (let stmt of Array.from(child.patterns)) {
+      if (stmt.subject === null) {
+        // wildcard on subject
+        return true;
+      }
+      if (patchSubjs.has(stmt.subject.value)) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  graphChanged(patch: Patch) {
+    // SyncedGraph is telling us this patch just got applied to the graph.
+
+    const subjs = allPatchSubjs(patch);
+
+    var rerunInners = (cur: Handler) => {
+      const toRun = cur.innerHandlers.slice();
+      for (let child of Array.from(toRun)) {
+        //match = @_handlerIsAffected(child, subjs)
+        //continue if not match
+        //log('match', child.label, match)
+        //child.innerHandlers = [] # let all children get called again
+
+        this._rerunHandler(child, patch);
+        rerunInners(child);
+      }
+    };
+    return rerunInners(this.handlers);
+  }
+
+  askedFor(s: Quad_Subject | null, p: Quad_Predicate | null, o: Quad_Object | null, g: Quad_Graph | null) {
+    // SyncedGraph is telling us someone did a query that depended on
+    // quads in the given pattern.
+    const current = this.handlerStack[this.handlerStack.length - 1];
+    if (current != null && current !== this.handlers) {
+      return current.patterns.push({ subject: s, predicate: p, object: o, graph: g } as QuadPattern);
+    }
+  }
+}
+
+export class SyncedGraph {
+  _autoDeps: AutoDependencies;
+  _client: any;
+  graph: N3.Store;
+  cachedFloatValues: any;
+  cachedUriValues: any;
+  prefixFuncs: (x: string) => string = (x) => x;
+  serial: any;
+  _nextNumber: any;
+  // Main graph object for a browser to use. 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(
+    // patchSenderUrl is the /syncedGraph path of an rdfdb server.
+    public patchSenderUrl: any,
+    // prefixes can be used in Uri(curie) calls.
+    public prefixes: { [short: string]: string },
+    private setStatus: any,
+    // called if we clear the graph
+    private clearCb: any
+  ) {
+    this.graph = new N3.Store();
+    this._autoDeps = new AutoDependencies(); // replaces GraphWatchers
+    this.clearGraph();
+
+    if (this.patchSenderUrl) {
+      this._client = new RdfDbClient(this.patchSenderUrl, this._clearGraphOnNewConnection.bind(this), this._applyPatch.bind(this), this.setStatus);
+    }
+  }
+
+  clearGraph() {
+    // just deletes the statements; watchers are unaffected.
+    if (this.graph != null) {
+      this._applyPatch({ adds: [], dels: this.graph.getQuads(null, null, null, null) });
+    }
+
+    // if we had a Store already, this lets N3.Store free all its indices/etc
+    this.graph = new N3.Store();
+    this._addPrefixes(this.prefixes);
+    this.cachedFloatValues = new Map(); // s + '|' + p -> number
+    return (this.cachedUriValues = new Map()); // s + '|' + p -> Uri
+  }
+
+  _clearGraphOnNewConnection() {
+    // must not send a patch to the server!
+    log("graph: clearGraphOnNewConnection");
+    this.clearGraph();
+    log("graph: clearGraphOnNewConnection done");
+    if (this.clearCb != null) {
+      return this.clearCb();
+    }
+  }
+
+  _addPrefixes(prefixes: { [x: string]: string }) {
+    for (let k of Array.from(prefixes || {})) {
+      this.prefixes[k] = prefixes[k];
+    }
+    this.prefixFuncs = N3.Util.prefixes(this.prefixes);
+  }
+
+  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]);
+  }
+
+  Literal(jsValue: any) {
+    return N3.DataFactory.literal(jsValue);
+  }
+
+  LiteralRoundedFloat(f: number) {
+    return N3.DataFactory.literal(d3.format(".3f")(f), this.Uri("http://www.w3.org/2001/XMLSchema#double"));
+  }
+
+  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 patch: Patch = { dels: [], adds: [] };
+    const parser = new N3.Parser();
+    return parser.parse(trig, (error: any, quad: any, prefixes: any) => {
+      if (error) {
+        throw new Error(error);
+      }
+      if (quad) {
+        return patch.adds.push(quad);
+      } else {
+        this._applyPatch(patch);
+        this._addPrefixes(prefixes);
+        if (cb) {
+          return 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 (!Array.isArray(patch.adds) || !Array.isArray(patch.dels)) {
+      console.timeEnd("applyAndSendPatch");
+      log("corrupt patch");
+      throw new Error(`corrupt patch: ${JSON.stringify(patch)}`);
+    }
+
+    this._validatePatch(patch);
+
+    this._applyPatch(patch);
+    if (this._client) {
+      this._client.sendPatch(patch);
+    }
+    return console.timeEnd("applyAndSendPatch");
+  }
+
+  _validatePatch(patch: Patch) {
+    return [patch.adds, patch.dels].map((qs: Quad[]) =>
+      (() => {
+        const result = [];
+        for (let q of Array.from(qs)) {
+          if (!q.equals) {
+            throw new Error("doesn't look like a proper Quad");
+          }
+          if (!q.subject.id || q.graph.id == null || q.predicate.id == null) {
+            throw new Error(`corrupt patch: ${JSON.stringify(q)}`);
+          } else {
+            result.push(undefined);
+          }
+        }
+        return result;
+      })()
+    );
+  }
+
+  _applyPatch(patch: Patch) {
+    // In most cases you want applyAndSendPatch.
+    //
+    // This is the only method that writes to @graph!
+    let quad: any;
+    this.cachedFloatValues.clear();
+    this.cachedUriValues.clear();
+    for (quad of Array.from(patch.dels)) {
+      //log("remove #{JSON.stringify(quad)}")
+      const did = this.graph.removeQuad(quad);
+    }
+    //log("removed: #{did}")
+    for (quad of Array.from(patch.adds)) {
+      this.graph.addQuad(quad);
+    }
+    //log('applied patch locally', patchSizeSummary(patch))
+    return this._autoDeps.graphChanged(patch);
+  }
+
+  getObjectPatch(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object, 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 {
+      dels: existing,
+      adds: [this.Quad(s, p, newObject, g)],
+    };
+  }
+
+  patchObject(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object, g: N3.NamedNode) {
+    return this.applyAndSendPatch(this.getObjectPatch(s, p, newObject, g));
+  }
+
+  clearObjects(s: N3.NamedNode, p: N3.NamedNode, g: N3.NamedNode) {
+    return this.applyAndSendPatch({
+      dels: this.graph.getQuads(s, p, null, g),
+      adds: [],
+    });
+  }
+
+  runHandler(func: any, label: any) {
+    // 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
+
+    return 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) {
+    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) {
+    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);
+  }
+
+  items(list: any) {
+    const out = [];
+    let current = list;
+    while (true) {
+      if (current === 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) {
+    this._autoDeps.askedFor(s, p, o, null);
+    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: any, p: any, o: any) {
+    this._autoDeps.askedFor(s, p, o, null);
+    const ctxs = [];
+    for (let q of Array.from(this.graph.getQuads(s, p, o, null))) {
+      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);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/patch.ts	Sun May 22 03:00:37 2022 -0700
@@ -0,0 +1,104 @@
+import debug from "debug";
+import * as async from "async";
+import { Writer, Parser, Quad, NamedNode } from "n3";
+const log = debug("patch");
+
+export interface Patch {
+  dels: Quad[];
+  adds: Quad[];
+  _allPredsCache?: Set<string>;
+  _allSubjsCache?: Set<string>;
+}
+
+interface SyncgraphPatchMessage {
+  patch: { adds: string; deletes: string };
+}
+
+export function patchSizeSummary(patch: Patch) {
+  return "-" + patch.dels.length + " +" + patch.adds.length;
+}
+
+export function parseJsonPatch(input: SyncgraphPatchMessage, cb: (p: Patch) => void) {
+  // note response cb doesn't have an error arg.
+  const patch: Patch = { dels: [], adds: [] };
+
+  const parseAdds = (cb: () => any) => {
+    const parser = new Parser();
+    return parser.parse(input.patch.adds, (error: any, quad: Quad, prefixes: any) => {
+      if (quad) {
+        return patch.adds.push(quad);
+      } else {
+        return cb();
+      }
+    });
+  };
+  const parseDels = (cb: () => any) => {
+    const parser = new Parser();
+    return parser.parse(input.patch.deletes, (error: any, quad: any, prefixes: any) => {
+      if (quad) {
+        return patch.dels.push(quad);
+      } else {
+        return cb();
+      }
+    });
+  };
+
+  return async.parallel([parseAdds, parseDels], (err: any) => cb(patch));
+}
+
+export function toJsonPatch(jsPatch: Patch, cb: { (json: any): any; (arg0: any): any }) {
+  const out: SyncgraphPatchMessage = { patch: { adds: "", deletes: "" } };
+
+  const writeDels = function (cb: () => any) {
+    const writer = new Writer({ format: "N-Quads" });
+    writer.addQuads(jsPatch.dels);
+    return writer.end(function (err: any, result: string) {
+      out.patch.deletes = result;
+      return cb();
+    });
+  };
+
+  const writeAdds = function (cb: () => any) {
+    const writer = new Writer({ format: "N-Quads" });
+    writer.addQuads(jsPatch.adds);
+    return writer.end(function (err: any, result: string) {
+      out.patch.adds = result;
+      return cb();
+    });
+  };
+
+  return async.parallel([writeDels, writeAdds], (err: any) => cb(JSON.stringify(out)));
+}
+
+export function patchContainsPreds(patch: Patch, preds: NamedNode[]): boolean {
+  if (patch._allPredsCache === undefined) {
+    patch._allPredsCache = new Set();
+    for (let qq of [patch.adds, patch.dels]) {
+      for (let q of Array.from(qq)) {
+        patch._allPredsCache.add(q.predicate.value);
+      }
+    }
+  }
+
+  for (let p of Array.from(preds)) {
+    if (patch._allPredsCache.has(p.value)) {
+      return true;
+    }
+  }
+  return false;
+}
+
+export function allPatchSubjs(patch: Patch): Set<string> {
+  // returns subjs as Set of strings
+  const out = new Set();
+  if (patch._allSubjsCache === undefined) {
+    patch._allSubjsCache = new Set();
+    for (let qq of [patch.adds, patch.dels]) {
+      for (let q of Array.from(qq)) {
+        patch._allSubjsCache.add(q.subject.value);
+      }
+    }
+  }
+
+  return patch._allSubjsCache;
+}
--- a/light9/web/rdfdbclient.coffee	Sat May 21 19:16:34 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,158 +0,0 @@
-log = debug('rdfdbclient')
-
-# for mocha
-if require?
-  `window = {}`
-  `N3 = require('../../node_modules/n3/n3-browser.js')`
-  module.exports = window
-
-
-toJsonPatch = (jsPatch, cb) ->
-  out = {patch: {adds: '', deletes: ''}}
-
-  writeDels = (cb) ->
-    writer = N3.Writer({ format: 'N-Quads' })
-    writer.addQuads(jsPatch.delQuads)
-    writer.end((err, result) ->
-      out.patch.deletes = result
-      cb())
-
-  writeAdds = (cb) ->
-    writer = N3.Writer({ format: 'N-Quads' })
-    writer.addQuads(jsPatch.addQuads)
-    writer.end((err, result) ->
-      out.patch.adds = result
-      cb())
-    
-  async.parallel([writeDels, writeAdds], (err) ->
-    cb(JSON.stringify(out))
-  )
-
-parseJsonPatch = (input, cb) ->
-  # note response cb doesn't have an error arg.
-  patch = {delQuads: [], addQuads: []}
-
-  parseAdds = (cb) =>
-    parser = N3.Parser()
-    parser.parse input.patch.adds, (error, quad, prefixes) =>
-      if (quad)
-        patch.addQuads.push(quad)
-      else
-        cb()
-  parseDels = (cb) =>
-    parser = N3.Parser()
-    parser.parse input.patch.deletes, (error, quad, prefixes) =>
-      if (quad)
-        patch.delQuads.push(quad)
-      else
-        cb()
-
-  async.parallel([parseAdds, parseDels], ((err) => cb(patch)))
-
-class window.RdfDbClient
-  # Send and receive patches from rdfdb
-  #
-  # What this should do, and does not yet, is keep the graph
-  # 'coasting' over a reconnect, applying only the diffs from the old
-  # contents to the new ones once they're in. Then, remove all the
-  # clearGraph stuff in graph.coffee that doesn't even work right.
-  # 
-  constructor: (@patchSenderUrl, @clearGraphOnNewConnection, @applyPatch,
-                @setStatus) ->
-    @_patchesToSend = []
-    @_lastPingMs = -1
-    @_patchesReceived = 0
-    @_patchesSent = 0
-    @_connectionId = '??'
-    @_reconnectionTimeout = null
-    @_newConnection()
-
-  _updateStatus: ->
-    ws = (if not @ws? then 'no' else switch @ws.readyState
-      when @ws.CONNECTING then 'connecting'
-      when @ws.OPEN then "open as #{@_connectionId}"
-      when @ws.CLOSING then 'closing'
-      when @ws.CLOSED then 'close'
-      )
-
-    ping = if @_lastPingMs > 0 then @_lastPingMs else '...'
-    @setStatus("#{ws};
-      #{@_patchesReceived} recv;
-      #{@_patchesSent} sent;
-      #{@_patchesToSend.length} pending;
-      #{ping}ms")
- 
-  sendPatch: (patch) ->
-    log('rdfdbclient: queue patch to server ', patchSizeSummary(patch))
-    @_patchesToSend.push(patch)
-    @_updateStatus()
-    @_continueSending()
-
-  _newConnection: ->
-    wsOrWss = window.location.protocol.replace('http', 'ws')
-    fullUrl = wsOrWss + '//' + window.location.host + @patchSenderUrl
-    @ws.close() if @ws?
-    @ws = new WebSocket(fullUrl)
-
-    @ws.onopen = =>
-      log('rdfdbclient: new connection to', fullUrl)
-      @_updateStatus()
-      @clearGraphOnNewConnection()
-      @_pingLoop()
-
-    @ws.onerror = (e) =>
-      log('rdfdbclient: ws error ' + e)
-      @ws.onclose()
-
-    @ws.onclose = =>
-      log('rdfdbclient: ws close')
-      @_updateStatus()
-      clearTimeout(@_reconnectionTimeout) if @_reconnectionTimeout?
-      @_reconnectionTimeout = setTimeout(@_newConnection.bind(@), 1000)
-
-    @ws.onmessage = @_onMessage.bind(@)
-
-  _pingLoop: () ->
-    if @ws.readyState == @ws.OPEN
-      @ws.send('PING')
-      @_lastPingMs = -Date.now()
-      
-      clearTimeout(@_pingLoopTimeout) if @_pingLoopTimeout?
-      @_pingLoopTimeout = setTimeout(@_pingLoop.bind(@), 10000)
-
-  _onMessage: (evt) ->
-    msg = evt.data
-    if msg == 'PONG'
-      @_lastPingMs = Date.now() + @_lastPingMs
-      @_updateStatus()
-      return
-      
-    input = JSON.parse(msg)
-    if input.connectedAs
-      @_connectionId = input.connectedAs
-    else
-      parseJsonPatch(input, @applyPatch.bind(@))
-      @_patchesReceived++
-    @_updateStatus()
-
-  _continueSending: ->
-    if @ws.readyState != @ws.OPEN
-      setTimeout(@_continueSending.bind(@), 500)
-      return
-
-    # we could call this less often and coalesce patches together to optimize
-    # the dragging cases.
-
-    sendOne = (patch, cb) =>
-      toJsonPatch(patch, (json) =>
-        log('rdfdbclient: send patch to server, ' + json.length + ' bytes')
-        @ws.send(json)
-        @_patchesSent++
-        @_updateStatus()
-        cb(null)
-      )
-
-    async.eachSeries(@_patchesToSend, sendOne, () =>
-      @_patchesToSend = []
-      @_updateStatus()
-    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/rdfdbclient.ts	Sun May 22 03:00:37 2022 -0700
@@ -0,0 +1,167 @@
+import debug from "debug";
+import * as async from "async";
+import { parseJsonPatch, Patch, patchSizeSummary, toJsonPatch } from "./patch";
+const log = debug("rdfdbclient");
+
+export class RdfDbClient {
+  _patchesToSend: Patch[];
+  _lastPingMs: number;
+  _patchesReceived: number;
+  _patchesSent: number;
+  _connectionId: string;
+  _reconnectionTimeout: number | null;
+  ws: WebSocket | undefined;
+  _pingLoopTimeout: any;
+  // Send and receive patches from rdfdb
+  //
+  // What this should do, and does not yet, is keep the graph
+  // 'coasting' over a reconnect, applying only the diffs from the old
+  // contents to the new ones once they're in. Then, remove all the
+  // clearGraph stuff in graph.coffee that doesn't even work right.
+  //
+  constructor(
+    public patchSenderUrl: string,
+    private clearGraphOnNewConnection: () => void,
+    private applyPatch: (p: Patch) => void,
+    private setStatus: (status: string) => void
+  ) {
+    this._patchesToSend = [];
+    this._lastPingMs = -1;
+    this._patchesReceived = 0;
+    this._patchesSent = 0;
+    this._connectionId = "??";
+    this._reconnectionTimeout = null;
+    this.ws = undefined;
+
+    this._newConnection();
+  }
+
+  _updateStatus() {
+    const conn = (() => {
+      if (this.ws === undefined) {
+        return "no";
+      } else {
+        switch (this.ws.readyState) {
+          case this.ws.CONNECTING:
+            return "connecting";
+          case this.ws.OPEN:
+            return `open as ${this._connectionId}`;
+          case this.ws.CLOSING:
+            return "closing";
+          case this.ws.CLOSED:
+            return "close";
+        }
+      }
+    })();
+
+    const ping = this._lastPingMs > 0 ? this._lastPingMs : "...";
+    return this.setStatus(`${conn}; \
+${this._patchesReceived} recv; \
+${this._patchesSent} sent; \
+${this._patchesToSend.length} pending; \
+${ping}ms`);
+  }
+
+  sendPatch(patch: Patch) {
+    log("rdfdbclient: queue patch to server ", patchSizeSummary(patch));
+    this._patchesToSend.push(patch);
+    this._updateStatus();
+    this._continueSending();
+  }
+
+  _newConnection() {
+    const wsOrWss = window.location.protocol.replace("http", "ws");
+    const fullUrl = wsOrWss + "//" + window.location.host + this.patchSenderUrl;
+    if (this.ws !== undefined) {
+      this.ws.close();
+    }
+    this.ws = new WebSocket(fullUrl);
+
+    this.ws.onopen = () => {
+      log("rdfdbclient: new connection to", fullUrl);
+      this._updateStatus();
+      this.clearGraphOnNewConnection();
+      return this._pingLoop();
+    };
+
+    this.ws.onerror = (e: Event) => {
+      log("rdfdbclient: ws error " + e);
+      if (this.ws !== undefined) {
+        const closeHandler = this.ws.onclose?.bind(this.ws);
+        if (!closeHandler) {
+          throw new Error();
+        }
+        closeHandler(new CloseEvent("forced"));
+      }
+    };
+
+    this.ws.onclose = (ev: CloseEvent) => {
+      log("rdfdbclient: ws close");
+      this._updateStatus();
+      if (this._reconnectionTimeout != null) {
+        clearTimeout(this._reconnectionTimeout);
+      }
+      this._reconnectionTimeout = (setTimeout(this._newConnection.bind(this), 1000) as unknown) as number;
+    };
+
+    this.ws.onmessage = this._onMessage.bind(this);
+  }
+
+  _pingLoop() {
+    if (this.ws && this.ws.readyState === this.ws.OPEN) {
+      this.ws.send("PING");
+      this._lastPingMs = -Date.now();
+
+      if (this._pingLoopTimeout != null) {
+        clearTimeout(this._pingLoopTimeout);
+      }
+      this._pingLoopTimeout = setTimeout(this._pingLoop.bind(this), 10000);
+    }
+  }
+
+  _onMessage(evt: { data: string }) {
+    const msg = evt.data;
+    if (msg === "PONG") {
+      this._lastPingMs = Date.now() + this._lastPingMs;
+      this._updateStatus();
+      return;
+    }
+
+    const input = JSON.parse(msg);
+    if (input.connectedAs) {
+      this._connectionId = input.connectedAs;
+    } else {
+      parseJsonPatch(input, this.applyPatch.bind(this));
+      this._patchesReceived++;
+    }
+    return this._updateStatus();
+  }
+
+  _continueSending() {
+    if (this.ws && this.ws.readyState !== this.ws.OPEN) {
+      setTimeout(this._continueSending.bind(this), 500);
+      return;
+    }
+
+    // we could call this less often and coalesce patches together to optimize
+    // the dragging cases.
+
+    const sendOne = (patch: any, cb: (arg0: any) => any) => {
+      return toJsonPatch(patch, (json: string) => {
+        log("rdfdbclient: send patch to server, " + json.length + " bytes");
+        if (!this.ws) {
+          throw new Error("can't send");
+        }
+        this.ws.send(json);
+        this._patchesSent++;
+        this._updateStatus();
+        return cb(null);
+      });
+    };
+
+    return async.eachSeries(this._patchesToSend, sendOne, () => {
+      this._patchesToSend = [];
+      return this._updateStatus();
+    });
+  }
+}
--- a/package.json	Sat May 21 19:16:34 2022 -0700
+++ b/package.json	Sun May 22 03:00:37 2022 -0700
@@ -9,16 +9,22 @@
     "test": "test"
   },
   "dependencies": {
+    "@types/async": "^3.2.13",
     "@types/d3": "^7.1.0",
     "@types/debug": "^4.1.7",
+    "@types/n3": "^1.10.4",
     "@types/node": "^17.0.31",
+    "@types/reconnectingwebsocket": "^1.0.7",
     "@types/sylvester": "^0.1.8",
     "@types/underscore": "^1.11.4",
+    "async": "^3.2.3",
     "d3": "^7.4.4",
     "debug": "^4.3.4",
     "knockout": "^3.5.1",
     "lit": "^2.2.3",
+    "n3": "^1.16.2",
     "parse-prometheus-text-format": "^1.1.1",
+    "reconnectingwebsocket": "^1.0.0",
     "sylvester": "^0.0.21",
     "underscore": "^1.13.3",
     "vite": "^2.9.1",
--- a/pnpm-lock.yaml	Sat May 21 19:16:34 2022 -0700
+++ b/pnpm-lock.yaml	Sun May 22 03:00:37 2022 -0700
@@ -1,32 +1,44 @@
 lockfileVersion: 5.3
 
 specifiers:
+  '@types/async': ^3.2.13
   '@types/d3': ^7.1.0
   '@types/debug': ^4.1.7
+  '@types/n3': ^1.10.4
   '@types/node': ^17.0.31
+  '@types/reconnectingwebsocket': ^1.0.7
   '@types/sylvester': ^0.1.8
   '@types/underscore': ^1.11.4
+  async: ^3.2.3
   d3: ^7.4.4
   debug: ^4.3.4
   knockout: ^3.5.1
   lit: ^2.2.3
+  n3: ^1.16.2
   parse-prometheus-text-format: ^1.1.1
+  reconnectingwebsocket: ^1.0.0
   sylvester: ^0.0.21
   underscore: ^1.13.3
   vite: ^2.9.1
   vite-plugin-rewrite-all: ^0.1.2
 
 dependencies:
+  '@types/async': 3.2.13
   '@types/d3': 7.1.0
   '@types/debug': 4.1.7
+  '@types/n3': 1.10.4
   '@types/node': 17.0.31
+  '@types/reconnectingwebsocket': 1.0.7
   '@types/sylvester': 0.1.8
   '@types/underscore': 1.11.4
+  async: 3.2.3
   d3: 7.4.4
   debug: 4.3.4
   knockout: 3.5.1
   lit: 2.2.3
+  n3: 1.16.2
   parse-prometheus-text-format: 1.1.1
+  reconnectingwebsocket: 1.0.0
   sylvester: 0.0.21
   underscore: 1.13.3
   vite: 2.9.1
@@ -38,6 +50,16 @@
     resolution: {integrity: sha512-A2e18XzPMrIh35nhIdE4uoqRzoIpEU5vZYuQN4S3Ee1zkGdYC27DP12pewbw/RLgPHzaE4kx/YqxMzebOpm0dA==}
     dev: false
 
+  /@rdfjs/types/1.1.0:
+    resolution: {integrity: sha512-5zm8bN2/CC634dTcn/0AhTRLaQRjXDZs3QfcAsQKNturHT7XVWcKy/8p3P5gXl+YkZTAmy7T5M/LyiT/jbkENw==}
+    dependencies:
+      '@types/node': 17.0.31
+    dev: false
+
+  /@types/async/3.2.13:
+    resolution: {integrity: sha512-7Q3awrhnvm89OzfsmqeqRQh8mh+8Pxfgq1UvSAn2nWQ5y/F3+NrbIF0RbkWq8+5dY99ozgap2b3DNBNwjLVOxw==}
+    dev: false
+
   /@types/d3-array/3.0.2:
     resolution: {integrity: sha512-5mjGjz6XOXKOCdTajXTZ/pMsg236RdiwKPrRPWAEf/2S/+PzwY+LLYShUpeysWaMvsdS7LArh6GdUefoxpchsQ==}
     dev: false
@@ -231,10 +253,21 @@
     resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
     dev: false
 
+  /@types/n3/1.10.4:
+    resolution: {integrity: sha512-FfRTwcbXcScVHuAjIASveRWL6Fi6fPALl1Ge8tMESYLqU7R42LJvtdBpUi+f9YK0oQPqIN+zFFgMDFJfLMx0bg==}
+    dependencies:
+      '@types/node': 17.0.31
+      rdf-js: 4.0.2
+    dev: false
+
   /@types/node/17.0.31:
     resolution: {integrity: sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==}
     dev: false
 
+  /@types/reconnectingwebsocket/1.0.7:
+    resolution: {integrity: sha512-17pnIZsGi9P8YNp7c0ueY2WEauSxDivuFeGuMuPPJMA3qk34CnFgBHGqkxgun3HUifEuwNr8cf+9rU7vSd8i5g==}
+    dev: false
+
   /@types/sylvester/0.1.8:
     resolution: {integrity: sha512-x1bzR4PCxvv1/9iPrbdQ15gWgP8Tp8EPjO4VLjhMijepB44BzJ/XvJavoPViSiHxlBX6NgzRgO0H+qa68lJFGA==}
     dev: false
@@ -247,6 +280,10 @@
     resolution: {integrity: sha512-uO4CD2ELOjw8tasUrAhvnn2W4A0ZECOvMjCivJr4gA9pGgjv+qxKWY9GLTMVEK8ej85BxQOocUyE7hImmSQYcg==}
     dev: false
 
+  /async/3.2.3:
+    resolution: {integrity: sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==}
+    dev: false
+
   /commander/7.2.0:
     resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
     engines: {node: '>= 10'}
@@ -757,6 +794,10 @@
       safer-buffer: 2.1.2
     dev: false
 
+  /inherits/2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+    dev: false
+
   /internmap/2.0.3:
     resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
     engines: {node: '>=12'}
@@ -797,6 +838,14 @@
     resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
     dev: false
 
+  /n3/1.16.2:
+    resolution: {integrity: sha512-5vYa2HuNEJ+a26FEs4FGgfFLgaPOODaZpJlc7FS0eUjDumc4uK0cvx216PjKXBkLzmAsSqGgQPwqztcLLvwDsw==}
+    engines: {node: '>=8.0'}
+    dependencies:
+      queue-microtask: 1.2.3
+      readable-stream: 3.6.0
+    dev: false
+
   /nanoid/3.3.2:
     resolution: {integrity: sha512-CuHBogktKwpm5g2sRgv83jEy2ijFzBwMoYA60orPDR7ynsLijJDqgsi4RDGj3OJpy3Ieb+LYwiRmIOGyytgITA==}
     engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -826,6 +875,29 @@
       source-map-js: 1.0.2
     dev: false
 
+  /queue-microtask/1.2.3:
+    resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+    dev: false
+
+  /rdf-js/4.0.2:
+    resolution: {integrity: sha512-ApvlFa/WsQh8LpPK/6hctQwG06Z9ztQQGWVtrcrf9L6+sejHNXLPOqL+w7q3hF+iL0C4sv3AX1PUtGkLNzyZ0Q==}
+    dependencies:
+      '@rdfjs/types': 1.1.0
+    dev: false
+
+  /readable-stream/3.6.0:
+    resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==}
+    engines: {node: '>= 6'}
+    dependencies:
+      inherits: 2.0.4
+      string_decoder: 1.3.0
+      util-deprecate: 1.0.2
+    dev: false
+
+  /reconnectingwebsocket/1.0.0:
+    resolution: {integrity: sha1-C4Jbq7N7ZwRFxlqn0+2XgwAgVEQ=}
+    dev: false
+
   /resolve/1.22.0:
     resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==}
     hasBin: true
@@ -851,6 +923,10 @@
     resolution: {integrity: sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=}
     dev: false
 
+  /safe-buffer/5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+    dev: false
+
   /safer-buffer/2.1.2:
     resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
     dev: false
@@ -864,6 +940,12 @@
     engines: {node: '>=0.10.0'}
     dev: false
 
+  /string_decoder/1.3.0:
+    resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+    dependencies:
+      safe-buffer: 5.2.1
+    dev: false
+
   /supports-preserve-symlinks-flag/1.0.0:
     resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
     engines: {node: '>= 0.4'}
@@ -878,6 +960,10 @@
     resolution: {integrity: sha512-QvjkYpiD+dJJraRA8+dGAU4i7aBbb2s0S3jA45TFOvg2VgqvdCDd/3N6CqA8gluk1W91GLoXg5enMUx560QzuA==}
     dev: false
 
+  /util-deprecate/1.0.2:
+    resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=}
+    dev: false
+
   /vite-plugin-rewrite-all/0.1.2_vite@2.9.1:
     resolution: {integrity: sha512-hBFuG043kbixgZ/ke9SzKhkO6P8a5ryxD0CmZTe+/Cz17RIKi7uSeNUJy79V4FgavZ7pWVRg0tqVwJ7lP/A2/Q==}
     engines: {node: '>=12.0.0'}