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:
Sylvain Duchesne
2026-08-10 14:50:07 +02:00
parent f378c71739
commit 7076c0cca8
7 changed files with 218 additions and 30 deletions
+22 -3
View File
@@ -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");