6a3501e700
- read-model.ts `readUnion` now applies the emulated ReadCap gate (drops a
subject when its doc is governsRead && !canRead for the current identity), so
per-scope isolation holds by construction AND by filter.
- store-registry: `listMyEntityDocs(username, scope)` (current account only) vs
the all-accounts `listEntityDocs` fallback (documented as the enumeration to
avoid on the read path).
- docs: nextgraph-current-state / read-model — corrected to the SOURCE-VERIFIED
reality that the JS SDK exposes NO open/sync-by-cap primitive
(load_repo_from_read_cap is pub(crate)); in the mono-wallet all repos are
already local (same session), so the anchorless union spans them with no open
step. simulation.md: listEntityDocs+useShape({graphs}) is a fallback, not the
read path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
152 lines
7.4 KiB
Markdown
152 lines
7.4 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 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;
|
|
with an anchor → **one** repo. Union is **read-only**.
|
|
- 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 = open/sync + ONE union query (never the ORM fan-out)
|
|
|
|
To produce a list:
|
|
|
|
1. **Open/sync** the relevant repos (the index-yielded event NURIs, my own docs, a
|
|
connection's shared NURIs). This is what puts them in the local union.
|
|
2. Run a **single** `sparql_query` with **NO anchor** over the LOCAL UNION, using a
|
|
`GRAPH ?g { ... }` body so each result row is attributed to its source graph.
|
|
|
|
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: (1) open/sync each doc via a per-doc
|
|
anchored `ASK` (tolerant — a doc that can't open is skipped, never aborting the
|
|
listing like the ORM fan-out would); (2) run ONE anchorless
|
|
`SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o . VALUES ?s { … } } }` over the
|
|
local union, constrained to the requested subjects (each entity's subject IRI IS
|
|
its own document NURI). Returns the triples grouped per subject; the consumer maps
|
|
them to its types (e.g. Festipod's `readEntities`). Reactivity = the consumer
|
|
re-calls `readUnion` on its change signal (no reactive union query exists).
|