docs: query capability (local-union sparql_query) + the read model

Source-verified against nextgraph-rs:
- nextgraph-current-state.md: NextGraph keeps ONE local oxigraph store per
  session; each synced repo is a named graph. sparql_query with NO anchor
  (UserSite/None) queries the UNION of all synced graphs (set_default_graph_as
  _union); with an anchor it is restricted to one repo. Union is read-only
  (updates need a doc anchor). No reactive SPARQL (one-shot). Root cause of the
  ORM fan-out hang: orm_start_graph opens every graph in scope; a fresh/unsynced
  per-entity doc → RepoNotFound aborts the subscription → the 75s never-fires.
- read-model.md (new): the read model — events via the global index (the one
  enumeration hack); everything else by following a shared graph, opened/synced,
  then listed via a single anchorless union sparql_query (never the ORM per-doc
  fan-out); reactivity via re-query on a doc_subscribe/ORM change signal. Plus
  the minimal broker probe to confirm the union behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-05 17:32:22 +02:00
parent b1d06b68b9
commit 5acc07a7e3
3 changed files with 209 additions and 0 deletions
+91
View File
@@ -129,6 +129,97 @@ path NOT taken). A related exposed primitive: `social_query_start` (a federated
query via inbox up to `degree` hops) exists but is limited to **contacts** — it
does not cover an anonymous notification to a non-connected host.
## The query capability — ONE local store, named graphs, union queries
The single fact that makes read-time *listing* possible on the shared wallet, and
the reason the reactive ORM must **not** be used as the listing primitive. Verified
directly in `nextgraph-rs`.
### One oxigraph Store per session; each repo is a NAMED GRAPH
- The verifier keeps **ONE** local oxigraph `Store` per session:
`graph_dataset: Option<Store>` (`engine/verifier/src/verifier.rs:94`).
- Every synced repo's data is inserted into that one store as a **distinct NAMED
GRAPH keyed by the repo** — `update_graph` writes each repo's main-branch triples
under `NuriV0::repo_graph_name(&repo_id, &overlay_id)`
(`engine/verifier/src/commits/transaction.rs:646-701`, the `ov_graphname` /
`repo_graph_name` around line 669). So all opened repos coexist as named graphs in
one dataset — a `GRAPH ?g { ... }` body can span them.
### The NURI target decides scope: one repo vs the LOCAL UNION
`sparql_query`'s scope is resolved from the NURI target by
`resolve_target_for_sparql` (`engine/verifier/src/request_processor.rs:256-285`):
- `Repo(repo_id)` / `PrivateStore``Some(repo_graph_name)` = **ONE repo's graph**.
- `UserSite` / `None``Ok(None)` = the **UNION of all named graphs**. That `None`
is passed to oxigraph's `store.query(parsed, default_graph)`
(`engine/oxigraph/src/oxigraph/store.rs:200`) as the default graph, and
`sparql_query` first calls `dataset.set_default_graph_as_union()` when the query
has no explicit dataset (`request_processor.rs:656-675`, the
`if dataset.has_no_default_dataset()` block). Union = "every named graph currently
in the store".
### The wasm binding defaults the target to UserSite when no `nuri` is passed
The binding (`sdk/js/lib-wasm/src/lib.rs` — both the nodejs variant at ~`350-405`
and the web variant at ~`553-610`) reads the target from the `nuri` arg: a string
`nuri` is parsed; an **absent** `nuri` falls back to
`NuriV0::new_entire_user_site()` = `UserSite`
(`engine/net/src/app_protocol.rs:488-490`). Therefore:
> **`sparql_query(sid, query, base, /*anchor*/ undefined)` queries the LOCAL UNION
> across all synced/opened graphs; with a string anchor it is restricted to that one
> repo.** A `GRAPH ?g { ... }` body then spans/attributes across the local graphs.
### A repo is only queryable once OPENED/synced into the store
A repo's triples enter `graph_dataset` (hence the union) only after the repo is
opened/synced into `self.repos` **and** its commits applied via `update_graph`.
Paths that open a repo: `doc_create` (own docs), bootstrap /
`load_repo_from_read_cap`, or being followed from a store's `ldp:contains` / a
shared cap / an inbox. Opening requires possessing the repo's **NURI + ReadCap**
there is **no store-level read inheritance** (see § Capability / ReadCap
granularity). *(INFERRED: that "following a graph reference makes a previously
unknown repo known/openable" is the one step not read verbatim in source; the
open-requires-cap and no-inheritance facts around it ARE verified.)*
### The union is READ-ONLY — writes must target one document
`resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
`None` (`request_processor.rs:275-282`). So `sparql_update` cannot write "to the
union": every write must name **one** document's `@graph` — exactly what the
polyfill's `docs.sparqlUpdate` already does.
### No reactive SPARQL — `sparql_query` is one-shot
`sparql_query` is non-streamed: it computes a `QueryResults` and returns once
(`lib-wasm/src/lib.rs:352-405` / `553-610`). There is **no** "subscribe to a union
query". The only reactive primitives are the streamed ones: `orm_start_graph`,
`orm_start_discrete`, `doc_subscribe`, `app_request_stream`.
### The ORM fan-out hang — verified root cause
The reactive ORM is structurally unfit for a fan-out of per-entity / not-yet-synced
graphs, and this is *why* subscribing such a fan-out hangs:
- `OrmStartGraph` first loops over EVERY graph in the requested scope and calls
`open_for_target(&nuri.target, /*publisher*/ true)` on each
(`request_processor.rs:53-66`), and `orm/graph/initialize.rs` does the same
fan-out again for the graphs the ORM discovers (~`125-128`).
- `open_for_target``resolve_target``self.repos.get(repo_id).ok_or(RepoNotFound)`
(`request_processor.rs:286-294` calling `resolve_target` at `:147`, the
`RepoNotFound` at `:155/:163`).
- A **freshly-created per-entity doc**, or any **not-yet-synced other-account doc**,
is absent from `self.repos``RepoNotFound` propagates through the `?` and
**aborts the whole `orm_start_graph`**. The subscription never emits its initial →
the ORM `readyPromise` never resolves → the multi-second hang observed (≈75s)
when subscribing a fan-out of per-entity graphs.
**Consequence:** passing per-entity / unsynced graphs to the reactive ORM is broken.
Listing must go through a one-shot union `sparql_query` instead — see
[`read-model.md`](./read-model.md).
## JS SDK limits (`@ng-org/web`)
`@ng-org/web` (verified `0.1.2-alpha.13` = `upstream/main` at 2026-05-21, the