5e91771da6
Bug: à la reconnexion, resolveAccount lisait le shim depuis le store-root (did🆖${privateStoreId}), NON abonnable → pas de barrière first-State → un "0 rows" à froid est ambigu → le retry (resolveAccountReliably/provisionRetry) échoue → nouveau compte provisionné → FORK → données du compte invisibles. Cause NextGraph (vérifiée nextgraph-rs): "trouvable-sans-lookup" (store-root) et "abonnable" (did:ng:o:<RepoID aléatoire>) sont DISJOINTS — pas de doc à la fois devinable et attendable → une résolution shim purement barrière est impossible. Fix (indirection pointeur → doc-shim abonnable): - Les AccountRecord migrent dans un doc-shim doc_create'd (did:ng:o:..., a une barrière). - Un pointeur écrit-une-fois dans le store-root (<shim:root> <shim:shimDoc> <docShim>) le nomme. resolveShimDoc lit le pointeur → ensureRepoOpen(docShim) [barrière] → lecture de compte AUTORITATIVE (cold 0 = absent pour de vrai). Retry de compte SUPPRIMÉ. - Micro-garde résiduel (pointerGuard, ex-provisionRetry) sur le SEUL triple pointeur écrit-une-fois; ne peut jamais forker un compte; fork de pointeur réconcilié au doc-shim canonique (lexicographiquement-min), sans perte. - Migration: migrateLegacyRecords copie (pas déplace) les comptes de l'ancien store-root vers le doc-shim avant toute conclusion "absent"; idempotent; wallet neuf → no-op. Tests: unit 128/128, e2e réel 42/42 (CONTRACT 2 = non-fork du compte à la reconnexion), red-before/green-after prouvé. Docs: nextgraph-current-state (antagonisme + indirection), simulation, migration-guide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
6.5 KiB
TypeScript
153 lines
6.5 KiB
TypeScript
/**
|
|
* The polyfill bootstrap — the ONLY non-SDK surface of the client.
|
|
*
|
|
* It injects the REAL SDK and the polyfill settings; afterwards the SDK-shaped
|
|
* exports (`ng`, `useShape`, `inbox`) behave as drop-ins. This is exposed at the
|
|
* subpath `@ng-eventually/client/polyfill` so the main entry
|
|
* (`@ng-eventually/client`) stays a **pure, SDK-identical** surface. Everything
|
|
* here is removed at migration.
|
|
*/
|
|
|
|
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
|
|
import type { RegistrySession } from "./store-registry";
|
|
import { CapRegistry } from "./caps";
|
|
import { setAccessLog } from "./access-log";
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
|
|
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;
|
|
/** Shared-wallet credentials — polyfill only (one wallet for everyone). */
|
|
sharedWallet?: { name: string; secret: string };
|
|
/** 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 emulated ReadCap/WriteCap registry. Empty until the app declares caps;
|
|
* while it has no read policy the read filter passes through (no regression). */
|
|
let caps = new CapRegistry();
|
|
|
|
export function configure(c: EventuallyConfig): void {
|
|
cfg = c;
|
|
currentUser = c.currentUser ?? null;
|
|
setAccessLog(c.debugAccessLog ?? false);
|
|
}
|
|
|
|
/** @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 consumer-injected dependencies (session + identity-id
|
|
* normalization). Must be called before any storeRegistry.* use. Separate from
|
|
* {@link configure} because it's storeRegistry-specific and, like the shim,
|
|
* disappears at migration.
|
|
*/
|
|
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
|
registryDeps = {
|
|
getSession: deps.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 {
|
|
currentUser = id;
|
|
}
|
|
|
|
export function getCurrentUser(): PrincipalId | null {
|
|
return currentUser;
|
|
}
|
|
|
|
/** The emulated cap registry — the app opens a document's read policy and issues
|
|
* directed read grants on it (as it will via real cap operations in the target).
|
|
* The read filter consults it. */
|
|
export function getCaps(): CapRegistry {
|
|
return caps;
|
|
}
|
|
|
|
/** Reset all emulated caps (mainly for tests / fresh sessions). */
|
|
export function resetCaps(): void {
|
|
caps = new CapRegistry();
|
|
}
|
|
|
|
// Cap surface — polyfill-era (caps are emulated now; native at migration).
|
|
// Re-exported here so the whole polyfill API lives under /polyfill.
|
|
export { CapRegistry } from "./caps";
|