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
+106
View File
@@ -0,0 +1,106 @@
import { test, expect, mock } from "bun:test";
import { readUnion } from "../src/read-model";
import { configure, configureStoreRegistry } from "../src/polyfill";
// A fake `ng` whose sparql_query answers the ANCHORLESS union query with the
// triples of the requested subjects, and the anchored ASK (open step) with an
// empty result. Each entity subject IRI IS its own document NURI (writeEntity
// convention), so the fixture keys triples by the doc NURI.
function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
return {
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
sparql_query: mock(async (_sid: string, query: string, _base: unknown, anchor: unknown) => {
// The open step is `ASK { ?s ?p ?o }` with an anchor → return a truthy ASK.
if (query.startsWith("ASK")) return { boolean: true };
// The union query is anchorless (anchor undefined) with a VALUES ?s block.
if (anchor !== undefined) return { results: { bindings: [] } };
const bindings: Array<Record<string, { value: string }>> = [];
for (const [doc, triples] of Object.entries(triplesByDoc)) {
// Only surface docs whose NURI is named in the VALUES block.
if (!query.includes(`<${doc}>`)) continue;
for (const [p, o] of triples) {
bindings.push({
g: { value: `${doc}:graph` },
s: { value: doc },
p: { value: p },
o: { value: o },
});
}
}
return { results: { bindings } };
}),
};
}
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
const ng = fakeNgWith(triplesByDoc);
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeUser: (u: string) => u,
});
return ng;
}
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
test("readUnion opens each doc then runs ONE anchorless union query", async () => {
const ng = inject({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "A"]],
"did:ng:o:b": [[TYPE, `${FP}Event`], [`${FP}title`, "B"]],
});
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
// Two per-doc ASK opens + one anchorless union query = 3 sparql_query calls.
expect(ng.sparql_query).toHaveBeenCalledTimes(3);
const anchorless = ng.sparql_query.mock.calls.filter(
(c: unknown[]) => !String(c[1]).startsWith("ASK") && c[3] === undefined,
);
expect(anchorless.length).toBe(1);
expect(subjects.length).toBe(2);
const a = subjects.find((s) => s.subject === "did:ng:o:a")!;
expect(a.props[`${FP}title`]).toEqual(["A"]);
});
test("readUnion groups predicates per subject", async () => {
inject({
"did:ng:o:p": [
[TYPE, `${FP}Participation`],
[`${FP}event`, "did:ng:o:e"],
[`${FP}user`, "urn:festipod:user:x"],
],
});
const s = (await readUnion(["did:ng:o:p"]))[0]!;
expect(s.subject).toBe("did:ng:o:p");
expect(s.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
expect(s.props[`${FP}user`]).toEqual(["urn:festipod:user:x"]);
});
test("readUnion returns [] for an empty doc set (no query)", async () => {
const ng = inject({});
const subjects = await readUnion([]);
expect(subjects).toEqual([]);
expect(ng.sparql_query).toHaveBeenCalledTimes(0);
});
test("a doc that fails to open is skipped, not aborting the union", async () => {
const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] });
// Make the OPEN (ASK) throw for the bad doc only.
const orig = ng.sparql_query;
ng.sparql_query = mock(async (sid: string, query: string, base: unknown, anchor: unknown) => {
if (query.startsWith("ASK") && anchor === "did:ng:o:bad") throw new Error("RepoNotFound");
return orig(sid, query, base, anchor);
}) as any;
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeUser: (u: string) => u,
});
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
// The bad doc opened-failed but the good one still lists.
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
});