63ecfeeff8
Align the polyfill's surface and docs with the verified NextGraph reality and
remove application-level concepts:
- Identity is an ID, not a username: AccountRecord.id, shim predicate shim:id,
normalizeId; accounts core becomes IdentityStore (set/clear/get) — the faux
login/logout framing is gone (identity is set at wallet-import time).
- Relationship/connection is an application concept, not a platform primitive
(NextGraph has no bilateral-connection primitive: grantee is unpersisted
scaffolding, cap-send is unimplemented). Remove connections.ts; caps exposes
only a directed grantRead(doc, granteeId) + a read-only protectedDocsOf(owner).
Delete the now-dead isolation.ts social-visibility axis.
- Inbox docs: NextGraph has no separate curator — the recipient's own verifier
unseals and applies each queued sealed message inline (process_inbox);
inbox_post_link is a proposed/future API. Stop attributing the emulated
curator to the platform.
- Read isolation reframed around the outcome: no cap -> empty union read;
targeted read of an unheld repo -> RepoNotFound; cap introspection
(canRead/governsRead) is emulation-only with no NextGraph API behind it.
- read-model.md corrected: the listing path is per-doc ANCHORED default-graph
queries, never the anchorless GRAPH ?g union (that is O(wallet)); the probe
section no longer claims the opposite.
- README recap table restructured (target | current NextGraph status | current
emulation); INDEX_ACCOUNT documented as reservedAccount("index") in the
sentinel namespace; de-domained generic-layer comments; softened tone.
Consumer application (Festipod) rewired separately to own the relationship
concept and feed the lib an id. Lib gates: bun test 83 pass / 0 fail, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
193 lines
10 KiB
Markdown
193 lines
10 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 application 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) — which is why
|
|
> the read path is per-doc anchored on a shared wallet that accumulates across runs.
|
|
> The per-doc anchored read is O(1) per doc, independent of wallet size, so a
|
|
> non-empty wallet does not matter.
|
|
|
|
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 per-doc anchored read resolves each one directly 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, protected data an owner has granted me, 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 an owner has granted me via a directed per-document read grant
|
|
(`grantRead(doc, granteeId)` — see the per-document ReadCap in
|
|
[`simulation.md`](./simulation.md));
|
|
- my inbox (deposits addressed to me).
|
|
|
|
The rule of thumb: access is not discovery. You only union-query over graphs you
|
|
were already entitled to open.
|
|
|
|
Accessing a document without read rights yields an empty result: a reactive / union
|
|
read never decrypts a repo you hold no cap for, so it simply returns nothing (this
|
|
matches NextGraph's union read). A targeted read of a repo you do not hold diverges
|
|
in one way — it raises `RepoNotFound` rather than returning empty — and the read
|
|
path tolerates that per-doc (a doc that throws is skipped). The cap-introspection
|
|
used here (`canRead` / `governsRead`) is emulation-only; there is no NextGraph API
|
|
behind it, so it has no migration target.
|
|
|
|
## 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, the NURIs an owner has granted me) 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 its cost
|
|
grows with the whole wallet. 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, so the readyPromise
|
|
never resolves and the subscription hangs (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 bounded set of per-doc anchored
|
|
`sparql_query`s (`readModel.readUnion`) — never an anchorless union-scan.
|
|
|
|
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 application
|
|
|
|
The consumer application asks the SDK for its lists by need and trusts the returned
|
|
set. It never constructs a NURI, never picks the union-vs-anchor mode, never touches
|
|
the ORM. The domain-shaped list helpers (e.g. "my meeting points", "events") live in
|
|
the consumer application, not the lib; the lib exposes the generic by-need read.
|
|
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, default-graph body** (the form the read path actually uses) —
|
|
expect only A:
|
|
|
|
```
|
|
sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
|
|
undefined, A /* string NURI → one repo becomes the default graph */)
|
|
// → rows from A's graph only
|
|
```
|
|
|
|
If (3) returns both and (4) returns only A, the read model above holds as
|
|
implemented in `resolve_target_for_sparql` /
|
|
`set_default_graph_as_union`: the anchor turns A's repo into the query's default
|
|
graph, and a default-graph body reads exactly that graph.
|
|
|
|
### 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, and it is exactly why the read path uses a
|
|
**default-graph body**, not an explicit `GRAPH ?g` one: with an explicit
|
|
`GRAPH ?g { … }` body, passing `anchor = A` would **not** restrict the result to A
|
|
(B still appears). 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). That is precisely why the per-doc
|
|
read in `read-model.ts` uses the anchored default-graph body
|
|
`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`: the anchor makes that one repo the default
|
|
graph, so the read is bounded to it — O(1) per doc, independent of wallet size —
|
|
and never iterates the other named graphs. (A repo absent from `self.repos` throws
|
|
`RepoNotFound` and is skipped per-doc, see the VERIFIED note above — the read cannot
|
|
sync an unknown repo.)
|
|
|
|
## 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 application maps the result to its
|
|
types (e.g. its own `readEntities`). Reactivity = the consumer application 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.
|