fix: readUnion regroupe par sujet réel — la fusion était une divergence
`readUnion` indexait par DOCUMENT une table nommée `bySubject`, créait ses entrées avec `subject: doc`, et jetait le sujet réellement lu après s'en être servi pour écarter la machinerie. Tout triplet non-machinerie d'un document tombait donc dans un sac unique étiqueté par la référence du document : deux entités écrites sous deux sujets revenaient **conflées**, une entité écrite sous un autre sujet revenait **ré-étiquetée**. Sans erreur, sans trace. **C'était une divergence, et c'est à ce titre qu'elle tombe.** NextGraph dit l'inverse aux deux niveaux : une requête ancrée résout le graphe du repo comme graphe par défaut et rend les sujets tels qu'ils sont ; et l'ORM porte sur chaque objet **deux** propriétés distinctes, `@id` et `@graph`, dont il FABRIQUE la première quand on la laisse vide (`graphIri + ":q:" + aléa`). Plusieurs objets par graphe est le cas prévu, et `@id` existe pour les distinguer à l'intérieur d'un `@graph`. La règle du projet reste **« un document séparé par entité métier »**, mais c'est une recommandation de placement dictée par le modèle de sécurité — une clé est par repo, donc l'isolation par entité exige un repo par entité. Ce n'est pas une contrainte que la lecture a le droit d'imposer en rendant l'autre disposition invisible. Le contrat porte désormais la recommandation, le code porte la capacité ; il faisait exactement l'inverse. **La justification de l'épinglage était une erreur de catégorie**, et elle a été retirée plutôt que contournée : `repo_graph_name` formate un nom de GRAPHE, il est estampillé sur les quads et aucun sujet n'est réécrit. Deux confirmations indépendantes, dont la suite e2e qui écrit un sujet puis le relit par correspondance exacte contre le vrai broker. `UnionSubject.subject` passe de `Nuri` à `string` — un sujet RDF réel est un IRI quelconque. `graph` reste `Nuri` et devient le champ à repasser au SDK ; l'app d'exemple l'utilise à ses deux sites, où le sens était « le document ». **Et la suite e2e ne comptait que les entrées.** C'est pour cela qu'elle est restée verte pendant tout le défaut : compter ne distingue pas un regroupement par document d'un regroupement par sujet. Elle écrit maintenant deux sujets dans le dernier document et vérifie les trois choses qui comptent — quatre entrées pour trois documents, chaque entrée portant le sujet sous lequel elle a été écrite, et son `graph` étant la référence du document. 202 tests unitaires (5 ajoutés, dont 3 échouent si l'on restaure l'ancien repliage), e2e 42/42 et applicatif 12/12.
This commit is contained in:
+22
-3
@@ -214,13 +214,32 @@ async function main(): Promise<void> {
|
||||
|
||||
// ── read-model ──────────────────────────────────────────────────────────
|
||||
console.log("\n── read-model ──");
|
||||
await step("readUnion over N docs → per-doc subjects", async () => {
|
||||
await step("readUnion returns one entry per SUBJECT, with the subject it was written under", async () => {
|
||||
// 3 documents, and the last carries TWO subjects → 4 entries, not 3. Counting alone
|
||||
// could not distinguish grouping-by-document from grouping-by-subject, which is why
|
||||
// this step stayed green while `readUnion` conflated them (fixed 2026-08-10).
|
||||
const r = await sdk<any>(frame, "readUnionOverDocs", 3, false);
|
||||
check("readUnion returns one subject per doc", r.subjectCount === 3, `subjects=${r.subjectCount}/3`);
|
||||
const iris: string[] = r.subjectIris ?? [];
|
||||
check(
|
||||
"one entry per subject, not per document",
|
||||
r.subjectCount === 4 && iris.includes("urn:e2e:rm:extra"),
|
||||
`entries=${r.subjectCount}/4 subjects=${JSON.stringify(iris)}`,
|
||||
);
|
||||
check(
|
||||
"each entry carries the subject it was written under, not the document",
|
||||
iris.every((s) => s.startsWith("urn:e2e:rm:")),
|
||||
JSON.stringify(iris),
|
||||
);
|
||||
check(
|
||||
"…and its `graph` is the document reference",
|
||||
(r.graphs ?? []).every((g: string) => g.startsWith("did:ng:")),
|
||||
JSON.stringify(r.graphs),
|
||||
);
|
||||
});
|
||||
await step("readUnion per-doc tolerance (bad NURI skipped)", async () => {
|
||||
const r = await sdk<any>(frame, "readUnionOverDocs", 2, true);
|
||||
check("bad NURI does not abort the batch", r.subjectCount === 2, `subjects=${r.subjectCount}/2 (+1 bad)`);
|
||||
// 2 documents, the last carrying two subjects → 3 entries.
|
||||
check("bad NURI does not abort the batch", r.subjectCount === 3, `entries=${r.subjectCount}/3 (+1 bad NURI)`);
|
||||
});
|
||||
await step("readUnion cap gate", async () => {
|
||||
const r = await sdk<any>(frame, "readUnionCapGate");
|
||||
|
||||
@@ -345,9 +345,27 @@ const identity = new IdentityStore(
|
||||
);
|
||||
docNuris.push(d);
|
||||
}
|
||||
// Two SUBJECTS in the LAST document, so the probe can tell "one entry per document"
|
||||
// from "one entry per subject". Until 2026-08-10 `readUnion` folded every triple of a
|
||||
// document into one bag keyed by the document, and this step stayed green throughout
|
||||
// because it only ever counted entries and never looked at what they were.
|
||||
if (n > 0) {
|
||||
await docs.sparqlUpdate(
|
||||
s.session_id,
|
||||
`INSERT DATA { <urn:e2e:rm:extra> <urn:e2e:idx> "extra" }`,
|
||||
docNuris[n - 1]!,
|
||||
);
|
||||
}
|
||||
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
|
||||
const subjects = await readUnion(toRead);
|
||||
return { docNuris, subjectCount: subjects.length, subjects };
|
||||
return {
|
||||
docNuris,
|
||||
subjectCount: subjects.length,
|
||||
// What each entry actually IS — the assertion the count could not make.
|
||||
subjectIris: subjects.map((x) => x.subject),
|
||||
graphs: subjects.map((x) => x.graph),
|
||||
subjects,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* readUnion possession gate: create a doc as owner O (whose keyring gets its
|
||||
|
||||
@@ -59,17 +59,26 @@ void sparqlUpdate;
|
||||
/** One subject read from a doc, with its properties (predicate → values). */
|
||||
export interface UnionSubject {
|
||||
/**
|
||||
* The subject IRI (`?s`) — in the polyfill, the doc's own NURI.
|
||||
* The subject IRI (`?s`), exactly as the document carries it.
|
||||
*
|
||||
* Typed `Nuri`, not `string`: both fields are always document references here (the
|
||||
* read is anchored per document and the subject is pinned to the anchor), and typing
|
||||
* them loosely forced a consumer to cast whatever it had just read before it could
|
||||
* pass it back — `shareNote(note.doc)`, `leaveMessage(note.doc)`. A cast at that
|
||||
* boundary re-opens exactly the confusion the template literal types exist to close.
|
||||
* Found by writing the example application (`examples/notebook`).
|
||||
* Typed `string`, not `Nuri`: a subject is an ordinary RDF subject and may be ANY
|
||||
* IRI. An application that writes its entity under the document's own NURI gets a
|
||||
* NURI back here, but that is its convention, not this type's promise — a document
|
||||
* may hold several subjects under IRIs of the consumer's choosing, and each comes
|
||||
* back as written.
|
||||
*
|
||||
* The need that once made this `Nuri` is real and is met by {@link graph}: a
|
||||
* consumer must be able to pass back what it just read without a cast —
|
||||
* `shareNote(note.doc)`, `leaveMessage(note.doc)` — and a cast at that boundary
|
||||
* would re-open exactly the confusion the template literal types exist to close.
|
||||
* `graph` is the document reference, so that is the field to carry around.
|
||||
*/
|
||||
subject: string;
|
||||
/**
|
||||
* The document this subject was read from — the reference the caller passed to
|
||||
* {@link readUnion}, unchanged. This is the anchor, it identifies the document
|
||||
* stably, and it is what goes back into any call of this surface.
|
||||
*/
|
||||
subject: Nuri;
|
||||
/** The graph (doc NURI) the subject was read from. */
|
||||
graph: Nuri;
|
||||
/** predicate IRI → the list of object values (literals or IRIs) for it. */
|
||||
props: Record<string, string[]>;
|
||||
@@ -142,6 +151,13 @@ async function readDoc(
|
||||
* Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the
|
||||
* batch.
|
||||
*
|
||||
* A document holding SEVERAL subjects yields several entries — one per distinct
|
||||
* subject, carrying that subject as written, all sharing the document as their
|
||||
* `graph`. Properties of different subjects are never merged. Placing one business
|
||||
* entity per document stays the recommended practice (a key is per repo, so
|
||||
* isolation needs a repo per entity), but it is a recommendation about writing: the
|
||||
* read reports what is there.
|
||||
*
|
||||
* Never an anchorless union-scan over all graphs (which is O(wallet size) and wrong
|
||||
* on a shared / bloated wallet — the footgun this path exists to avoid). Each doc is
|
||||
* read with an anchored default-graph query, O(1) per doc, independent of wallet
|
||||
@@ -182,29 +198,44 @@ export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
|
||||
);
|
||||
|
||||
// Possession gate, kept as defence in depth behind rule 2 above: `reachable`
|
||||
// already excluded these, so this loop should never drop anything. In this
|
||||
// polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI.
|
||||
// already excluded these, so this loop should never drop anything. The read is
|
||||
// anchored per document, so the unit of possession is the document: the cap key is
|
||||
// the doc NURI, whatever subjects that document turns out to carry.
|
||||
const caps = getCaps();
|
||||
|
||||
// Keyed by (document, subject) — an entry is one subject INSIDE one graph, which is
|
||||
// the identity upstream gives an object too: the ORM carries `@id` and `@graph` as
|
||||
// two distinct read-only properties, and fabricates an `@id` when the writer leaves
|
||||
// it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`). Several objects per
|
||||
// graph is therefore the PROVIDED case, and `@id` is what tells them apart within a
|
||||
// `@graph`. Two documents carrying the same subject IRI stay two entries: they are
|
||||
// two objects, distinguished by their graph.
|
||||
//
|
||||
// Placing one business entity per document remains the recommended practice — a key
|
||||
// is per repo, so isolating an entity requires a repo of its own. That is a
|
||||
// recommendation about WRITING, and the read does not get to enforce it by making
|
||||
// the other arrangement invisible.
|
||||
const bySubject = new Map<string, UnionSubject>();
|
||||
for (const { doc, rows } of perDoc) {
|
||||
if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue;
|
||||
// Anchored to `doc`, so every row belongs to `doc`; the subject is the doc NURI
|
||||
// (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which
|
||||
// is stable regardless of the repo_graph_name overlay suffix the store carries.
|
||||
// Anchored to `doc`, so every row belongs to `doc` — hence `graph` is the caller's
|
||||
// reference to it. The subject comes back exactly as the document carries it.
|
||||
for (const row of rows) {
|
||||
// The polyfill's own compartments live as reserved SUBJECTS inside the very
|
||||
// documents the consumer reads (the Header branch carrying a document's inbox
|
||||
// address is the first). They are machinery, not this entity's properties —
|
||||
// drop them here, once, for every compartment present and future.
|
||||
if (isMachinerySubject(row.s?.value)) continue;
|
||||
const s = row.s?.value;
|
||||
if (isMachinerySubject(s)) continue;
|
||||
const p = row.p?.value;
|
||||
const o = row.o?.value;
|
||||
if (!p || o === undefined) continue;
|
||||
let entry = bySubject.get(doc);
|
||||
if (s === undefined || !p || o === undefined) continue;
|
||||
// NUL cannot appear in an IRI, so the pair never collides with either half.
|
||||
const key = `${doc}\u0000${s}`;
|
||||
let entry = bySubject.get(key);
|
||||
if (!entry) {
|
||||
entry = { subject: doc, graph: doc, props: {} };
|
||||
bySubject.set(doc, entry);
|
||||
entry = { subject: s, graph: doc, props: {} };
|
||||
bySubject.set(key, entry);
|
||||
}
|
||||
(entry.props[p] ??= []).push(o);
|
||||
}
|
||||
|
||||
@@ -151,3 +151,117 @@ test("readUnion tolerates holes in the list, and refuses a malformed reference",
|
||||
await expect(readUnion(["", null as never, undefined as never])).resolves.toEqual([]);
|
||||
await expect(readUnion(["not-a-nuri"])).rejects.toThrow(/not a NextGraph reference/i);
|
||||
});
|
||||
|
||||
// ── several subjects inside one document ───────────────────────────────────
|
||||
//
|
||||
// The fixture above pins every row's subject to the doc NURI, which is what an
|
||||
// application writing one entity per document produces — the recommended placement,
|
||||
// and the only case it can exercise. A document may nevertheless carry SEVERAL
|
||||
// subjects, and upstream that is the provided case, not an accident: the ORM carries
|
||||
// `@id` and `@graph` as two distinct read-only properties and fabricates an `@id`
|
||||
// when the writer leaves it empty, precisely so objects sharing a `@graph` stay
|
||||
// distinguishable. This fixture lets the subject vary so that case can be tested.
|
||||
function injectTriples(triplesByDoc: Record<string, Array<[string, string, string]>>) {
|
||||
const ng = {
|
||||
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) => {
|
||||
if (anchor === undefined) {
|
||||
throw new Error("read-model must NEVER run an anchorless (union) query");
|
||||
}
|
||||
const triples = triplesByDoc[anchor as string];
|
||||
if (!triples) return { results: { bindings: [] } };
|
||||
return {
|
||||
results: {
|
||||
bindings: triples.map(([s, p, o]) => ({
|
||||
s: { value: s },
|
||||
p: { value: p },
|
||||
o: { value: o },
|
||||
})),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
configure({ ng, useShape: () => undefined });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
||||
normalizeId: (u: string) => u,
|
||||
});
|
||||
return ng;
|
||||
}
|
||||
|
||||
test("two subjects in ONE document come back as two entries, unmixed", async () => {
|
||||
// The document holds two entities under two IRIs of the consumer's choosing.
|
||||
injectTriples({
|
||||
"did:ng:o:d": [
|
||||
["urn:app:alice", TYPE, `${FP}Person`],
|
||||
["urn:app:alice", `${FP}title`, "A"],
|
||||
["urn:app:bob", TYPE, `${FP}Person`],
|
||||
["urn:app:bob", `${FP}title`, "B"],
|
||||
],
|
||||
});
|
||||
|
||||
const subjects = await readUnion(["did:ng:o:d"]);
|
||||
|
||||
// Under the old per-document fold this was ONE entry subject'd `did:ng:o:d`,
|
||||
// whose `${FP}title` carried BOTH ["A","B"] — two entities conflated into one bag,
|
||||
// and both re-labelled with the document's own NURI.
|
||||
expect(subjects.length).toBe(2);
|
||||
expect(subjects.map((s) => s.subject).sort()).toEqual(["urn:app:alice", "urn:app:bob"]);
|
||||
|
||||
const alice = subjects.find((s) => s.subject === "urn:app:alice")!;
|
||||
const bob = subjects.find((s) => s.subject === "urn:app:bob")!;
|
||||
expect(alice.props[`${FP}title`]).toEqual(["A"]);
|
||||
expect(bob.props[`${FP}title`]).toEqual(["B"]);
|
||||
|
||||
// Both were read from the same document, and `graph` is the reference the caller
|
||||
// passed — that is what identifies the document and what goes back into the SDK.
|
||||
expect(alice.graph).toBe("did:ng:o:d");
|
||||
expect(bob.graph).toBe("did:ng:o:d");
|
||||
});
|
||||
|
||||
test("a subject written under an IRI of its own is NOT re-labelled with the doc NURI", async () => {
|
||||
injectTriples({
|
||||
"did:ng:o:d": [["urn:app:thing", `${FP}title`, "T"]],
|
||||
});
|
||||
const [only] = await readUnion(["did:ng:o:d"]);
|
||||
// The old fold reported `did:ng:o:d` here, a subject the document never carried.
|
||||
expect(only!.subject).toBe("urn:app:thing");
|
||||
expect(only!.graph).toBe("did:ng:o:d");
|
||||
});
|
||||
|
||||
test("the SAME subject IRI in two documents stays two entries, told apart by graph", async () => {
|
||||
injectTriples({
|
||||
"did:ng:o:a": [["urn:app:shared", `${FP}title`, "in-a"]],
|
||||
"did:ng:o:b": [["urn:app:shared", `${FP}title`, "in-b"]],
|
||||
});
|
||||
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
|
||||
expect(subjects.length).toBe(2);
|
||||
expect(subjects.map((s) => s.graph).sort()).toEqual(["did:ng:o:a", "did:ng:o:b"]);
|
||||
// Never merged across documents: an object is identified by its subject WITHIN a graph.
|
||||
expect(subjects.find((s) => s.graph === "did:ng:o:a")!.props[`${FP}title`]).toEqual(["in-a"]);
|
||||
expect(subjects.find((s) => s.graph === "did:ng:o:b")!.props[`${FP}title`]).toEqual(["in-b"]);
|
||||
});
|
||||
|
||||
test("machinery is dropped per subject, and the real subjects beside it survive", async () => {
|
||||
injectTriples({
|
||||
"did:ng:o:d": [
|
||||
["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"],
|
||||
["urn:app:entity", `${FP}title`, "kept"],
|
||||
],
|
||||
});
|
||||
const subjects = await readUnion(["did:ng:o:d"]);
|
||||
expect(subjects.map((s) => s.subject)).toEqual(["urn:app:entity"]);
|
||||
expect(subjects[0]!.props[`${FP}title`]).toEqual(["kept"]);
|
||||
});
|
||||
|
||||
test("a document holding ONLY machinery yields no entry at all", async () => {
|
||||
injectTriples({
|
||||
"did:ng:o:d": [
|
||||
["urn:ng-eventually:shim:headerBranch", "urn:ng-eventually:p:inboxAddress", "did:ng:o:inbox"],
|
||||
],
|
||||
});
|
||||
expect(await readUnion(["did:ng:o:d"])).toEqual([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user