The anchorless union query (`GRAPH ?g`) scanned EVERY named graph in the local
store (the whole shared physical wallet) → O(wallet size), slow/timeouts on a
bloated wallet. Rewrite `readUnion` to run ONE ANCHORED `sparql_query` per by-need
doc (in parallel, per-doc tolerant): an anchored query is restricted to that
repo's graph, so it is O(1) per doc, INDEPENDENT of physical-wallet size. Keep the
ReadCap defense-in-depth gate.
docs/simulation.md: new "Physical wallet vs virtual wallet" section — the physical
shared wallet is a substrate that accumulates and must NEVER be enumerated/scanned;
each user's VIRTUAL wallet (the account's scope index in the shim) is the bounded
thing you enumerate ("list my documents"), then read those docs per-doc anchored.
read-model.md / nextgraph-current-state.md updated to the per-doc anchored rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.3 KiB
The READ MODEL the polyfill implements
How the polyfill turns "give me my lists" into concrete NextGraph reads on the
shared wallet. This is a design decision, grounded entirely in the query
capability documented in
nextgraph-current-state.md § The query
capability. The consumer (Festipod) never sees any of this: it asks
@ng-eventually/client for its lists by need and trusts the answer — the whole
read mechanism lives here, in the polyfill.
The rule in one line: read each by-need doc with its OWN anchored
sparql_query; NEVER run an anchorless union-scan over all graphs. An anchorless union spans every named graph in the session store — O(wallet size) — and on a shared/bloated wallet that accumulates across runs it produced ~90s timeouts. The per-doc anchored read is O(1) per doc, INDEPENDENT of wallet size, so a non-empty wallet no longer matters.
The governing constraints (all verified in nextgraph-rs, cited there):
- One local oxigraph store per session; every opened repo is a named graph.
sparql_querywith no anchor → the LOCAL UNION of all opened graphs (O(wallet), NOT used on the read path); with a string anchor → restricted to one repo (that repo becomes the query's DEFAULT graph). Union is read-only.- The anchor's one-repo restriction applies only to a default-graph body (no
GRAPHwrapper); an explicitGRAPH ?g { … }body iterates the named graphs regardless of the anchor (see § probe step 4). The read path therefore uses an anchoredSELECT ?s ?p ?o WHERE { ?s ?p ?o }(default-graph body) per doc. - A repo is queryable only after it is opened/synced (needs its NURI + ReadCap;
no store-level read inheritance). VERIFIED (T03.k): the current JS SDK exposes
no primitive that syncs an unknown repo —
sparql_query/doc_subscribe/orm_start_graphall resolve viaself.repos.get().ok_or(RepoNotFound)and only touch a repo already present; the real loaderload_repo_from_read_capispub(crate), unexposed. In THIS mono-wallet polyfill that is fine: every account's docs aredoc_created in the SAME session, so they are all already inself.reposand the anchorless union spans them with no per-doc open needed. The open step becomes a real broker sync only at the multi-store migration. - No reactive union query, and the reactive ORM hangs if handed a per-entity
/ unsynced graph fan-out (
RepoNotFoundabortsorm_start_graph).
Two read regimes — enumerate vs follow
There is no cross-wallet read in current NextGraph, so nothing is globally enumerable "for free". The polyfill splits every list into one of two regimes:
Events (all public) = the GLOBAL INDEX — the ONE enumeration hack
Public events are the only thing enumerated across accounts, via the emulated
discovery index (discovery.readIndex, see
simulation.md § Emulated discovery index). This is the ONE
"hack", and it is justified precisely because P2P has no cross-wallet read: without
a shared index a client could never learn that another account's public event-doc
exists. readIndex yields the event-doc NURIs to open/sync; those repos
then enter the local union and become union-queryable.
Everything else = FOLLOW a graph, never enumerate across accounts
My participations / my profile, a connection's shared protected data, my notifications — none of these is enumerated across accounts. Each is reached by what is already reachable to me:
- my own docs (always in
self.repos); - docs reachable via a connection's shared cap (a bilateral connection surfaces
the peer's protected NURIs — see the bilateral connection registry in
simulation.md); - my inbox (deposits addressed to me).
The rule of thumb: Access ≠ discovery. You only union-query over graphs you were already entitled to open.
Listing = a bounded set of per-doc ANCHORED reads (never a union-scan, never the ORM fan-out)
To produce a list, take the bounded, by-need set of doc NURIs (the index-yielded
event NURIs, my own docs, a connection's shared NURIs) and read each one with its
OWN anchored sparql_query (SELECT ?s ?p ?o WHERE { ?s ?p ?o }, anchor = that
doc NURI, in parallel and tolerant per-doc). The anchor restricts the query to that
one repo's graph, so each read is O(1) in the doc's own size and INDEPENDENT of how
many other graphs the (possibly bloated / shared) session store holds.
Do NOT run an anchorless union-scan (SELECT … WHERE { GRAPH ?g { ?s ?p ?o } },
no anchor) over the local union: it iterates every named graph in the session
store — O(wallet size) — so on a shared wallet that accumulates across runs it times
out (~90s observed). The read-set is already bounded and known; read exactly those
docs, anchored, and never scan the wallet.
Do NOT drive listing through the reactive ORM's per-document fan-out
(orm_start_graph over many graphs): a freshly-created or not-yet-synced graph in
the fan-out makes RepoNotFound abort the whole subscription → the readyPromise
never resolves → the ~75s hang (root cause verified in
nextgraph-current-state.md § The ORM fan-out
hang).
Reactivity = re-query on a change signal (no reactive union)
There is no reactive union query. So reactivity is assembled:
- keep a lightweight reactive subscription —
doc_subscribe, or the ORM on an already-opened single store (never a per-entity fan-out) — on the synced docs; - on its change signal, re-run the one-shot union
sparql_query.
Keep the reactive ORM strictly to already-opened single stores; it is a change signal source here, not the list source.
The boundary with the consumer
Festipod asks the SDK for its lists by need (listMyMeetingPoints(),
listEvents(), …) and trusts the returned set. It never constructs a NURI, never
picks the union-vs-anchor mode, never touches the ORM. Open/sync + union-query +
re-query-on-signal all live in the polyfill.
Minimal broker probe (confirms the union behaviour)
The one experiment that pins down union vs anchor, to run against a real broker:
-
doc_createtwo docs A and B (own docs → both opened into the session store). -
sparql_updatea distinct triple into each (target A's@graph, then B's). -
No anchor — expect BOTH graphs:
sparql_query( sid, "SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }", undefined /* base */, undefined /* anchor → UserSite → LOCAL UNION */ ) // → rows from BOTH A's and B's graphs -
Anchor = A — expect only A:
sparql_query(sid, "SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }", undefined, A /* string NURI → one repo */) // → rows from A's graph only
If (3) returns both and (4) returns only A, the union read model above holds as
implemented in resolve_target_for_sparql /
set_default_graph_as_union.
Verified against the real broker (T03.k)
Step (3) — the load-bearing one — is CONFIRMED: an anchorless
SELECT … WHERE { GRAPH ?g { ?s ?p ?o } } returns triples from BOTH docs A and B
(the local union of the opened graphs). That is the entire premise the listing
path relies on.
Step (4) has a nuance worth recording: with an explicit GRAPH ?g { … } body,
passing anchor = A did not restrict the result to A (B still appeared). The
reason: the anchor sets the query's default graph, but a GRAPH ?g pattern
iterates over the named graphs regardless of the default graph — so an
explicit GRAPH ?g body spans every opened graph independently of the anchor.
The anchor's "one repo" restriction is observable only for a body that reads the
default graph (no GRAPH wrapper). The read model never needs the anchored
form for listing — it uses the anchorless GRAPH ?g union — so this does not
affect it. (The per-doc "open" step in read-model.ts uses an anchored ASK
only to CONFIRM presence — it cannot sync an unknown repo, see the VERIFIED note
above; a repo absent from self.repos throws RepoNotFound and is skipped.)
Implementation — read-model.ts
readModel.readUnion(docs) implements this: for each requested doc NURI (the
bounded by-need set), run — in parallel, tolerant per-doc (a doc that fails is
skipped, never aborting the batch like the ORM fan-out would) — ONE anchored
SELECT ?s ?p ?o WHERE { ?s ?p ?o } with anchor = docNuri. The anchor restricts
the query to that doc's graph (default graph), so it returns ONLY that doc's
triples, O(1) per doc, independent of wallet size. There is NO anchorless
union-scan. Each entity's subject IRI IS its own document NURI, so the subject is
the anchor doc NURI; the result is grouped per subject (keeping the UnionSubject[]
shape: subject, graph, props). A ReadCap gate drops any doc the current user
may not read (defence-in-depth). The consumer maps the result to its types (e.g.
Festipod's readEntities). Reactivity = the consumer re-calls readUnion on its
change signal (no reactive union query exists).
The name
readUnion/UnionSubjectis historical (it once ran a union query). The read is now per-doc anchored, bounded to the read-set — the "union" is only the logical concatenation of the per-doc results, never an anchorless graph scan.