fix(client): anchored default-graph writes for inbox + scope index
Inbox deposits and the per-(account,scope) index append were written into an explicit GRAPH <plainNuri> named graph, which the real broker stores separately from the repo's default graph (repo_graph_name with overlay suffix) — so an anchored default-graph read (read-model.readDoc) never saw them and deposits did not round-trip. Drop the GRAPH wrapper: the anchor scopes the write to the repo's default graph, matching the read. Mocks updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -124,13 +124,18 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
|||||||
const fromTriple =
|
const fromTriple =
|
||||||
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
|
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
|
||||||
|
|
||||||
|
// NO explicit `GRAPH <…>` wrapper: the real broker stores each repo's triples
|
||||||
|
// under repo_graph_name(repo_id, overlay_id) — the doc NURI WITH an overlay
|
||||||
|
// suffix, not the plain NURI. An `INSERT DATA { GRAPH <plainNuri> {…} }`
|
||||||
|
// therefore targets a phantom graph and does NOT round-trip. The only
|
||||||
|
// verified-working shape is the anchored DEFAULT graph (no GRAPH clause):
|
||||||
|
// `sparqlUpdate(sid, update, targetInbox)` scopes the write to that repo's
|
||||||
|
// default graph (same shape as read-model.ts readDoc/readUnion).
|
||||||
const update = `
|
const update = `
|
||||||
INSERT DATA {
|
INSERT DATA {
|
||||||
GRAPH <${targetInbox}> {
|
<${subject}> a <${P.type}> ;
|
||||||
<${subject}> a <${P.type}> ;
|
|
||||||
<${P.payload}> "${payloadLiteral}" ;
|
<${P.payload}> "${payloadLiteral}" ;
|
||||||
<${P.ts}> "${ts}"${fromTriple} .
|
<${P.ts}> "${ts}"${fromTriple} .
|
||||||
}
|
|
||||||
}`;
|
}`;
|
||||||
await sparqlUpdate(sid, update, targetInbox);
|
await sparqlUpdate(sid, update, targetInbox);
|
||||||
}
|
}
|
||||||
@@ -145,14 +150,15 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
|||||||
*/
|
*/
|
||||||
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||||
const sid = await sessionId();
|
const sid = await sessionId();
|
||||||
|
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
|
||||||
|
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
|
||||||
|
// default graph, exactly where `post` writes.
|
||||||
const query = `
|
const query = `
|
||||||
SELECT ?payload ?ts ?from WHERE {
|
SELECT ?payload ?ts ?from WHERE {
|
||||||
GRAPH <${targetInbox}> {
|
?d a <${P.type}> ;
|
||||||
?d a <${P.type}> ;
|
<${P.payload}> ?payload ;
|
||||||
<${P.payload}> ?payload ;
|
<${P.ts}> ?ts .
|
||||||
<${P.ts}> ?ts .
|
OPTIONAL { ?d <${P.from}> ?from }
|
||||||
OPTIONAL { ?d <${P.from}> ?from }
|
|
||||||
}
|
|
||||||
}`;
|
}`;
|
||||||
const result = await sparqlQuery(sid, query, undefined, targetInbox);
|
const result = await sparqlQuery(sid, query, undefined, targetInbox);
|
||||||
const deposits: Deposit[] = [];
|
const deposits: Deposit[] = [];
|
||||||
|
|||||||
@@ -422,9 +422,12 @@ export async function createEntityDoc(username: string, scope: Scope): Promise<N
|
|||||||
try {
|
try {
|
||||||
await sparqlUpdate(
|
await sparqlUpdate(
|
||||||
s.sessionId,
|
s.sessionId,
|
||||||
// indexDoc is a NURI in IRI position → assertNuri; entityNuri is a NURI
|
// NO explicit `GRAPH <…>` wrapper: the real broker stores each repo's
|
||||||
// stored as a literal → escapeLiteral.
|
// triples under repo_graph_name(repo_id, overlay_id), not the plain NURI,
|
||||||
`INSERT DATA { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" } }`,
|
// so an INSERT into `GRAPH <plainNuri>` targets a phantom graph and does
|
||||||
|
// NOT round-trip. Write the anchored DEFAULT graph instead (the `indexDoc`
|
||||||
|
// anchor scopes it). entityNuri is a NURI stored as a literal → escapeLiteral.
|
||||||
|
`INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
|
||||||
indexDoc,
|
indexDoc,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -440,7 +443,9 @@ async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
|
|||||||
try {
|
try {
|
||||||
const res = await sparqlQuery(
|
const res = await sparqlQuery(
|
||||||
s.sessionId,
|
s.sessionId,
|
||||||
`SELECT ?e WHERE { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`,
|
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see
|
||||||
|
// the note in createEntityDoc). The `indexDoc` anchor scopes the query.
|
||||||
|
`SELECT ?e WHERE { <${INDEX_SUBJECT}> <${P.contains}> ?e }`,
|
||||||
undefined,
|
undefined,
|
||||||
indexDoc,
|
indexDoc,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,10 +61,23 @@ function makeFakeNg() {
|
|||||||
|
|
||||||
const sparql_update = mock(async (...a: unknown[]) => {
|
const sparql_update = mock(async (...a: unknown[]) => {
|
||||||
const query = a[1] as string;
|
const query = a[1] as string;
|
||||||
|
const anchor = a[2] as string | undefined;
|
||||||
|
// TWO shapes coexist: the shim account write STILL uses `GRAPH <${priv}>`
|
||||||
|
// (the private-store repo's graph name equals the plain store NURI → it
|
||||||
|
// round-trips; key by that GRAPH IRI). The inbox deposit write has NO
|
||||||
|
// explicit GRAPH — the real broker keys it by the ANCHORED repo's default
|
||||||
|
// graph (repo_graph_name(id, overlay)); key it by the ANCHOR arg (a[2]).
|
||||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||||
if (!gm) return undefined;
|
let g: string;
|
||||||
const g = gm[1]!;
|
let body: string;
|
||||||
const body = gm[2]!;
|
if (gm) {
|
||||||
|
g = gm[1]!;
|
||||||
|
body = gm[2]!;
|
||||||
|
} else {
|
||||||
|
if (!anchor) return undefined;
|
||||||
|
g = anchor;
|
||||||
|
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||||
|
}
|
||||||
const sm = body.match(/<([^>]+)>/);
|
const sm = body.match(/<([^>]+)>/);
|
||||||
if (!sm) return undefined;
|
if (!sm) return undefined;
|
||||||
const s = sm[1]!;
|
const s = sm[1]!;
|
||||||
|
|||||||
@@ -60,12 +60,19 @@ function makeFakeNg() {
|
|||||||
const doc_create = mock(async (..._a: unknown[]) => "did:ng:o:new");
|
const doc_create = mock(async (..._a: unknown[]) => "did:ng:o:new");
|
||||||
|
|
||||||
// Parses one deposit: `<subj> a <Deposit> ; <payload> "..." ; <ts> "..." [; <from> "..."] .`
|
// Parses one deposit: `<subj> a <Deposit> ; <payload> "..." ; <ts> "..." [; <from> "..."] .`
|
||||||
|
//
|
||||||
|
// The REAL broker keys triples by the ANCHORED repo's default graph, not by an
|
||||||
|
// explicit `GRAPH <…>` IRI (repo_graph_name(repo_id, overlay_id)). So this mock
|
||||||
|
// keys stored quads by the ANCHOR arg (a[2]) — the default graph of the anchored
|
||||||
|
// repo — and REJECTS any explicit `GRAPH <…>` wrapper, so the old wrong shape
|
||||||
|
// does NOT round-trip and can never regress silently.
|
||||||
const sparql_update = mock(async (...a: unknown[]) => {
|
const sparql_update = mock(async (...a: unknown[]) => {
|
||||||
const query = a[1] as string;
|
const query = a[1] as string;
|
||||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
const anchor = a[2] as string | undefined;
|
||||||
if (!gm) return undefined;
|
if (/GRAPH\s*</.test(query)) return undefined; // explicit-GRAPH write → dropped
|
||||||
const g = gm[1]!;
|
if (!anchor) return undefined;
|
||||||
const body = gm[2]!;
|
const g = anchor;
|
||||||
|
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||||
const sm = body.match(/<([^>]+)>/);
|
const sm = body.match(/<([^>]+)>/);
|
||||||
if (!sm) return undefined;
|
if (!sm) return undefined;
|
||||||
const s = sm[1]!;
|
const s = sm[1]!;
|
||||||
@@ -140,7 +147,9 @@ test("post writes via the real injected ng.sparql_update (not makeNg), scoped to
|
|||||||
const call = fake.sparql_update.mock.calls[0]!;
|
const call = fake.sparql_update.mock.calls[0]!;
|
||||||
expect(call[0]).toBe("sid-1"); // sessionId from the injected session
|
expect(call[0]).toBe("sid-1"); // sessionId from the injected session
|
||||||
expect(call[2]).toBe(TARGET); // anchored to the target inbox
|
expect(call[2]).toBe(TARGET); // anchored to the target inbox
|
||||||
expect(call[1] as string).toContain(`GRAPH <${TARGET}>`);
|
// The write targets the anchored DEFAULT graph — NO explicit `GRAPH <…>`
|
||||||
|
// wrapper (which the real broker would route to a phantom graph).
|
||||||
|
expect(call[1] as string).not.toContain("GRAPH <");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("post → read round-trips payload, from and ts", async () => {
|
test("post → read round-trips payload, from and ts", async () => {
|
||||||
|
|||||||
@@ -72,10 +72,26 @@ function makeFakeNg() {
|
|||||||
// injection escaping keeps the query well-formed.
|
// injection escaping keeps the query well-formed.
|
||||||
const sparql_update = mock(async (...a: unknown[]) => {
|
const sparql_update = mock(async (...a: unknown[]) => {
|
||||||
const query = a[1] as string;
|
const query = a[1] as string;
|
||||||
|
const anchor = a[2] as string | undefined;
|
||||||
|
// TWO shapes coexist here:
|
||||||
|
// - the shim account write STILL uses `GRAPH <${privateStore}>` — the
|
||||||
|
// private STORE repo's graph name equals the plain store NURI, so it
|
||||||
|
// round-trips (login must not regress). Key it by that GRAPH IRI.
|
||||||
|
// - the per-entity INDEX write has NO explicit GRAPH — the real broker keys
|
||||||
|
// it by the ANCHORED repo's default graph (repo_graph_name(id, overlay)),
|
||||||
|
// so key it by the ANCHOR arg (a[2]). An explicit `GRAPH <indexDoc>`
|
||||||
|
// would target a phantom graph → must NOT round-trip.
|
||||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||||
if (!gm) return undefined;
|
let g: string;
|
||||||
const g = gm[1]!;
|
let body: string;
|
||||||
const body = gm[2]!;
|
if (gm) {
|
||||||
|
g = gm[1]!;
|
||||||
|
body = gm[2]!;
|
||||||
|
} else {
|
||||||
|
if (!anchor) return undefined;
|
||||||
|
g = anchor;
|
||||||
|
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||||
|
}
|
||||||
// Subject is the first <...> token in the body.
|
// Subject is the first <...> token in the body.
|
||||||
const sm = body.match(/<([^>]+)>/);
|
const sm = body.match(/<([^>]+)>/);
|
||||||
if (!sm) return undefined;
|
if (!sm) return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user