refactor(vocabulary): les noms publiés parlent la langue de la cible, et un test le tient
La correction de nomenclature du 2026-07-30 — en amont un *wallet* n'est qu'un trousseau, ce qui possède des stores est un **user** (un *site*) — s'était faite à la main. `walletInbox` y a échappé et a vécu des semaines, en faisant des dégâts : le nom rendait « une inbox par wallet » évident, masquant qu'un user en a **deux** en amont (repos de store public et protected, les deux seuls `AddInboxCap` du moteur). Une discipline appliquée à la main en oublie un ; un test non. D'où `test/vocabulary.test.ts` : tout nom publié est bâti sur des mots que la CIBLE emploie — vérifiés dans `nextgraph-rs` — ou porte un marqueur disant POURQUOI il n'existe qu'ici (`virtual`, `physical`, `shim`, `emulated`, `polyfill`), ce qui dit aussi quand il disparaît. Un échec n'est pas « renommer pour faire passer le test », c'est une question : la cible a-t-elle un mot pour ça ? la chose n'existe-t-elle qu'ici ? le mot est-il vraiment de la glue ? Ce que le test a trouvé, et les réponses : - `walletInbox` → `userInbox`, avec l'écart de cardinalité écrit noir sur blanc plutôt que caché par le nom. - `accounts` / `AccountRecord` / `AccountStorage` → `virtualUsers` / `VirtualUserRecord` / `VirtualUserStorage`, module `accounts.ts` → `virtual-users.ts`. « account » n'est pas de la cible : c'est notre mot pour l'utilisateur virtuel, et le marqueur le dit désormais. - `readModel` → la fonction `readUnion`, exposée directement. « model » n'était ni de la cible ni de la glue, et le namespace ne tenait qu'une fonction. - Le reste était du vocabulaire légitime à déclarer (`subject`, `base`, `schema`, `connected`, le modèle réactif de l'ORM). Corrigé au passage, sur signalement du contrat interne : l'en-tête d'`open-repo` justifiait son correctif par un mécanisme que le source contredit. Un repo absent de `self.repos` lève bien `RepoNotFound` (`engine/verifier/src/request_processor.rs:264,269`). Les 0 lignes observées viennent d'ailleurs — `Verifier::load` repeuple `self.repos` depuis le stockage sur un profil persistant (`verifier.rs:535-560`), et notre propre `readDoc` attrape toute erreur et rend `[]`. Le correctif est bon, le diagnostic écrit à côté ne l'était pas. 159 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
This commit is contained in:
@@ -3,11 +3,11 @@ import {
|
||||
IdentityStore,
|
||||
browserIdentityStore,
|
||||
ACCOUNT_STORAGE_KEY,
|
||||
type AccountStorage,
|
||||
} from "../src/shared-wallet/accounts";
|
||||
type VirtualUserStorage,
|
||||
} from "../src/shared-wallet/virtual-users";
|
||||
|
||||
// In-memory fake of the Storage subset — keeps this framework/DOM-agnostic.
|
||||
function fakeStorage(): AccountStorage & { map: Map<string, string> } {
|
||||
function fakeStorage(): VirtualUserStorage & { map: Map<string, string> } {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
map,
|
||||
@@ -50,7 +50,7 @@ test("IdentityStore: null storage degrades to non-persisting (SSR-safe)", () =>
|
||||
});
|
||||
|
||||
test("IdentityStore: swallows storage errors on read and write", () => {
|
||||
const throwing: AccountStorage = {
|
||||
const throwing: VirtualUserStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
createEntityDoc,
|
||||
openDocumentInbox,
|
||||
resetRegistryCache,
|
||||
walletInbox,
|
||||
userInbox,
|
||||
} from "../src/shared-wallet/account-registry";
|
||||
import { documentInboxAddress } from "../src/emulated-verifier/branch-registers";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
@@ -263,7 +263,7 @@ test("Bob: reads the public document, sees the reference, and cannot read throug
|
||||
test("Charlie: same public document, same reference — and he reads through it", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
|
||||
const CHARLIE_INBOX = await walletInbox("charlie");
|
||||
const CHARLIE_INBOX = await userInbox("charlie");
|
||||
|
||||
// Alice decides Charlie may read that ONE document, and delivers its cap to his
|
||||
// inbox. She names no principal to the registry; she addresses an inbox.
|
||||
@@ -283,7 +283,7 @@ test("Charlie: same public document, same reference — and he reads through it"
|
||||
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
|
||||
inject();
|
||||
const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
|
||||
const CHARLIE_INBOX = await walletInbox("charlie");
|
||||
const CHARLIE_INBOX = await userInbox("charlie");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await shareCap(protCap, CHARLIE_INBOX);
|
||||
@@ -306,7 +306,7 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async
|
||||
test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
|
||||
inject();
|
||||
const { pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
|
||||
const BOB_INBOX = await walletInbox("bob");
|
||||
const BOB_INBOX = await userInbox("bob");
|
||||
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(pubLink);
|
||||
@@ -362,7 +362,7 @@ test("a bare reference to the PUBLIC document is not enough either — the link
|
||||
test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => {
|
||||
const ng = inject();
|
||||
const { protDoc, protCap } = await aliceSetsUpHerDocuments();
|
||||
const bobInbox = await walletInbox("bob");
|
||||
const bobInbox = await userInbox("bob");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await shareCap(protCap, bobInbox);
|
||||
@@ -407,7 +407,7 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
const aliceInbox = await openDocumentInbox(doc);
|
||||
expect(aliceInbox).not.toBe(await walletInbox("alice"));
|
||||
expect(aliceInbox).not.toBe(await userInbox("alice"));
|
||||
const link = capFor(doc)!; // the repo link alice circulates — links DO travel
|
||||
|
||||
// Bob RESOLVES the address himself, from the document. The only thing he is handed
|
||||
@@ -498,7 +498,7 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async
|
||||
const protDoc = await createEntityDoc("alice", "protected");
|
||||
const pubDoc = await createEntityDoc("alice", "public");
|
||||
const docInbox = await openDocumentInbox(pubDoc);
|
||||
const aliceInbox = await walletInbox("alice");
|
||||
const aliceInbox = await userInbox("alice");
|
||||
|
||||
// Two deposits, one at each level, both made by someone else.
|
||||
setCurrentUser("carol");
|
||||
@@ -520,8 +520,8 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async
|
||||
test("a third party resolves another user's inbox (the wallet level)", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const aliceView = await walletInbox("alice");
|
||||
const aliceView = await userInbox("alice");
|
||||
setCurrentUser("bob");
|
||||
const bobView = await walletInbox("alice");
|
||||
const bobView = await userInbox("alice");
|
||||
expect(bobView).toBe(aliceView);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { post, read, materialize, watch } from "../src/surface/inbox";
|
||||
import { walletInbox, resetRegistryCache } from "../src/shared-wallet/account-registry";
|
||||
import { userInbox, resetRegistryCache } from "../src/shared-wallet/account-registry";
|
||||
import type { Deposit } from "../src/surface/inbox";
|
||||
import {
|
||||
configure,
|
||||
@@ -165,7 +165,7 @@ beforeEach(async () => {
|
||||
fake = inject();
|
||||
resetRegistryCache();
|
||||
setCurrentUser("alice");
|
||||
TARGET = await walletInbox("alice");
|
||||
TARGET = await userInbox("alice");
|
||||
});
|
||||
|
||||
test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
|
||||
*/
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { createEntityDoc, resetRegistryCache, walletInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
||||
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import type { ReadCap } from "../src/model/types";
|
||||
import {
|
||||
@@ -228,7 +228,7 @@ test("(a) sharing one document's cap to ONE inbox reveals it there, and only the
|
||||
|
||||
// The app decides alice↔bob are related: alice shares ONE document's cap into
|
||||
// bob's OWN inbox — the only cross-wallet act there is.
|
||||
const bobInbox = await walletInbox("bob");
|
||||
const bobInbox = await userInbox("bob");
|
||||
setCurrentUser("alice");
|
||||
await shareCap(capFor(shared)!, bobInbox);
|
||||
|
||||
@@ -239,7 +239,7 @@ test("(a) sharing one document's cap to ONE inbox reveals it there, and only the
|
||||
|
||||
// carol, who was not shared with, still reads nothing.
|
||||
setCurrentUser("carol");
|
||||
await readInbox(await walletInbox("carol"));
|
||||
await readInbox(await userInbox("carol"));
|
||||
expect(view(items)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -247,7 +247,7 @@ test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () =
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
const bobInbox = await walletInbox("bob");
|
||||
const bobInbox = await userInbox("bob");
|
||||
await shareCap(capFor(doc)!, bobInbox);
|
||||
|
||||
setCurrentUser("bob");
|
||||
@@ -320,7 +320,7 @@ test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", asy
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const secret = await createEntityDoc("alice", "protected");
|
||||
const bobInbox = await walletInbox("bob");
|
||||
const bobInbox = await userInbox("bob");
|
||||
|
||||
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
|
||||
await shareCap(capFor(secret)!, bobInbox);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { sparqlQuery, sparqlUpdate, depositInto } from "../src/surface/docs";
|
||||
import { createEntityDoc, resetRegistryCache, walletInbox } from "../src/shared-wallet/account-registry";
|
||||
import { createEntityDoc, resetRegistryCache, userInbox } from "../src/shared-wallet/account-registry";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import {
|
||||
configure,
|
||||
@@ -103,7 +103,7 @@ test("a user reaches its OWN stores and inbox — the boundary must not lock it
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "protected"); // provisions alice's account
|
||||
const inbox = await walletInbox("alice");
|
||||
const inbox = await userInbox("alice");
|
||||
|
||||
expect(mayReach(inbox)).toBe(true);
|
||||
await sparqlQuery(SESSION.sessionId, READ, undefined, inbox);
|
||||
@@ -116,7 +116,7 @@ test("a user reaches its OWN stores and inbox — the boundary must not lock it
|
||||
test("DEPOSITING into another user's inbox crosses the boundary, and gives nothing back", async () => {
|
||||
const { ng } = inject();
|
||||
setCurrentUser("bob");
|
||||
const bobInbox = await walletInbox("bob");
|
||||
const bobInbox = await userInbox("bob");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "private"); // alice now holds caps → guard is armed
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveAccount,
|
||||
listMyEntityDocs,
|
||||
resolveScopeGraph,
|
||||
walletInbox,
|
||||
userInbox,
|
||||
createEntityDoc,
|
||||
resetRegistryCache,
|
||||
} from "../src/shared-wallet/account-registry";
|
||||
@@ -248,11 +248,11 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
|
||||
// docCreate), not the private-store root, so deposits never bloat the shim graph.
|
||||
// Stable per wallet, and DISJOINT between wallets: reading someone else's inbox
|
||||
// would collect the caps addressed to them (see inbox.ts's read guard).
|
||||
const mine = await walletInbox("@alice");
|
||||
const mine = await userInbox("@alice");
|
||||
expect(mine).toMatch(/^did:ng:o:doc/);
|
||||
expect(mine).not.toBe("did:ng:PRIV");
|
||||
expect(await walletInbox("@alice")).toBe(mine); // stable
|
||||
expect(await walletInbox("@bob")).not.toBe(mine); // another wallet, another inbox
|
||||
expect(await userInbox("@alice")).toBe(mine); // stable
|
||||
expect(await userInbox("@bob")).not.toBe(mine); // another wallet, another inbox
|
||||
});
|
||||
|
||||
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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",
|
||||
// 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"]);
|
||||
|
||||
/** 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",
|
||||
]);
|
||||
|
||||
/** `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 two entry points publish, read from the `export` statements. */
|
||||
function publishedNames(): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const entry of ["index.ts", "polyfill.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]!);
|
||||
}
|
||||
}
|
||||
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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user