feat(client): read-model surface — open/sync + anchorless union sparql_query

Proven against the real broker (probe): opening docs then a single anchorless
`sparql_query` with a `GRAPH ?g { ... }` body reads the LOCAL UNION of all synced
named graphs — the fast, hang-free replacement for the reactive-ORM per-document
fan-out (which aborted on any unsynced/fresh repo → 75s never-fires). New
`read-model.ts` (readUnion) exposes this; there is no reactive union query, so
listing is one-shot and consumers re-query on change. docs/read-model.md refined
with the probe finding (an explicit `GRAPH ?g` body iterates all named graphs
regardless of the anchor; the anchor only bounds the default graph). 93 tests
pass; tsc rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-05 18:20:09 +02:00
parent 5acc07a7e3
commit e24f52749f
4 changed files with 281 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
/**
* read-model — the LISTING primitive of the polyfill: open/sync a set of
* documents, then run ONE anchorless union `sparql_query` over the LOCAL UNION
* and return the triples grouped per subject. This is the mechanism documented
* in docs/read-model.md, verified against the real broker (T03.k probe): a
* `GRAPH ?g { ?s ?p ?o }` body with NO anchor spans every graph currently opened
* in the session store.
*
* ── WHY not the reactive ORM fan-out ──────────────────────────────────────
* `useShape({ graphs: […manyDocs] })` drives `orm_start_graph` over a fan-out of
* per-entity graphs; a freshly-created / not-yet-synced doc in that fan-out makes
* `RepoNotFound` abort the whole subscription → the readyPromise never resolves →
* the ~75s hang (docs/nextgraph-current-state.md § The ORM fan-out hang). Listing
* MUST instead be a one-shot union `sparql_query`. There is no reactive union
* query, so reactivity is assembled by RE-QUERYING on a change signal.
*
* ── The two steps ─────────────────────────────────────────────────────────
* 1. OPEN/SYNC the docs. A repo is only in the union once opened into the
* session's oxigraph store. Opening is done PER-DOC via an anchored probe
* query (`resolveTargetForSparql(anchor)` opens that one repo) — per-doc, so a
* single unreachable doc fails in isolation instead of aborting a fan-out.
* 2. UNION QUERY. One anchorless `SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }`
* over the local union; group rows by subject.
*
* ── GENERIC by construction ───────────────────────────────────────────────
* Zero application domain here: the consumer passes the doc NURIs to open (from
* the discovery index for public events, or its own scope docs for my-entities)
* and interprets the returned per-subject property bags. All NextGraph I/O routes
* through the T01.a `docs` primitives (the REAL injected `ng`), so this module
* imports no `@ng-org` package.
*
* At migration the union query is unchanged (native SPARQL union over the wallet);
* only the "open/sync" step swaps to opening real per-user store repos by cap.
*/
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getStoreRegistryDeps } from "./polyfill";
import { assertNuri } from "./sparql";
import type { Nuri } from "./types";
// Keep the primitives referenced so tree-shaking never drops the import used by
// the (side-effecting) open step below; `docCreate`/`sparqlUpdate` are not used
// here but the module intentionally depends only on the docs primitive surface.
void docCreate;
void sparqlUpdate;
/** One subject read from the union, with its properties (predicate → values). */
export interface UnionSubject {
/** The subject IRI (`?s`). */
subject: string;
/** The graph the subject was read from (`?g` — the repo graph name). */
graph: string;
/** predicate IRI → the list of object values (literals or IRIs) for it. */
props: Record<string, string[]>;
}
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function bindings(
result: unknown,
): Array<Record<string, { value: string } | undefined>> {
if (!result) return [];
if (Array.isArray(result))
return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
return anyRes.results?.bindings ?? [];
}
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
/**
* Open/sync ONE doc into the session store by running a cheap anchored probe
* query against it (`resolve_target_for_sparql(anchor)` opens that repo). Per-doc
* and tolerant: a doc that can't be opened (not yet synced, no cap) throws HERE,
* in isolation, and is skipped — it never aborts the listing of the others (the
* fan-out ORM's failure mode). Returns whether the open succeeded.
*/
async function openDoc(sid: string, doc: Nuri): Promise<boolean> {
try {
const nuri = assertNuri(doc);
// ASK is the cheapest way to touch (hence open) the repo; the result is
// irrelevant — the side effect (repo opened into the union) is the point.
await sparqlQuery(sid, "ASK { ?s ?p ?o }", undefined, nuri);
return true;
} catch (error) {
console.error("[read-model] open failed for", doc, error);
return false;
}
}
/**
* OPEN/SYNC a set of docs, then read the LOCAL UNION with ONE anchorless
* `sparql_query`, grouped per subject. `docs` are the NURIs to bring into the
* union (the consumer resolves them by need — index for public, own scope docs
* for mine). Docs that fail to open are skipped (see {@link openDoc}).
*
* The union query is anchorless (`anchor` undefined → LOCAL UNION) with an
* explicit `GRAPH ?g { ?s ?p ?o }` body, so it spans every opened graph and
* attributes each row to its source graph.
*/
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
const sid = await sessionId();
const unique = [...new Set(docs.filter(Boolean))];
// 1. OPEN/SYNC — per-doc, tolerant (a bad doc never aborts the whole list).
await Promise.all(unique.map((d) => openDoc(sid, d)));
if (unique.length === 0) return [];
// 2. UNION QUERY — ONE anchorless query over the local union. The union spans
// ALL opened graphs (including the shim/inbox internals), so constrain it to
// the requested subjects with a VALUES block. In the polyfill each entity's
// subject IRI IS its own document NURI (writeEntity uses the doc NURI as the
// subject), so the requested doc NURIs ARE the subjects to read — precise and
// independent of the repo_graph_name overlay suffix on ?g.
const values = unique.map((d) => `<${assertNuri(d)}>`).join(" ");
const query =
`SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o . VALUES ?s { ${values} } } }`;
let rows: Array<Record<string, { value: string } | undefined>> = [];
try {
rows = bindings(await sparqlQuery(sid, query, undefined, undefined));
} catch (error) {
console.error("[read-model] union query failed:", error);
return [];
}
const bySubject = new Map<string, UnionSubject>();
for (const row of rows) {
const s = row.s?.value;
const p = row.p?.value;
const o = row.o?.value;
const g = row.g?.value ?? "";
if (!s || !p || o === undefined) continue;
let entry = bySubject.get(s);
if (!entry) {
entry = { subject: s, graph: g, props: {} };
bySubject.set(s, entry);
}
(entry.props[p] ??= []).push(o);
}
return [...bySubject.values()];
}