diff --git a/packages/client/src/inbox.ts b/packages/client/src/inbox.ts index b715d55..bdf4828 100644 --- a/packages/client/src/inbox.ts +++ b/packages/client/src/inbox.ts @@ -124,13 +124,18 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise const fromTriple = 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 {…} }` + // 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 = ` INSERT DATA { - GRAPH <${targetInbox}> { - <${subject}> a <${P.type}> ; + <${subject}> a <${P.type}> ; <${P.payload}> "${payloadLiteral}" ; <${P.ts}> "${ts}"${fromTriple} . - } }`; await sparqlUpdate(sid, update, targetInbox); } @@ -145,14 +150,15 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise */ export async function read(targetInbox: Nuri): Promise { 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 = ` SELECT ?payload ?ts ?from WHERE { - GRAPH <${targetInbox}> { - ?d a <${P.type}> ; - <${P.payload}> ?payload ; - <${P.ts}> ?ts . - OPTIONAL { ?d <${P.from}> ?from } - } + ?d a <${P.type}> ; + <${P.payload}> ?payload ; + <${P.ts}> ?ts . + OPTIONAL { ?d <${P.from}> ?from } }`; const result = await sparqlQuery(sid, query, undefined, targetInbox); const deposits: Deposit[] = []; diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index 552e7c2..ad1a77b 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -422,9 +422,12 @@ export async function createEntityDoc(username: string, scope: Scope): Promise { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" } }`, + // NO explicit `GRAPH <…>` wrapper: the real broker stores each repo's + // triples under repo_graph_name(repo_id, overlay_id), not the plain NURI, + // so an INSERT into `GRAPH ` 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, ); } catch (error) { @@ -440,7 +443,9 @@ async function readScopeIndex(indexDoc: Nuri): Promise { try { const res = await sparqlQuery( 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, indexDoc, ); diff --git a/packages/client/test/discovery.test.ts b/packages/client/test/discovery.test.ts index 5c2a6a6..51be219 100644 --- a/packages/client/test/discovery.test.ts +++ b/packages/client/test/discovery.test.ts @@ -61,10 +61,23 @@ function makeFakeNg() { const sparql_update = mock(async (...a: unknown[]) => { 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]*)\}/); - if (!gm) return undefined; - const g = gm[1]!; - const body = gm[2]!; + let g: string; + let body: string; + 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(/<([^>]+)>/); if (!sm) return undefined; const s = sm[1]!; diff --git a/packages/client/test/inbox.test.ts b/packages/client/test/inbox.test.ts index 4c96f41..182baa7 100644 --- a/packages/client/test/inbox.test.ts +++ b/packages/client/test/inbox.test.ts @@ -60,12 +60,19 @@ function makeFakeNg() { const doc_create = mock(async (..._a: unknown[]) => "did:ng:o:new"); // Parses one deposit: ` a ; "..." ; "..." [; "..."] .` + // + // 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 query = a[1] as string; - const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); - if (!gm) return undefined; - const g = gm[1]!; - const body = gm[2]!; + const anchor = a[2] as string | undefined; + if (/GRAPH\s*]+)>/); if (!sm) return undefined; 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]!; expect(call[0]).toBe("sid-1"); // sessionId from the injected session 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 () => { diff --git a/packages/client/test/store-registry.test.ts b/packages/client/test/store-registry.test.ts index 27907a3..ab1912e 100644 --- a/packages/client/test/store-registry.test.ts +++ b/packages/client/test/store-registry.test.ts @@ -72,10 +72,26 @@ function makeFakeNg() { // injection escaping keeps the query well-formed. const sparql_update = mock(async (...a: unknown[]) => { 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 ` + // would target a phantom graph → must NOT round-trip. const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); - if (!gm) return undefined; - const g = gm[1]!; - const body = gm[2]!; + let g: string; + let body: string; + 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. const sm = body.match(/<([^>]+)>/); if (!sm) return undefined;