refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph

Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont
ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever
l'ambiguïté à chaque phrase — et le contrat publié, lu par une application,
était le pire endroit pour laisser traîner ça.

packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill,
contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts →
e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md.

Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes,
y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est
fait occurrence par occurrence, pas par substitution.

Le contrat énonce désormais son identité en une phrase : « This package is a
polyfill of NextGraph's SDK. »
This commit is contained in:
Sylvain Duchesne
2026-08-10 17:14:25 +02:00
parent 49b046268e
commit 737729c9ce
88 changed files with 122 additions and 106 deletions
+267
View File
@@ -0,0 +1,267 @@
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([]);
});