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:
@@ -14,7 +14,7 @@
|
||||
* The mapping (account → its 3 document NURIs) is the **sharedWalletShim**. It
|
||||
* is persisted as RDF, but NOT in the store-root graph anymore — see the
|
||||
* indirection below. That makes login cross-device: another device opening the
|
||||
* same wallet reads the same shim and finds the same accounts.
|
||||
* same wallet reads the same shim and finds the same virtualUsers.
|
||||
*
|
||||
* ── The indirection: pointer (store-root) → doc-shim (subscribable) ─────────
|
||||
* On the real NextGraph platform "findable-without-lookup" and "subscribable"
|
||||
@@ -93,7 +93,7 @@ import type { Nuri, ReadCap, Scope } from "../model/types";
|
||||
* The empty case is NOT new — `canonicalDoc` has always returned `""` for a missing
|
||||
* field, and callers have always had to test for it — but with {@link Nuri} typed it
|
||||
* stops hiding inside a `string`. It is kept confined to the shim-reading functions
|
||||
* below: `AccountRecord` still promises real NURIs, because a record with an empty
|
||||
* below: `VirtualUserRecord` still promises real NURIs, because a record with an empty
|
||||
* scope document is a corrupt record, not a valid state to spread through the API.
|
||||
* Tightening that (reject the record rather than let it flow) is a change of
|
||||
* behaviour and belongs to its own lot — see `recordFromRows`.
|
||||
@@ -101,7 +101,7 @@ import type { Nuri, ReadCap, Scope } from "../model/types";
|
||||
type MaybeNuri = Nuri | "";
|
||||
|
||||
/** One account's three scope-document NURIs, as recorded in the shim. */
|
||||
export interface AccountRecord {
|
||||
export interface VirtualUserRecord {
|
||||
id: string;
|
||||
docPublic: Nuri;
|
||||
docProtected: Nuri;
|
||||
@@ -278,7 +278,7 @@ async function rootNuri(): Promise<Nuri> {
|
||||
// Per-account cache, keyed by account key. Populated by the TARGETED resolver
|
||||
// (resolveAccount) and by loadShim(). Independent of `cache` so a single
|
||||
// targeted resolve never forces a full shim scan. Both are cleared together.
|
||||
const accountCache = new Map<string, AccountRecord>();
|
||||
const accountCache = new Map<string, VirtualUserRecord>();
|
||||
|
||||
// The resolved doc-shim NURI for the current session (cached: the pointer read +
|
||||
// barrier open happen once, then every account read reuses this doc). Cleared on
|
||||
@@ -363,18 +363,18 @@ function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: strin
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/** Build an AccountRecord by picking the canonical (lexicographically-smallest)
|
||||
/** Build an VirtualUserRecord by picking the canonical (lexicographically-smallest)
|
||||
* doc NURI per scope across all bindings for one account. See {@link canonicalDoc}. */
|
||||
function recordFromRows(
|
||||
rows: Array<Record<string, { value: string }>>,
|
||||
fallbackId: string,
|
||||
): AccountRecord {
|
||||
): VirtualUserRecord {
|
||||
let id = "";
|
||||
for (const row of rows) {
|
||||
const v = bindingValue(row, "id");
|
||||
if (v) { id = v; break; }
|
||||
}
|
||||
// The ONE place the `""`-for-corrupt case is absorbed. `AccountRecord` promises
|
||||
// The ONE place the `""`-for-corrupt case is absorbed. `VirtualUserRecord` promises
|
||||
// real NURIs; a shim missing a scope document yields `""` here, exactly as it
|
||||
// always has, and the cast records that this is a KNOWN gap rather than a proven
|
||||
// invariant. Callers already test for the empty value (e.g. `watchShape` skips a
|
||||
@@ -486,7 +486,7 @@ async function createDoc(): Promise<Nuri> {
|
||||
|
||||
/**
|
||||
* Resolve (or on first login, create) the doc-shim NURI for this session — the
|
||||
* `did:ng:o:...` repo that holds every AccountRecord and IS subscribable.
|
||||
* `did:ng:o:...` repo that holds every VirtualUserRecord and IS subscribable.
|
||||
*
|
||||
* Steps (cached; runs at most once per session, concurrent callers share one):
|
||||
* 1. Read the pointer from the store-root (resolvePointer). If present → that is
|
||||
@@ -553,7 +553,7 @@ async function resolveShimDoc(): Promise<Nuri> {
|
||||
* Cached per account (in `accountCache`); a hit skips the query entirely, so
|
||||
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
|
||||
*/
|
||||
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
|
||||
export async function resolveAccount(id: string): Promise<VirtualUserRecord | null> {
|
||||
const key = accountKey(id);
|
||||
const cached = accountCache.get(key);
|
||||
if (cached) return cached;
|
||||
@@ -593,9 +593,9 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist one AccountRecord into the doc-shim (anchored default-graph write, the
|
||||
/** Persist one VirtualUserRecord into the doc-shim (anchored default-graph write, the
|
||||
* canonical always-safe shape — same convention as createEntityDoc). */
|
||||
async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
|
||||
async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise<void> {
|
||||
const s = await session();
|
||||
const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`;
|
||||
// `subj` is IRI-safe (escapeIri). `id` is UNTRUSTED text in a LITERAL position →
|
||||
@@ -634,7 +634,7 @@ async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
|
||||
* bounded in-memory promise map (mirrors open-repo.ts `inFlight`), cleared the instant
|
||||
* it settles.
|
||||
*/
|
||||
const ensureInFlight = new Map<string, Promise<AccountRecord>>();
|
||||
const ensureInFlight = new Map<string, Promise<VirtualUserRecord>>();
|
||||
|
||||
/**
|
||||
* Ensure an account exists in the shim, creating its 3 scope documents on
|
||||
@@ -642,7 +642,7 @@ const ensureInFlight = new Map<string, Promise<AccountRecord>>();
|
||||
* Concurrency-safe: concurrent calls for the same account share one provision
|
||||
* (see {@link ensureInFlight}) so a fresh page never FORKS the account.
|
||||
*/
|
||||
export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
export async function ensureAccount(id: string): Promise<VirtualUserRecord> {
|
||||
const key = accountKey(id);
|
||||
// A completed provision/resolve is cached → no query, no fork risk.
|
||||
const cached = accountCache.get(key);
|
||||
@@ -655,7 +655,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
const pending = ensureInFlight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const p = (async (): Promise<AccountRecord> => {
|
||||
const p = (async (): Promise<VirtualUserRecord> => {
|
||||
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
|
||||
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
|
||||
//
|
||||
@@ -675,7 +675,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
createDoc(),
|
||||
createDoc(),
|
||||
]);
|
||||
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
|
||||
const record: VirtualUserRecord = { id, docPublic, docProtected, docPrivate };
|
||||
// Persist the record INTO the doc-shim (not the store-root anymore).
|
||||
await writeRecord(doc, record);
|
||||
// Feed the per-account cache, and the full-shim cache if it is already loaded
|
||||
@@ -696,7 +696,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
// --- resolvers ------------------------------------------------------------
|
||||
|
||||
/** The index document NURI of an account for a scope (the store-container). */
|
||||
export function storeOf(record: AccountRecord, scope: Scope): Nuri {
|
||||
export function storeOf(record: VirtualUserRecord, scope: Scope): Nuri {
|
||||
return scope === "public"
|
||||
? record.docPublic
|
||||
: scope === "protected"
|
||||
@@ -751,7 +751,7 @@ export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
|
||||
}
|
||||
|
||||
/**
|
||||
* In-flight `walletInbox` resolutions, keyed by account key — so concurrent callers
|
||||
* In-flight `userInbox` resolutions, keyed by account key — so concurrent callers
|
||||
* for the SAME wallet share ONE resolve-or-create instead of racing two documents
|
||||
* into existence (mirrors {@link ensureInFlight}).
|
||||
*/
|
||||
@@ -760,6 +760,26 @@ const inboxInFlight = new Map<string, Promise<Nuri>>();
|
||||
const inboxCache = new Map<string, Nuri>();
|
||||
|
||||
/**
|
||||
* The inbox of a virtual user — where caps and messages addressed to THEM arrive.
|
||||
*
|
||||
* **Renamed from `walletInbox` on 2026-08-03, and the old name was wrong twice.**
|
||||
* A *wallet* upstream is only a keyring; what owns stores — and therefore what an inbox
|
||||
* belongs to — is a **user** (a *site*): `SensitiveWalletV0.sites: HashMap<String, SiteV0>`
|
||||
* (`engine/wallet/src/types.rs:456`), `SiteV0` carrying `public`/`protected`/`private`
|
||||
* (`engine/verifier/src/site.rs:31-37`). The library had corrected that vocabulary
|
||||
* everywhere else and this name survived the pass — and it did cost: reasoning about
|
||||
* "the inbox per wallet" hid that upstream a user has **two**.
|
||||
*
|
||||
* **Known divergence, deliberate.** Upstream a user has TWO inboxes, one on its public
|
||||
* store repo and one on its protected store repo — the only two `AddInboxCap` commits in
|
||||
* the engine (`engine/verifier/src/site.rs:128,149`; `new_store_default` attaches one
|
||||
* only `if !private`, `engine/verifier/src/verifier.rs:2994`). They are distinguished
|
||||
* right down to the predicates a contact record uses (`ng:site_inbox` vs
|
||||
* `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:374-375`). This function
|
||||
* exposes ONE. Collapsing them is a simplification this library has not yet had a reason
|
||||
* to undo; the day a caller needs to address a user's public inbox distinctly from its
|
||||
* protected one, this is the seam that has to split in two.
|
||||
*
|
||||
* The NURI of a virtual user's OWN inbox — where deposits addressed to that
|
||||
* identity land, ReadCaps among them.
|
||||
*
|
||||
@@ -783,7 +803,7 @@ const inboxCache = new Map<string, Nuri>();
|
||||
* At migration this becomes the identity's native inbox and the resolution moves
|
||||
* here — the consumer-facing act (deposit to an inbox, process my own) is unchanged.
|
||||
*/
|
||||
export async function walletInbox(id: string): Promise<Nuri> {
|
||||
export async function userInbox(id: string): Promise<Nuri> {
|
||||
const key = accountKey(id);
|
||||
const cached = inboxCache.get(key);
|
||||
if (cached) {
|
||||
@@ -806,7 +826,7 @@ export async function walletInbox(id: string): Promise<Nuri> {
|
||||
`SELECT ?d WHERE { <${subj}> <${P.docInbox}> ?d }`,
|
||||
undefined,
|
||||
shimDoc,
|
||||
"walletInbox",
|
||||
"userInbox",
|
||||
);
|
||||
const existing = canonicalDoc(readBindings(res), "d");
|
||||
if (existing) {
|
||||
@@ -815,7 +835,7 @@ export async function walletInbox(id: string): Promise<Nuri> {
|
||||
return existing;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " walletInbox read failed:", error);
|
||||
console.error(accessLogPrefix() + " userInbox read failed:", error);
|
||||
}
|
||||
|
||||
const doc = await createDoc();
|
||||
@@ -825,13 +845,13 @@ export async function walletInbox(id: string): Promise<Nuri> {
|
||||
s.sessionId,
|
||||
`INSERT DATA { <${subj}> <${P.docInbox}> "${escapeLiteral(doc)}" }`,
|
||||
shimDoc,
|
||||
"walletInbox",
|
||||
"userInbox",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " walletInbox persist failed:", error);
|
||||
console.error(accessLogPrefix() + " userInbox persist failed:", error);
|
||||
}
|
||||
inboxCache.set(key, doc);
|
||||
logStage("walletInbox(" + key + ") → " + shortNuri(doc));
|
||||
logStage("userInbox(" + key + ") → " + shortNuri(doc));
|
||||
return doc;
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user