refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever l'ambiguïté à chaque phrase — et le contrat publié, lu par une application, était le pire endroit pour laisser traîner ça. packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill, contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts → e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md. Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes, y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est fait occurrence par occurrence, pas par substitution. Le contrat énonce désormais son identité en une phrase : « This package is a polyfill of NextGraph's SDK. »
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* The injection store — where the consumer application plugs the real SDK in, and
|
||||
* where the emulation keeps the state that only exists because one wallet hosts every
|
||||
* identity: the injected `ng`/`useShape`, the registry dependencies, WHO is currently
|
||||
* connected, and the `CapRegistry` singleton keyed by that holder.
|
||||
*
|
||||
* **NO COUNTERPART at any layer, by construction.** Upstream nothing is injected: the
|
||||
* app imports the SDK, and "who am I" is the session — there is no current-user relay
|
||||
* because a wallet has exactly one user. This module is the shape of that absence, so
|
||||
* it belongs with the shared-wallet machinery and evaporates whole at migration.
|
||||
*
|
||||
* Extracted from `polyfill.ts` on 2026-08-03. Before that, every internal module
|
||||
* imported the published ENTRY to reach the config, which made the entry a dependency
|
||||
* of the code it publishes — cycles `polyfill` <-> `connect` and `polyfill` <-> `inbox`.
|
||||
* The entry now only re-exports; the internals import this module instead.
|
||||
*/
|
||||
|
||||
import type { NgLike, UseShapeLike, Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
|
||||
import type { SharedWalletConfig } from "./access-gate";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import type { RegistrySession } from "./account-registry";
|
||||
import { CapRegistry } from "../emulated-verifier/caps";
|
||||
import { resetPublicStoreFetches } from "../emulated-verifier/public-store";
|
||||
import { setAccessLog } from "./access-log";
|
||||
import { inspectOutbox } from "./outbox-log";
|
||||
import { startConnect } from "../emulated-verifier/connect";
|
||||
|
||||
/**
|
||||
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
|
||||
* registry itself is generic (it knows only native scopes); the consumer wires
|
||||
* up how to reach the shared-wallet session and how to normalize an identity id
|
||||
* used as the shim key. Removed at migration along with the whole shim.
|
||||
*/
|
||||
export interface StoreRegistryDeps {
|
||||
/** Resolve the current shared-wallet session (id + private-store anchor). */
|
||||
getSession: () => Promise<RegistrySession>;
|
||||
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
|
||||
normalizeId?: (id: string) => string;
|
||||
/**
|
||||
* POINTER micro-guard budget. The account records now live in a subscribable
|
||||
* doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple
|
||||
* in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts
|
||||
* need NO retry (this replaces the deleted account-level `provisionRetry`). The
|
||||
* ONLY residual sync-lag window is the store-root pointer read itself — one
|
||||
* write-once triple. This bounded guard re-reads JUST that pointer a few times if a
|
||||
* fresh cold read misses it; it can never provision or fork an account (worst case:
|
||||
* a couple extra reads before an existing pointer is seen). Enable it where the REAL
|
||||
* broker is used (app + e2e). Left UNSET (the default) → `attempts: 1` = single
|
||||
* read, keeping the synchronous unit fakes fast and unchanged.
|
||||
*/
|
||||
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the polyfill needs, in ONE call.
|
||||
*
|
||||
* It used to take two — `configure` for the SDK injection, `configureStoreRegistry` for
|
||||
* the session — because the two belonged to different internals. That is a reason the
|
||||
* library has, not one an application should pay for: from a caller's side both are
|
||||
* "here is what you need to run", and two bootstrap calls is one more thing to delete
|
||||
* at migration than there needs to be. Merged 2026-08-07; the registry's own wiring
|
||||
* function stays internal.
|
||||
*/
|
||||
export interface EventuallyConfig {
|
||||
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
|
||||
ng: NgLike;
|
||||
/** The REAL `@ng-org/orm` `useShape`. */
|
||||
useShape: UseShapeLike;
|
||||
/**
|
||||
* Resolve the wallet session. Shared-wallet only: upstream the session IS the user, so
|
||||
* there is nothing to inject — an application opens its wallet and the SDK knows.
|
||||
* A thunk, so it may be given before the session exists.
|
||||
*/
|
||||
getSession?: () => Promise<RegistrySession>;
|
||||
/** Normalize an identity id for shim keying. Default: trim. */
|
||||
normalizeId?: (id: string) => string;
|
||||
/**
|
||||
* POINTER micro-guard budget — see {@link StoreRegistryDeps.pointerGuard}. Left unset
|
||||
* → a single read, which keeps the synchronous unit fakes fast.
|
||||
*/
|
||||
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||
/**
|
||||
* The shared wallet this deployment hands out, and what the access gate needs to do
|
||||
* it (`shared-wallet/access-gate.ts`). Absent → no gate; the caller sets the identity
|
||||
* itself. Disappears with the gate: upstream a user opens their own wallet.
|
||||
*/
|
||||
sharedWallet?: SharedWalletConfig;
|
||||
/** Initial current user; may also be set later via {@link setCurrentUser}. */
|
||||
currentUser?: PrincipalId;
|
||||
/**
|
||||
* Turn on the OFF-by-default document access log (see {@link ./access-log}):
|
||||
* every real read/write is printed, prefixed by the active identity, to
|
||||
* diagnose the shared-wallet isolation leak. Also enablable without a code
|
||||
* change via the env var `NG_EVENTUALLY_ACCESS_LOG=1`. Default: false.
|
||||
*/
|
||||
debugAccessLog?: boolean;
|
||||
/** REAL `@ng-org/web` `init` (lifecycle) — forwarded by the lib's `init()`. */
|
||||
init?: (...args: any[]) => any;
|
||||
/** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */
|
||||
initNg?: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
let cfg: EventuallyConfig | null = null;
|
||||
let currentUser: PrincipalId | null = null;
|
||||
/** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard`
|
||||
* defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */
|
||||
type ResolvedRegistryDeps = Required<
|
||||
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
|
||||
>;
|
||||
let registryDeps: ResolvedRegistryDeps | null = null;
|
||||
/**
|
||||
* The map key of the current identity — deliberately NOT the raw id.
|
||||
*
|
||||
* A virtual user IS a shim account, and the shim keys accounts by the
|
||||
* consumer-injected `normalizeId` ("@Alice" and "alice" are ONE account, with one
|
||||
* set of scope documents). This record must key the same way, or a consumer that
|
||||
* spells its own id differently between two calls gets a SECOND record and stops
|
||||
* reading its own documents — the caps are filed under one spelling and looked up
|
||||
* under the other. Falls back to the raw id while the registry deps are not yet
|
||||
* configured (nothing can be filed before that anyway).
|
||||
*/
|
||||
function capsHolder(): PrincipalId | null {
|
||||
if (currentUser === null) return null;
|
||||
return registryDeps ? registryDeps.normalizeId(currentUser) : currentUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* The emulated cap registry — one record PER identity (per virtual user),
|
||||
* resolved through {@link capsHolder} on every call. So switching identity
|
||||
* SWITCHES heldByHolder (nothing to reset, nothing wiped); see `caps.ts`. Empty until
|
||||
* the first cap is issued, and while it is empty the read filter passes through
|
||||
* (no regression).
|
||||
*/
|
||||
let caps = new CapRegistry(capsHolder);
|
||||
|
||||
export function configure(c: EventuallyConfig): void {
|
||||
cfg = c;
|
||||
currentUser = c.currentUser ?? null;
|
||||
setAccessLog(c.debugAccessLog ?? false);
|
||||
// The session wiring is part of the same act — see {@link EventuallyConfig}. Omitted
|
||||
// only by unit suites that never touch the registry; those get the same
|
||||
// "must be configured" error they got before, from `getStoreRegistryDeps`.
|
||||
if (c.getSession) {
|
||||
configureStoreRegistry({
|
||||
getSession: c.getSession,
|
||||
...(c.normalizeId ? { normalizeId: c.normalizeId } : {}),
|
||||
...(c.pointerGuard ? { pointerGuard: c.pointerGuard } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
|
||||
export function getConfig(): EventuallyConfig {
|
||||
if (!cfg) throw new Error("[ng-eventually] configure() must be called before use");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Reset the injected config back to un-configured (mainly for tests, so a
|
||||
* suite that calls configure() can restore the not-configured guard state). */
|
||||
export function resetConfig(): void {
|
||||
cfg = null;
|
||||
currentUser = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the storeRegistry's dependencies. INTERNAL since 2026-08-07: an application
|
||||
* passes these to {@link configure}, which calls this. Still exported for the library's
|
||||
* own suites, which wire the registry alone.
|
||||
*/
|
||||
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
||||
// Fire the outbox inspection (Volet 3 of the low-level data-path trace) once,
|
||||
// on the FIRST successful `getSession()` resolution — the most reliable
|
||||
// "a session is established" signal available: every low-level reader/writer
|
||||
// (store-registry, open-repo, read-model, subscribe, inbox) reaches its
|
||||
// session through this SAME injected `getSession`, so wrapping it HERE catches
|
||||
// the first success from whichever caller happens to run first, instead of
|
||||
// tying the probe to one particular call site. Only on SUCCESS (an error
|
||||
// propagates untouched, exactly as before) and only ONCE per
|
||||
// `configureStoreRegistry()` call (a fresh session config → a fresh check).
|
||||
let outboxInspected = false;
|
||||
const getSession = async (): Promise<RegistrySession> => {
|
||||
const session = await deps.getSession();
|
||||
if (!outboxInspected) {
|
||||
outboxInspected = true;
|
||||
inspectOutbox();
|
||||
}
|
||||
return session;
|
||||
};
|
||||
registryDeps = {
|
||||
getSession,
|
||||
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
|
||||
// Default: single read (no re-read). Only the real-broker consumers (app + e2e)
|
||||
// opt into the bounded pointer micro-guard; unit fakes stay synchronous.
|
||||
pointerGuard: deps.pointerGuard ?? { attempts: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal — used by the storeRegistry to reach its injected dependencies. */
|
||||
export function getStoreRegistryDeps(): ResolvedRegistryDeps {
|
||||
if (!registryDeps) {
|
||||
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
|
||||
}
|
||||
return registryDeps;
|
||||
}
|
||||
|
||||
/** Reset storeRegistry deps (mainly for tests). */
|
||||
export function resetStoreRegistry(): void {
|
||||
registryDeps = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current identity id — who the SDK is reading/writing as. In the target
|
||||
* this is the wallet user established at wallet-import time; here the consumer
|
||||
* relays that id through this call so the read filter and the inbox `from` know
|
||||
* who is acting. Passing `null` clears it (no identity yet, e.g. during startup).
|
||||
*/
|
||||
export function setCurrentUser(id: PrincipalId | null): void {
|
||||
const changed = currentUser !== id;
|
||||
currentUser = id;
|
||||
// Connecting a user is what triggers inbox processing — the library's job, not
|
||||
// the app's. Fire-and-forget: this setter is synchronous and every consumer calls
|
||||
// it from synchronous code, so the work announces itself through the cap
|
||||
// registry's change signal instead of making callers await. See `connect.ts`.
|
||||
//
|
||||
// Gated on the registry being configured, and that is not a test convenience: an
|
||||
// identity set before the session resolves has nothing to restore and no inbox to
|
||||
// reach, so firing would be I/O that can only fail. The consumer's real sequence
|
||||
// is `configureStoreRegistry` then `setCurrentUser`; anything else can call
|
||||
// `connectedUser()` explicitly.
|
||||
if (changed && id !== null && registryDeps !== null) startConnect();
|
||||
}
|
||||
|
||||
export function getCurrentUser(): PrincipalId | null {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
/** The emulated cap registry — what the current identity holds, plus the emulated
|
||||
* public store. The read filter and the read-model consult it. */
|
||||
export function getCaps(): CapRegistry {
|
||||
return caps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do I hold this document's key?
|
||||
*
|
||||
* The only question the model admits. There is no "may principal P read D" anywhere
|
||||
* upstream and there cannot be: reading IS key possession, so a cap-introspection API
|
||||
* would have to invent an ACL the engine does not have (`docs/api-contract.md` § 10).
|
||||
*
|
||||
* Returns a BOOLEAN, not the cap. It used to hand the value back, and the only consumer
|
||||
* that used it did so to pass it to `share` — which now takes the document instead.
|
||||
* Nothing an application does requires holding a key: upstream it never sees one, the
|
||||
* verifier fills `ContactDetails.read_cap` itself. So the surface answers the question
|
||||
* and keeps the key.
|
||||
*/
|
||||
export function hasCap(nuri: NuriLike): boolean {
|
||||
return caps.capFor(toNuri(nuri, "hasCap")) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop EVERY holder's caps (tests / a fresh wallet). This is **not** what an identity
|
||||
* change does: switching identity switches heldByHolder, it never wipes one — if it
|
||||
* wiped, durability would be a lie and per-session re-declaration would come back
|
||||
* under another name. Nothing in the library calls this on `setCurrentUser`.
|
||||
*/
|
||||
export function resetCaps(): void {
|
||||
// Clear IN PLACE rather than rebuilding: whoever subscribed to the registry's
|
||||
// change signal (`watchShape`) stays subscribed to the live instance instead of
|
||||
// silently holding a listener on an orphaned one.
|
||||
caps.clear();
|
||||
// …and forget which documents were already asked about, or the emulated public-store
|
||||
// fetch would answer from a memo taken before the wipe and hand back caps this
|
||||
// registry no longer holds.
|
||||
resetPublicStoreFetches();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user