refactor: renommer client → sdk, et fusionner les deux portes en une
Deux mouvements de surface, aucun changement de comportement. **`packages/client` → `packages/sdk`, `@ng-eventually/client` → `@ng-eventually/sdk`.** « client » ne disait rien : ce paquet EST le SDK que l'application appelle, et c'est tout ce qu'elle appelle. L'ancien nom reste comme mot-clé de recherche dans `docs/source-layout-by-fate.md` et le tableau des paquets du README. **Une seule entrée.** L'entrée `./polyfill` disparaît ; ses symboles applicatifs — `configure`, `configureStoreRegistry`, `setCurrentUser`, `connectedUser` et leurs types — vivent dans un bloc `POLYFILL-ERA` de `src/index.ts`. Ce que la seconde porte portait mérite d'être nommé avant d'être retiré : *ce qu'on importe de ce chemin est exactement ce qu'on supprimera à la migration*. Une seule porte perd ce signal — rien à la ligne d'import ne distingue `configure`, qui part, de `docs`, que le vrai SDK remplace sur place. Trois choses le portent désormais : le bloc lui-même, l'inventaire d'exports de `docs/api-contract.md` (épinglé par `test/vocabulary.test.ts`, donc il ne peut pas rancir en silence), et le contrôle de vocabulaire sur les noms publiés. **Six symboles quittent la surface au passage**, et la fusion est ce qui a rendu le choix visible plutôt qu'hérité : - `getConfig` / `getStoreRegistryDeps` — câblage interne, atteint par `shared-wallet/bootstrap` ; - `resetConfig` / `resetStoreRegistry` / `resetCaps` — remises à zéro de test, atteintes par leur chemin interne, ce qui est leur raison d'être ; - le `share` direct — `inbox.share` a toujours été la même fonction, et la publier deux fois brouillait la frontière qu'elle servait à marquer. Corrections d'affirmations fausses trouvées en chemin : le contrat annonçait `isNuri` / `hasReadCap` sur la porte SDK alors qu'ils ne sont plus exportés depuis le passage au permissif en entrée (`NuriLike` validé à la porte) ; le README du paquet documentait `capFor`, `shareCap`, `getCaps` et `publishRepoLink`, dont aucun n'existe ; et le README de l'app d'exemple affirmait que la suite e2e la pilote, ce qui reste à faire. 179 tests unitaires, typecheck bibliothèque / exemple / harnais, e2e 42/42 contre le broker en ligne — mesuré une fois après le renommage, une fois après la fusion.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* The published names may only use words the TARGET uses, or a marker that says why
|
||||
* they exist here.
|
||||
*
|
||||
* ── Why this is a test and not a rule ─────────────────────────────────────
|
||||
* The library corrected its vocabulary on 2026-07-30 — upstream a *wallet* is only a
|
||||
* keyring, and what owns stores is a **user** (a *site*) — by a manual pass over the
|
||||
* code and docs. `walletInbox` survived that pass and lived on for weeks, and it did
|
||||
* damage: the name made "one inbox per wallet" sound obvious, hiding that a user
|
||||
* upstream has **two** (public store repo and protected store repo — the only two
|
||||
* `AddInboxCap` commits in the engine, `engine/verifier/src/site.rs:128,149`). A
|
||||
* discipline applied by hand misses one; a test does not.
|
||||
*
|
||||
* So this pins the naming half of the design principle (`README.md`): a name either
|
||||
* belongs to the target's vocabulary — in which case it needs no translation and
|
||||
* survives migration — or it carries a marker saying WHY it exists only here, which
|
||||
* also says when it disappears.
|
||||
*
|
||||
* ── What it checks, and what it deliberately does not ─────────────────────
|
||||
* Only the PUBLISHED names, the ones a consumer application types. Internal names are
|
||||
* held to the same intent but not mechanically: the folder they live in already states
|
||||
* their fate, and pinning every internal identifier would fight refactoring for little.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
/**
|
||||
* Words the TARGET itself uses, verified in `nextgraph-rs`. A published name built
|
||||
* from these needs no translation at migration.
|
||||
*/
|
||||
const TARGET_WORDS = new Set([
|
||||
// addressing and objects
|
||||
"nuri", "doc", "docs", "document", "repo", "store", "stores", "branch", "graph",
|
||||
"overlay", "cap", "caps", "read", "write", "link", "links", "shape", "shapes",
|
||||
// actors and containers
|
||||
"user", "users", "session", "wallet", "inbox", "inboxes", "site", "principal",
|
||||
// scopes (upstream store types, `StoreRepo::from_type_and_repo`)
|
||||
"public", "protected", "private", "group", "dialog", "scope",
|
||||
// acts the target performs
|
||||
"create", "subscribe", "unsubscribe", "query", "update", "post", "share", "open",
|
||||
"fetch", "init", "watch", "sparql", "ng", "orm", "type", "types",
|
||||
// RDF / SPARQL terms the engine's own query paths use
|
||||
"subject", "base", "schema", "connected", "identity", "identities",
|
||||
// `publisher` is upstream's word for a pub/sub role on a topic (`as_publisher`,
|
||||
// `publisher_advert`, 126 occurrences in the engine). Our own "publish a document" is
|
||||
// banned as ambiguous, but that ban never reaches upstream's term — see the traps
|
||||
// block in `docs/readcap-and-nuri-model.md`.
|
||||
"publisher", "topic", "advert",
|
||||
// the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`)
|
||||
"observable", "deep", "signal", "set",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Markers that name WHY something exists only in this library. Each says when it
|
||||
* disappears, which a bare `fake`/`tmp` would not.
|
||||
*/
|
||||
const EMULATION_MARKERS = new Set([
|
||||
"virtual", "physical", "shim", "emulated", "polyfill",
|
||||
// `shared` as in "shared wallet" — the single fact every piece of scaffolding in this
|
||||
// library descends from. A name carrying it says both what it is and when it goes.
|
||||
"shared",
|
||||
]);
|
||||
|
||||
/** Glue with no domain meaning — never the load-bearing part of a name. */
|
||||
const NEUTRAL = new Set([
|
||||
"get", "set", "is", "has", "to", "for", "of", "my", "own", "all", "by", "with",
|
||||
"current", "reset", "configure", "config", "deps", "id", "ids", "address", "entity",
|
||||
"list", "resolve", "assert", "escape", "literal", "iri", "record", "registry",
|
||||
"change", "changed", "state", "value", "data", "info", "count", "the", "a", "an",
|
||||
"options", "opts", "result", "error", "signal", "filter", "placement", "and", "or",
|
||||
"make", "use", "on", "off", "from", "into", "at", "in", "out", "up", "down",
|
||||
// `union` is OURS — the bounded multi-document read — but it names an operation,
|
||||
// not a domain notion a consumer would have to unlearn. `eventually` is the
|
||||
// library's own name.
|
||||
"union", "eventually", "ensure",
|
||||
// `…Like` is a structural-typing suffix (`NgLike` = "whatever has ng's shape"), not
|
||||
// a domain word: it describes how the injection is typed, not what the thing is.
|
||||
"like",
|
||||
]);
|
||||
|
||||
/** `documentInboxAddress` → ["document","inbox","address"] ; `NG` → ["ng"]. */
|
||||
function words(name: string): string[] {
|
||||
return name
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
||||
.split(/[\s_]+/)
|
||||
.map((w) => w.toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const SRC = path.join(import.meta.dir, "..", "src");
|
||||
|
||||
/** Every identifier the entry point publishes, read from its `export` statements. */
|
||||
function publishedNames(): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const entry of ["index.ts"]) {
|
||||
const text = fs.readFileSync(path.join(SRC, entry), "utf8");
|
||||
// `export * as ns from "…"`
|
||||
for (const m of text.matchAll(/export \* as (\w+) from/g)) out.add(m[1]!);
|
||||
// `export { a, b as c }` / `export type { … }`, single- and multi-line
|
||||
for (const m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) {
|
||||
for (const raw of m[1]!.split(",")) {
|
||||
const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim();
|
||||
if (name) out.add(name);
|
||||
}
|
||||
}
|
||||
// `export const x` / `export function x` / `export interface x`
|
||||
for (const m of text.matchAll(/export (?:declare )?(?:const|function|class|interface|type) (\w+)/g)) {
|
||||
out.add(m[1]!);
|
||||
}
|
||||
// `export * from "./x"` — re-exports every name that module declares.
|
||||
for (const m of text.matchAll(/export \* from "\.\/([^"]+)"/g)) {
|
||||
const file = path.join(SRC, m[1]! + ".ts");
|
||||
if (!fs.existsSync(file)) continue;
|
||||
const t = fs.readFileSync(file, "utf8");
|
||||
for (const mm of t.matchAll(/^export (?:declare )?(?:const|function|class|interface|type) (\w+)/gm)) {
|
||||
out.add(mm[1]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
test("every published name is built from the target's vocabulary, or carries an emulation marker", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const name of publishedNames()) {
|
||||
const ws = words(name);
|
||||
// A marker anywhere in the name licenses the whole name: it declares the thing
|
||||
// as ours and says when it goes.
|
||||
if (ws.some((w) => EMULATION_MARKERS.has(w))) continue;
|
||||
const unknown = ws.filter((w) => !TARGET_WORDS.has(w) && !NEUTRAL.has(w));
|
||||
if (unknown.length > 0) offenders.push(`${name} → ${unknown.join(", ")}`);
|
||||
}
|
||||
// A failure here is not "rename to satisfy the test": it is a question. Does the
|
||||
// target have a word for this? Use it. Does the thing exist only here? Say so with a
|
||||
// marker. Is the word genuinely neutral glue? Add it to NEUTRAL, deliberately.
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test("no published name says `wallet` where the target says `user`", () => {
|
||||
// The specific regression that motivated this file. `wallet` is a legitimate target
|
||||
// word (a keyring IS a wallet upstream), so the generic check above cannot catch it —
|
||||
// what is wrong is using it for the thing that owns stores and inboxes.
|
||||
const wrong = publishedNames().filter((n) =>
|
||||
/wallet/i.test(n) && /(inbox|store|doc|cap)/i.test(n),
|
||||
);
|
||||
expect(wrong).toEqual([]);
|
||||
});
|
||||
|
||||
// --- the invariant the internal contract flagged as a migration risk -------
|
||||
|
||||
test("a reserved-namespace key cannot be produced by a consumer's normalizeId", async () => {
|
||||
// The reserved namespace hosts infrastructure accounts, and its guarantee is that no
|
||||
// user id lands there. That guarantee is not the library's to make — `normalizeId` is
|
||||
// injected by the consumer — so a careless one must be refused, not trusted. A
|
||||
// collision would key a user onto an infrastructure account: reads and writes on
|
||||
// documents that are not theirs.
|
||||
const { configureStoreRegistry, resetStoreRegistry } = await import("../src/shared-wallet/bootstrap");
|
||||
const { ensureAccount, resetRegistryCache } = await import(
|
||||
"../src/shared-wallet/account-registry"
|
||||
);
|
||||
configureStoreRegistry({
|
||||
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
|
||||
normalizeId: () => " | ||||