Files
ng-eventually/packages/polyfill/test/read-document.test.ts
Sylvain Duchesne 3a8941159f fix: le filtre de lecture couvre l-enveloppe que le SDK renvoie vraiment
Le `useShape` publie enveloppait le resultat du hook dans le filtre ecrit pour
une COLLECTION. Depuis que `@ng-org/orm` renvoie un `UseShapeResult` autour du
set, `[...resultat]` et `.size` levaient, et `data`, `promise` et `subscription`
revenaient BRUTS - chacun tous les objets de toutes les identites, sans que rien
ne casse. Aucun faux de ce depot ne renvoyait la forme livree, donc aucune garde
ne pouvait le voir.

Deux vues au lieu d-une : celle de la collection, et celle de l-enveloppe que
`surface/use-shape.ts` applique. `data` et `promise` sont filtres, `subscription`
est refuse (c-est le handle de l-ORM, que cette surface ne publie pas), les deux
commandes de page passent.

Le defaut par TYPE et non par une liste de noms : un primitif ne peut atteindre
aucun objet et passe, un objet ou une fonction refuse tant qu-il n-existe pas de
forme filtree ici. C-est pourquoi `isLoading` n-est nomme nulle part, et pourquoi
un membre qu-une version suivante ajoutera est deja tranche. `size` et `length`
comptent ce que le porteur peut lire.

`UseShapeLike` etait `(...args: any[]) => any`, ce qui est la raison pour
laquelle les deux typechecks restaient a 0 pendant que la fuite etait ouverte :
aucune declaration ne disait ce que le hook rend. Il rend `ShapeResult`, releve
sur le `.d.ts` installe, et le vrai `reactUseShape` y est assignable sans cast.

Le faux partage `test/use-shape-fake.ts` renvoie la forme livree et c-est celui
que les deux gardes injectent - unitaire et e2e. La garantie du contrat et le
test qui l-epingle arrivent ensemble ; `1.0.0-dev.11`, majeur.
2026-09-24 18:27:02 +02:00

312 lines
14 KiB
TypeScript

/**
* read-document.test.ts - the internal single-document read (`src/surface/read-document.ts`).
*
* This file was `read-model.test.ts` until `1.0.0-dev.8`, when the published `readUnion`
* went. Three of its cases tested the BATCH POLICY rather than the read, and a batch no
* longer exists, so each was re-pointed at the property that replaced it rather than
* dropped:
*
* - "a doc that fails to read is SKIPPED, not aborting the batch" -> a read that fails
* THROWS. With no other document to carry on for, swallowing a failure would leave
* "it failed" and "it is empty" as the same answer, which is the family of defect
* this package has closed repeatedly.
* - "returns [] for an empty doc SET (no query)" -> a document that holds nothing is
* read, answers, and returns `[]`. That is the surviving distinction: an empty ANSWER.
* - "the same subject IRI in two documents stays two entries, told apart by `graph`" ->
* two reads, each reporting its own document. Nothing can merge across documents any
* more, because nothing shares a call; `graph` went with the batch, since the caller
* names exactly one document and already holds its reference.
*
* One BEHAVIOUR CHANGE is pinned below rather than left to be discovered: a document the
* holder has no capability for used to be dropped silently from the batch, and now makes
* the read THROW - the refusal belongs to `docs.sparqlQuery`, which is the published door
* this read goes through and which has always refused out loud.
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { makeUseShapeHook } from "./use-shape-fake";
import { test, expect, mock, afterAll } from "bun:test";
import { readDocumentSubjects } from "../src/surface/read-document";
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";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
// 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();
resetOpenedRepos();
resetPublicStoreFetches();
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: a document is read 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-document 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();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rd", 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("reads the document 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 a = await readDocumentSubjects("did:ng:o:a");
const b = await readDocumentSubjects("did:ng:o:b");
// One anchored query per read = 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 documents that were named.
expect(anchored.map((c: unknown[]) => c[3])).toEqual(["did:ng:o:a", "did:ng:o:b"]);
expect(a.map((s) => s.subject)).toEqual(["did:ng:o:a"]);
expect(a[0]!.props[`${FP}title`]).toEqual(["A"]);
expect(b[0]!.props[`${FP}title`]).toEqual(["B"]);
});
test("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 readDocumentSubjects("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("a document that holds nothing is READ, answers, and returns []", async () => {
// The surviving half of the old "empty doc set" case, and the one that matters: an
// empty ANSWER is not a failure, and the query really was issued to obtain it. A caller
// deciding "absent versus unreadable" depends on exactly that difference.
const ng = inject({});
expect(await readDocumentSubjects("did:ng:o:empty")).toEqual([]);
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
});
test("a read that FAILS throws, rather than reading as an empty document", 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;
resetCaps();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rd", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
// The batch used to swallow this one and carry on with the good document. There is no
// "carry on" left, so the failure reaches the caller as a failure — which is the ONLY
// thing that keeps it distinguishable from a document that is genuinely empty.
await expect(readDocumentSubjects("did:ng:o:bad")).rejects.toThrow(/RepoNotFound/);
// The neighbour is unaffected: nothing is shared between two reads.
expect((await readDocumentSubjects("did:ng:o:ok")).map((s) => s.subject)).toEqual([
"did:ng:o:ok",
]);
});
// The possession gate: once ANY cap exists, a document whose cap is not in what the
// current holder holds is REFUSED — however well its NURI resolves. Before the first cap
// the gate is inert (no regression).
test("a document whose cap the holder does not hold is refused, out loud", async () => {
inject({
"did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]],
"did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]],
});
const mine = "did:ng:o:mine" as Nuri;
const theirs = "did:ng:o:theirs" as Nuri;
// Inert: no cap issued yet → everything flows through.
expect((await readDocumentSubjects(mine)).map((s) => s.subject)).toEqual([mine]);
expect((await readDocumentSubjects(theirs)).map((s) => s.subject)).toEqual([theirs]);
// One cap issued → possession is now the rule for every document. BEHAVIOUR CHANGE:
// the batch dropped the unheld document silently and answered with the rest; a single
// read has no rest, so `docs.sparqlQuery` refuses it the way it always has — loudly.
setCurrentUser("alice");
getCaps().mint(mine);
expect((await readDocumentSubjects(mine)).map((s) => s.subject)).toEqual([mine]);
await expect(readDocumentSubjects(theirs)).rejects.toThrow(/does not hold this document/i);
// …and for every holder: bob holds nothing, so bob reads nothing.
setCurrentUser("bob");
await expect(readDocumentSubjects(mine)).rejects.toThrow(/does not hold this document/i);
});
test("a malformed reference is refused, loudly", async () => {
// A non-reference is a caller mistake worth a loud error, never an empty read: swallowing
// it is how a mistake becomes an absence nobody can explain. The list tolerance this
// used to sit beside went with the batch — there is no list to carry a hole any more.
inject({});
await expect(readDocumentSubjects("not-a-nuri" as 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-document 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();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
configure({ ng, useShape: makeUseShapeHook().hook });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rd", 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 readDocumentSubjects("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"]);
});
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 readDocumentSubjects("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");
});
test("the SAME subject IRI in two documents is never merged - two reads, two answers", 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 a = await readDocumentSubjects("did:ng:o:a");
const b = await readDocumentSubjects("did:ng:o:b");
// Two documents carrying one subject IRI are two objects. The batch had to say so with
// a `graph` field on every entry; a read that names ONE document says it structurally,
// because nothing of one read's answer can reach the other's.
expect(a.map((s) => s.subject)).toEqual(["urn:app:shared"]);
expect(b.map((s) => s.subject)).toEqual(["urn:app:shared"]);
expect(a[0]!.props[`${FP}title`]).toEqual(["in-a"]);
expect(b[0]!.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 readDocumentSubjects("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 readDocumentSubjects("did:ng:o:d")).toEqual([]);
});