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
+195
View File
@@ -0,0 +1,195 @@
/**
* The app-facing surface, pinned where it changed shape on 2026-08-10.
*
* Every test here exists because behaviour shipped without one and an adversarial pass
* had to find it: an application that cannot learn its own identity, a share that invents
* its recipient, a creation that reports success on a half-written document. They are
* about what a CALLER sees, not about the emulation's internals.
*/
import { test, expect, mock, afterEach } from "bun:test";
import { configure, ensureIdentity, storeRegistry } from "../src/index";
import {
resetCaps,
resetConfig,
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import { createEntityDoc as registryCreateEntityDoc, resetRegistryCache } from "../src/shared-wallet/account-registry";
import { share } from "../src/surface/inbox";
const SHIM = "urn:ng-eventually:shim";
const SESSION = { sessionId: "sid-app", privateStoreId: "PRIV-APP" };
interface Quad { g: string; s: string; p: string; o: string }
/** A stateful fake `ng`, with a switch that makes a chosen register write fail. */
function inject(failWriteMatching?: RegExp) {
const quads: Quad[] = [];
let created = 0;
const doc_create = mock(async () => `did:ng:o:doc${++created}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (failWriteMatching && failWriteMatching.test(query)) throw new Error("broker refused");
if (!anchor) return undefined;
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
const body = gm ? gm[2]! : query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const subj = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
quads.push({ g: anchor, s: subj, p: m[1] ?? `${SHIM}:Account`, o: m[2] ?? m[3] ?? "" });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
const byPred = (pred: string, v: string) => ({
results: { bindings: quads.filter((q) => q.g === anchor && q.p === pred).map((q) => ({ [v]: { value: q.o } })) },
});
if (query.includes(`${SHIM}:shimDoc`)) return byPred(`${SHIM}:shimDoc`, "shimDoc");
if (query.includes(`${SHIM}:readCap`)) return byPred(`${SHIM}:readCap`, "c");
if (query.includes(`${SHIM}:contains`)) return byPred(`${SHIM}:contains`, "e");
if (query.includes(`${SHIM}:id`)) {
// Filter by SUBJECT when the query names one — the account read asks about ONE
// account. A fake that ignores it hands back somebody else's record, and then
// "bob does not see alice's document" and "share refuses an unknown name" both
// fail for a reason that has nothing to do with the code. (Made that mistake here
// first; it is the same one this review found in the other fakes.)
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const only = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (only !== null && q.s !== only) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
})),
},
};
}
return { results: { bindings: [] } };
});
configure({
ng: { doc_create, sparql_update, sparql_query } as never,
useShape: (() => {}) as never,
getSession: async () => SESSION,
});
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return { quads };
}
afterEach(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
// ── identity ───────────────────────────────────────────────────────────────
// An application has to know which user it is — to display it, at least. Upstream it
// does: it passes `user_id` to `session_start`, having got it from the wallet it opened.
// Here the gate chooses, so the gate returns. Without this the example application read
// the gate's own private storage key, which is a boundary no consumer should see.
test("ensureIdentity returns the identity it settled", async () => {
inject();
setCurrentUser("alice");
expect(await ensureIdentity()).toBe("alice");
});
// The other half of the same decision: no placement call TAKES an identity, because a
// session belongs to one user and the target's `doc_create` carries none. Calling one
// before signing in is a caller error worth naming, not an empty result.
test("a placement call before signing in names the mistake", async () => {
inject();
setCurrentUser(null);
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/ensureIdentity/i);
await expect(storeRegistry.listMyEntityDocs("protected")).rejects.toThrow(/no identity/i);
});
test("placement acts as the connected user, with nothing passed", async () => {
inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
expect(await storeRegistry.listMyEntityDocs("protected")).toContain(doc);
setCurrentUser("bob");
expect(await storeRegistry.listMyEntityDocs("protected")).not.toContain(doc);
});
// ── sharing names someone who exists ───────────────────────────────────────
test("share refuses a recipient nobody has signed in as, instead of creating them", async () => {
inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
// "bpb" is a typo for "bob". It used to mint that name's three stores and an inbox,
// and the cap landed where nobody will ever look — with no error at all.
await expect(share(doc, "bpb")).rejects.toThrow(/no such recipient/i);
});
// …and the refusal must rest on ABSENCE, never on ignorance: `resolveAccount` answers
// `null` for a failed read as well as for a missing one, so a refusal built on it would
// tell a user "nobody has signed in as bob" because a query timed out.
test("a failed lookup surfaces as a failure, not as 'no such recipient'", async () => {
const { quads } = inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
setCurrentUser("bob");
await registryCreateEntityDoc("bob", "private"); // bob genuinely exists
setCurrentUser("alice");
resetRegistryCache(); // force a read rather than the cache
const { ng } = (await import("../src/shared-wallet/bootstrap")).getConfig();
const realQuery = ng.sparql_query;
(ng as { sparql_query: unknown }).sparql_query = async () => {
throw new Error("broker unreachable");
};
await expect(share(doc, "bob")).rejects.toThrow(/broker unreachable/i);
(ng as { sparql_query: unknown }).sparql_query = realQuery;
void quads;
});
// ── a creation that half-worked is a failure, and says which half ──────────
test("createEntityDoc reports a half-written document instead of returning its reference", async () => {
inject(/shim:contains/); // the LISTING write fails
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/not listed in its store/i);
});
// Both writes are attempted before the failure is raised — the cap must land even when
// the listing did not, because either is worth having without the other. Throwing on the
// first one (2026-08-07) skipped the second and orphaned the document entirely.
test("a failed listing does not cost the document its key", async () => {
const { quads } = inject(/shim:contains/);
setCurrentUser("alice");
await storeRegistry.createEntityDoc("protected").catch(() => {});
expect(quads.some((q) => q.p === `${SHIM}:readCap`)).toBe(true);
});
test("a failed key write is reported too, and names that half", async () => {
inject(/shim:readCap/);
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/key is not recorded/i);
});