Files
ng-eventually/docs/read-model.md
T
Sylvain Duchesne ac2b026955 feat(client): read via per-doc ANCHORED queries; document virtual vs physical wallet
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>
2026-07-05 22:50:14 +02:00

178 lines
9.3 KiB
Markdown

# 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`](./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_query` with **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
`GRAPH` wrapper); an explicit `GRAPH ?g { … }` body iterates the named graphs
regardless of the anchor (see § probe step 4). The read path therefore uses an
anchored `SELECT ?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_graph` all resolve via `self.repos.get().ok_or(RepoNotFound)` and only
touch a repo already present; the real loader `load_repo_from_read_cap` is
`pub(crate)`, unexposed. In THIS mono-wallet polyfill that is fine: every account's
docs are `doc_create`d in the SAME session, so they are all already in `self.repos`
and 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 (`RepoNotFound` aborts `orm_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`](./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`](./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`](./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:
1. `doc_create` two docs **A** and **B** (own docs → both opened into the session
store).
2. `sparql_update` a **distinct** triple into each (target A's `@graph`, then B's).
3. **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
```
4. **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` / `UnionSubject` is 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.