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:
@@ -29,7 +29,7 @@ import {
|
||||
docs,
|
||||
subscribeDoc,
|
||||
subscribeDocs,
|
||||
readModel,
|
||||
readUnion,
|
||||
inbox,
|
||||
storeRegistry,
|
||||
useShape as libUseShape,
|
||||
@@ -39,11 +39,11 @@ import {
|
||||
// application must not — but through the internal path, never the published entry.
|
||||
// `storeRegistry` above is the app-facing slice; these are the shim internals.
|
||||
import * as registryInternals from "../src/shared-wallet/account-registry";
|
||||
import * as accounts from "../src/shared-wallet/accounts";
|
||||
import * as virtualUsers from "../src/shared-wallet/virtual-users";
|
||||
import { isNuri } from "@ng-eventually/client";
|
||||
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
|
||||
|
||||
const { IdentityStore } = accounts;
|
||||
const { IdentityStore } = virtualUsers;
|
||||
|
||||
/**
|
||||
* The Playwright boundary. Every NURI reaching this harness crosses the bridge as
|
||||
@@ -131,7 +131,7 @@ configureStoreRegistry({
|
||||
|
||||
const state: { status: string; error?: string } = { status: "connecting" };
|
||||
|
||||
// Identity store over the iframe's localStorage (the real AccountStorage).
|
||||
// Identity store over the iframe's localStorage (the real VirtualUserStorage).
|
||||
const identity = new IdentityStore(
|
||||
typeof window !== "undefined" && window.localStorage ? window.localStorage : null,
|
||||
);
|
||||
@@ -293,7 +293,7 @@ const identity = new IdentityStore(
|
||||
docNuris.push(d);
|
||||
}
|
||||
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
|
||||
const subjects = await readModel.readUnion(toRead);
|
||||
const subjects = await readUnion(toRead);
|
||||
return { docNuris, subjectCount: subjects.length, subjects };
|
||||
},
|
||||
/**
|
||||
@@ -309,9 +309,9 @@ const identity = new IdentityStore(
|
||||
setCurrentUser("owner-O");
|
||||
getCaps().open(doc, "protected");
|
||||
setCurrentUser("someone-else");
|
||||
const asStranger = await readModel.readUnion([doc]);
|
||||
const asStranger = await readUnion([doc]);
|
||||
setCurrentUser("owner-O");
|
||||
const asOwner = await readModel.readUnion([doc]);
|
||||
const asOwner = await readUnion([doc]);
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
return { strangerCount: asStranger.length, ownerCount: asOwner.length };
|
||||
@@ -382,9 +382,9 @@ const identity = new IdentityStore(
|
||||
async inboxPostRead(id: string, payloadA: unknown, payloadB: unknown) {
|
||||
// The target must be that user's OWN inbox, not an arbitrary document: you may
|
||||
// deposit into anyone's, you may only read your own. Establishing the identity
|
||||
// FIRST is what makes `walletInbox` resolve (and file) that user's inbox.
|
||||
// FIRST is what makes `userInbox` resolve (and file) that user's inbox.
|
||||
setCurrentUser(id);
|
||||
const target = await storeRegistry.walletInbox(id);
|
||||
const target = await storeRegistry.userInbox(id);
|
||||
await inbox.post(target, { payload: payloadA, from: null, ts: 1000 });
|
||||
await inbox.post(target, { payload: payloadB, from: null, ts: 2000 });
|
||||
const deposits = await inbox.read(target);
|
||||
@@ -398,7 +398,7 @@ const identity = new IdentityStore(
|
||||
// Watching an inbox is READING it continuously, so the watcher stays connected
|
||||
// for the whole probe — including across `inboxWatchDeposit`.
|
||||
setCurrentUser(id);
|
||||
const target = await storeRegistry.walletInbox(id);
|
||||
const target = await storeRegistry.userInbox(id);
|
||||
const rec = { fires: 0, lastLen: -1, unsub: () => {}, target };
|
||||
(window as any).__sdk._inboxWatch = rec;
|
||||
rec.unsub = inbox.watch(target, (deposits) => {
|
||||
@@ -556,7 +556,7 @@ const identity = new IdentityStore(
|
||||
} catch (e: any) {
|
||||
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
|
||||
}
|
||||
const subjects = await readModel.readUnion(listed.length ? listed : [asNuri(entityNuri)]);
|
||||
const subjects = await readUnion(listed.length ? listed : [asNuri(entityNuri)]);
|
||||
const markers: string[] = [];
|
||||
for (const subj of subjects) {
|
||||
for (const vals of Object.values(subj.props)) {
|
||||
@@ -840,7 +840,7 @@ const identity = new IdentityStore(
|
||||
setCurrentUser(ownerId);
|
||||
const deposits = await inbox.read(ownerInbox);
|
||||
// The address is machinery: it must not surface among the document's properties.
|
||||
const subjects = await readModel.readUnion([doc]);
|
||||
const subjects = await readUnion([doc]);
|
||||
const props = Object.keys(subjects[0]?.props ?? {});
|
||||
setCurrentUser(null);
|
||||
return {
|
||||
@@ -859,7 +859,7 @@ const identity = new IdentityStore(
|
||||
// the recipient's durable Links would grow run after run on a persistent wallet,
|
||||
// making every later `connectedUser()` re-apply a longer and longer history.
|
||||
setCurrentUser(friendId);
|
||||
const friendInbox = await storeRegistry.walletInbox(friendId);
|
||||
const friendInbox = await storeRegistry.userInbox(friendId);
|
||||
|
||||
setCurrentUser("owner-O");
|
||||
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
|
||||
@@ -52,9 +52,9 @@ import {
|
||||
resolveAccount,
|
||||
storeOf,
|
||||
readUserStore,
|
||||
walletInbox,
|
||||
userInbox,
|
||||
ensureAccount,
|
||||
type AccountRecord,
|
||||
type VirtualUserRecord,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import type { Nuri, ReadCap, Scope } from "../model/types";
|
||||
|
||||
@@ -66,7 +66,7 @@ import type { Nuri, ReadCap, Scope } from "../model/types";
|
||||
export async function isOwnInbox(nuri: Nuri): Promise<boolean> {
|
||||
const holder = getCurrentUser();
|
||||
if (holder === null) return false;
|
||||
if ((await walletInbox(holder)) === nuri) return true;
|
||||
if ((await userInbox(holder)) === nuri) return true;
|
||||
// …and the inbox of any document this user opened one on (the emulated
|
||||
// `AddInboxCap` records on its User branch).
|
||||
return (await readInboxCapPairs()).some((p) => p.inbox === nuri);
|
||||
@@ -115,7 +115,7 @@ export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): v
|
||||
* Scoped to the current holder, like {@link holdOwnCap}: another user's stores are
|
||||
* emphatically not ours to hold.
|
||||
*/
|
||||
export function fileOwnStructure(id: string, record: AccountRecord): void {
|
||||
export function fileOwnStructure(id: string, record: VirtualUserRecord): void {
|
||||
const holder = getCurrentUser();
|
||||
if (holder === null || accountKey(holder) !== accountKey(id)) return;
|
||||
const caps = getCaps();
|
||||
@@ -294,7 +294,7 @@ export async function myInboxes(): Promise<Nuri[]> {
|
||||
const holder = getCurrentUser();
|
||||
if (holder === null) return [];
|
||||
const out: Nuri[] = [];
|
||||
if ((await resolveAccount(holder)) !== null) out.push(await walletInbox(holder));
|
||||
if ((await resolveAccount(holder)) !== null) out.push(await userInbox(holder));
|
||||
for (const { inbox } of await readInboxCapPairs()) out.push(inbox);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,26 @@
|
||||
* open-repo — cold-start repo opening for the ANCHORED read path (polyfill-era).
|
||||
*
|
||||
* ── The cold-start defect this heals ──────────────────────────────────────
|
||||
* The anchored read path (`read-model.ts` `readDoc`, `store-registry.ts`
|
||||
* `readUserStore`) assumes the target repo is already in the verifier's
|
||||
* `self.repos` — true within the session that CREATED the doc (every `doc_create`
|
||||
* opens it), but FALSE on a FRESH session over the same persistent wallet
|
||||
* (reconnection / new page / re-login). On that fresh session nothing has opened
|
||||
* the user's scope-index or entity repos yet, so an anchored `sparql_query`
|
||||
* resolves a repo absent from `self.repos` and silently returns 0 rows (never a
|
||||
* `RepoNotFound`) — persisted documents read as empty.
|
||||
* The anchored read path (`surface/read-model.ts` `readDoc`,
|
||||
* `shared-wallet/account-registry.ts` `readUserStore`) assumes the target repo is
|
||||
* already usable by the verifier — true within the session that CREATED the doc
|
||||
* (every `doc_create` opens it), but FALSE on a FRESH session over the same
|
||||
* persistent wallet (reconnection / new page / re-login): the repos are on the broker
|
||||
* and in the profile's cache, but this session has not synced them, so a persisted
|
||||
* document reads as empty.
|
||||
*
|
||||
* **The mechanism, corrected 2026-08-03.** This comment used to say the verifier
|
||||
* "silently returns 0 rows (never a `RepoNotFound`)" for a repo absent from
|
||||
* `self.repos`. That is FALSE at the source: `resolve_target_for_sparql` does
|
||||
* `self.repos.get(repo_id).ok_or(NgError::RepoNotFound)?`
|
||||
* (`engine/verifier/src/request_processor.rs:264,269`), which surfaces as a rejected
|
||||
* promise. Two things produce the 0 rows actually observed, and neither is silence in
|
||||
* the verifier: on a persistent profile `Verifier::load` repopulates `self.repos` from
|
||||
* user storage at construction (`engine/verifier/src/verifier.rs:535-560`), so the repo
|
||||
* is PRESENT but unsynced and the anchored query legitimately matches nothing; and this
|
||||
* library's own `readDoc` catches every error and returns `[]`
|
||||
* (`surface/read-model.ts:122`), so anything that did throw would reach the caller as
|
||||
* emptiness anyway. The fix below is right; the diagnosis written beside it was not.
|
||||
*
|
||||
* The circularity that made this self-inflicted: `doc_subscribe` WOULD open the
|
||||
* repo, but the reactive layer only subscribes AFTER the listing produced NURIs —
|
||||
|
||||
@@ -29,7 +29,10 @@ export * as inbox from "./surface/inbox";
|
||||
export * as docs from "./surface/docs";
|
||||
export { subscribeDoc, subscribeDocs, docChangeType } from "./surface/subscribe";
|
||||
export type { DocChange, DocChangeType, Unsubscribe } from "./surface/subscribe";
|
||||
export * as readModel from "./surface/read-model";
|
||||
// `readUnion` is exposed as a function, not under a `readModel` namespace: "model" is
|
||||
// neither the target's vocabulary nor neutral glue, and the namespace bought nothing —
|
||||
// it held one published function. Renamed 2026-08-03 by the vocabulary check.
|
||||
export { readUnion } from "./surface/read-model";
|
||||
export type { UnionSubject } from "./surface/read-model";
|
||||
export * as storeRegistry from "./surface/placement";
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export { connectedUser } from "./emulated-verifier/connect";
|
||||
// one shared wallet hosts several identities. The real SDK has no counterpart: there
|
||||
// each user opens their own wallet, and "who am I" is the session. Shipping it from
|
||||
// the SDK entry advertised as durable something that disappears at migration.
|
||||
export * as accounts from "./shared-wallet/accounts";
|
||||
export type { AccountStorage } from "./shared-wallet/accounts";
|
||||
export * as virtualUsers from "./shared-wallet/virtual-users";
|
||||
export type { VirtualUserStorage } from "./shared-wallet/virtual-users";
|
||||
// Config-shaped types the bootstrap needs; both describe the shim, not the SDK.
|
||||
export type { AccountRecord, RegistrySession } from "./shared-wallet/account-registry";
|
||||
export type { VirtualUserRecord, RegistrySession } from "./shared-wallet/account-registry";
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
|
||||
+6
-6
@@ -23,7 +23,7 @@ export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id";
|
||||
* so this stays framework/DOM-agnostic. When none is available (SSR, no
|
||||
* `window`), pass `null` and the store degrades to in-memory-null (no persist).
|
||||
*/
|
||||
export interface AccountStorage {
|
||||
export interface VirtualUserStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
@@ -31,14 +31,14 @@ export interface AccountStorage {
|
||||
|
||||
/**
|
||||
* The persisted current identity id. A tiny store around an injected
|
||||
* {@link AccountStorage}. It holds no framework state; the consumer's Provider
|
||||
* {@link VirtualUserStorage}. It holds no framework state; the consumer's Provider
|
||||
* mirrors `get()` into framework state and re-reads after `set`/`clear`.
|
||||
*/
|
||||
export class IdentityStore {
|
||||
private readonly storage: AccountStorage | null;
|
||||
private readonly storage: VirtualUserStorage | null;
|
||||
private readonly key: string;
|
||||
|
||||
constructor(storage: AccountStorage | null, key: string = ACCOUNT_STORAGE_KEY) {
|
||||
constructor(storage: VirtualUserStorage | null, key: string = ACCOUNT_STORAGE_KEY) {
|
||||
this.storage = storage;
|
||||
this.key = key;
|
||||
}
|
||||
@@ -89,8 +89,8 @@ export class IdentityStore {
|
||||
export function browserIdentityStore(key: string = ACCOUNT_STORAGE_KEY): IdentityStore {
|
||||
const ls =
|
||||
typeof globalThis !== "undefined" &&
|
||||
(globalThis as { localStorage?: AccountStorage }).localStorage
|
||||
? (globalThis as { localStorage: AccountStorage }).localStorage
|
||||
(globalThis as { localStorage?: VirtualUserStorage }).localStorage
|
||||
? (globalThis as { localStorage: VirtualUserStorage }).localStorage
|
||||
: null;
|
||||
return new IdentityStore(ls, key);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export {
|
||||
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
|
||||
resolveWriteGraph,
|
||||
/** A user's own inbox — where caps and messages addressed to THEM arrive. */
|
||||
walletInbox,
|
||||
userInbox,
|
||||
/** Open an inbox on a document you OWN, so others can deposit into it. */
|
||||
openDocumentInbox,
|
||||
/** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* you can only follow links** (see docs/readcap-and-nuri-model.md §4ter-bis),
|
||||
* and a link reaches you through an inbox or through a document you already
|
||||
* hold. A document whose cap you were given is read by NAMING it
|
||||
* (`readModel.readUnion`), not by turning up in a scope you never put it in.
|
||||
* (`readUnion`), not by turning up in a scope you never put it in.
|
||||
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
|
||||
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
|
||||
* fallback). `isPending` holds until the barrier is reached for the current
|
||||
@@ -218,7 +218,7 @@ export function watchShape<T = UnionSubject>(
|
||||
* There is no "everything public" to fold in. You cannot discover; you can only
|
||||
* follow links, and a link reaches you through an inbox or through a document
|
||||
* you already hold — never through a shared index. A document someone gave you
|
||||
* the cap for is read by naming it (`readModel.readUnion`), not by appearing in
|
||||
* the cap for is read by naming it (`readUnion`), not by appearing in
|
||||
* a scope you did not put it in. */
|
||||
async function resolveDocs(): Promise<Nuri[]> {
|
||||
const user = getCurrentUser();
|
||||
|
||||
@@ -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