7076c0cca8
`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.
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
import { getCaps } from "../src/shared-wallet/bootstrap";
|
|
import { test, expect, mock, afterAll } from "bun:test";
|
|
import { readUnion } from "../src/surface/read-model";
|
|
import type { Nuri } from "../src/model/types";
|
|
import { configure } from "../src/index";
|
|
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
|
import { resetCaps } from "../src/shared-wallet/bootstrap";
|
|
|
|
// The cap registry is process-wide, so each inject() starts from an empty one:
|
|
// once ANY cap exists the possession gate is in force for every reader, and a
|
|
// suite that never declares caps must not inherit another suite's.
|
|
afterAll(() => {
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
// 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) => {
|
|
// 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 } };
|
|
}),
|
|
};
|
|
}
|
|
|
|
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
|
|
const ng = fakeNgWith(triplesByDoc);
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
|
normalizeId: (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 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"]);
|
|
|
|
// 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(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 () => {
|
|
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 read is skipped, not aborting the batch", async () => {
|
|
const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] });
|
|
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 (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" }),
|
|
normalizeId: (u: string) => u,
|
|
});
|
|
|
|
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
|
|
// The bad doc failed its read but the good one still lists.
|
|
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
|
|
});
|
|
|
|
// The possession gate, at the read-model's own level: once ANY cap exists, a doc
|
|
// whose cap is not in what the current holder holds is dropped — however well its
|
|
// NURI resolves. Before the first cap the gate is inert (no regression).
|
|
test("readUnion drops a doc whose cap the holder does not hold", async () => {
|
|
inject({
|
|
"did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]],
|
|
"did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]],
|
|
});
|
|
const both: Nuri[] = ["did:ng:o:mine", "did:ng:o:theirs"];
|
|
|
|
// Inert: no cap issued yet → everything flows through.
|
|
expect((await readUnion(both)).map((s) => s.subject).sort()).toEqual(both);
|
|
|
|
// One cap issued → possession is now the rule for every document.
|
|
setCurrentUser("alice");
|
|
getCaps().mint("did:ng:o:mine");
|
|
expect((await readUnion(both)).map((s) => s.subject)).toEqual(["did:ng:o:mine"]);
|
|
|
|
// …and for every holder: bob holds nothing, so bob reads nothing.
|
|
setCurrentUser("bob");
|
|
expect(await readUnion(both)).toEqual([]);
|
|
});
|
|
|
|
test("readUnion tolerates holes in the list, and refuses a malformed reference", async () => {
|
|
// Two different things that must not be conflated, and conflating them broke a whole
|
|
// reconnect run: an EMPTY entry is absence — a scope index can carry one, and a caller
|
|
// assembling a list from optional values should not have to compact it — while a
|
|
// non-reference is a caller mistake worth a loud error. Validating before filtering
|
|
// turned the first into the second.
|
|
inject({});
|
|
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([]);
|
|
});
|