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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user