feat(client): read via per-doc ANCHORED queries; document virtual vs physical wallet
The anchorless union query (`GRAPH ?g`) scanned EVERY named graph in the local
store (the whole shared physical wallet) → O(wallet size), slow/timeouts on a
bloated wallet. Rewrite `readUnion` to run ONE ANCHORED `sparql_query` per by-need
doc (in parallel, per-doc tolerant): an anchored query is restricted to that
repo's graph, so it is O(1) per doc, INDEPENDENT of physical-wallet size. Keep the
ReadCap defense-in-depth gate.
docs/simulation.md: new "Physical wallet vs virtual wallet" section — the physical
shared wallet is a substrate that accumulates and must NEVER be enumerated/scanned;
each user's VIRTUAL wallet (the account's scope index in the shim) is the bounded
thing you enumerate ("list my documents"), then read those docs per-doc anchored.
read-model.md / nextgraph-current-state.md updated to the per-doc anchored rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,32 +2,28 @@ 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.
|
||||
// A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o
|
||||
// WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is
|
||||
// NO anchorless union scan: each doc is read independently by its own anchor. Each
|
||||
// entity subject IRI IS its own document NURI (writeEntity convention), so the
|
||||
// fixture keys triples by the doc NURI and returns them for the matching anchor.
|
||||
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 },
|
||||
});
|
||||
}
|
||||
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
|
||||
// Every read is ANCHORED to one doc NURI — never anchorless.
|
||||
if (anchor === undefined) {
|
||||
throw new Error("read-model must NEVER run an anchorless (union) query");
|
||||
}
|
||||
const doc = anchor as string;
|
||||
const triples = triplesByDoc[doc];
|
||||
if (!triples) return { results: { bindings: [] } };
|
||||
const bindings = triples.map(([p, o]) => ({
|
||||
s: { value: doc },
|
||||
p: { value: p },
|
||||
o: { value: o },
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
}),
|
||||
};
|
||||
@@ -46,23 +42,26 @@ function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
|
||||
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 () => {
|
||||
test("readUnion reads each doc with its OWN anchored query (never anchorless)", 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,
|
||||
// One anchored query per doc = 2 sparql_query calls, each anchored (c[3] set).
|
||||
expect(ng.sparql_query).toHaveBeenCalledTimes(2);
|
||||
const anchored = ng.sparql_query.mock.calls.filter((c: unknown[]) => c[3] !== undefined);
|
||||
expect(anchored.length).toBe(2);
|
||||
// The anchors are exactly the requested doc NURIs.
|
||||
expect(new Set(anchored.map((c: unknown[]) => c[3]))).toEqual(
|
||||
new Set(["did:ng:o:a", "did:ng:o:b"]),
|
||||
);
|
||||
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"]);
|
||||
expect(a.graph).toBe("did:ng:o:a");
|
||||
});
|
||||
|
||||
test("readUnion groups predicates per subject", async () => {
|
||||
@@ -86,12 +85,12 @@ test("readUnion returns [] for an empty doc set (no query)", async () => {
|
||||
expect(ng.sparql_query).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("a doc that fails to open is skipped, not aborting the union", async () => {
|
||||
test("a doc that fails to read is skipped, not aborting the batch", 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;
|
||||
// Make the anchored read throw for the bad doc only.
|
||||
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");
|
||||
if (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 });
|
||||
@@ -101,6 +100,6 @@ test("a doc that fails to open is skipped, not aborting the union", async () =>
|
||||
});
|
||||
|
||||
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
|
||||
// The bad doc opened-failed but the good one still lists.
|
||||
// The bad doc failed its read but the good one still lists.
|
||||
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user