refactor(api): séparer la surface de l'app et la machinerie
L'entrée SDK déversait la machinerie par deux fuites : - `export * as storeRegistry from "./store-registry"` exportait TOUT le module — `ensureAccount`, `addLink`, `readLinks`, `resolveAccount`, `reservedAccount`, `resetRegistryCache`, `isOwnInbox`, `myInboxes`, `userStoreDoc`. Remplacé par `store-registry-api.ts`, qui ne ré-expose que les sept appels destinés à l'app : createEntityDoc, listMyEntityDocs, resolveScopeGraph, resolveWriteGraph, walletInbox, openDocumentInbox, documentInboxAddress. - `accounts.*` — persistance d'identité navigateur, sans aucun pendant SDK — passe sur `/polyfill`, où sa disparition à la migration se lit sur la ligne d'import. L'en-tête d'`index.ts` affirmait n'exposer « que ce que @ng-org/web et @ng-org/orm exposent ». C'était faux et enseignait une frontière fausse : un consommateur en déduisait que tout ce qui s'importe de l'entrée survit à la migration, ce qui ne valait ni pour `accounts` ni pour l'essentiel de `storeRegistry`. Il énonce désormais ce que l'entrée promet vraiment : tout symbole y a un pendant dans le futur SDK, vérifié ou assumé, et rien n'y est de la machinerie. La frontière mord : le typecheck e2e a échoué aussitôt, le harnais atteignant `ensureAccount` et `resetRegistryCache` par l'entrée publique. Il passe désormais par le chemin interne, comme les tests unitaires — légitime, il teste la bibliothèque. Deux documents plutôt qu'un, mêmes exigences, publics différents : `docs/api-contract.md` (la surface de l'app, avec pour chaque sujet la signature que le futur SDK devrait exposer, et l'étiquette qui distingue le vérifié de l'assumé) et `docs/internal-contract.md` (le complément exact). 157 tests unitaires, e2e 40/40 contre le broker en ligne.
This commit is contained in:
@@ -34,8 +34,12 @@ import {
|
||||
storeRegistry,
|
||||
useShape as libUseShape,
|
||||
watchShape,
|
||||
accounts,
|
||||
} from "@ng-eventually/client";
|
||||
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
|
||||
// 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/store-registry";
|
||||
import * as accounts from "../src/accounts";
|
||||
import { isNuri } from "@ng-eventually/client";
|
||||
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
|
||||
|
||||
@@ -436,10 +440,10 @@ const identity = new IdentityStore(
|
||||
|
||||
// ── store-registry ───────────────────────────────────────────────────────
|
||||
async ensureAccountIdempotent(id: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
const first = await storeRegistry.ensureAccount(id);
|
||||
storeRegistry.resetRegistryCache();
|
||||
const second = await storeRegistry.ensureAccount(id);
|
||||
registryInternals.resetRegistryCache();
|
||||
const first = await registryInternals.ensureAccount(id);
|
||||
registryInternals.resetRegistryCache();
|
||||
const second = await registryInternals.ensureAccount(id);
|
||||
return {
|
||||
firstDocs: [first.docPublic, first.docProtected, first.docPrivate],
|
||||
secondDocs: [second.docPublic, second.docProtected, second.docPrivate],
|
||||
@@ -450,7 +454,7 @@ const identity = new IdentityStore(
|
||||
};
|
||||
},
|
||||
async entityDocsBounded(idA: string, idB: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
// Each user creates its OWN documents: you act as one virtual user at a time,
|
||||
// and the caps of what you create are filed under the identity you were acting
|
||||
// as. Creating B's document while connected as A is not a thing the model has.
|
||||
@@ -463,7 +467,7 @@ const identity = new IdentityStore(
|
||||
setCurrentUser(idA);
|
||||
let listA: string[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
listA = await storeRegistry.listMyEntityDocs(idA, "public");
|
||||
if (listA.includes(dA1) && listA.includes(dA2)) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
@@ -490,7 +494,7 @@ const identity = new IdentityStore(
|
||||
* expected values to assert against.
|
||||
*/
|
||||
async reconnectSeed(id: string, scope: "public" | "protected" | "private") {
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
const s = await sessionReady;
|
||||
// Seed AS the user whose document this is — otherwise the cap of the created
|
||||
// document is filed under nobody and the very session that created it is
|
||||
@@ -508,7 +512,7 @@ const identity = new IdentityStore(
|
||||
// data is persisted before the fresh session tries to read it back.
|
||||
let listed: string[] = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
listed = await storeRegistry.listMyEntityDocs(id, scope);
|
||||
if (listed.includes(entityNuri)) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
@@ -534,7 +538,7 @@ const identity = new IdentityStore(
|
||||
setCurrentUser(id);
|
||||
await connectedUser();
|
||||
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
const listed = await storeRegistry.listMyEntityDocs(id, scope);
|
||||
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
|
||||
// many rows the bare anchored query resolves for a not-yet-opened repo (the
|
||||
@@ -579,8 +583,8 @@ const identity = new IdentityStore(
|
||||
* first so the resolve goes to the shim, not a same-session in-memory hit.
|
||||
*/
|
||||
async accountDocs(id: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
const rec = await storeRegistry.ensureAccount(id);
|
||||
registryInternals.resetRegistryCache();
|
||||
const rec = await registryInternals.ensureAccount(id);
|
||||
return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
||||
},
|
||||
async scopeResolvers() {
|
||||
@@ -621,9 +625,9 @@ const identity = new IdentityStore(
|
||||
* truthy iff provisioning succeeded) so the runner can gate on real persistence.
|
||||
*/
|
||||
async coldEnsureAccount(id: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
try {
|
||||
const rec = await storeRegistry.ensureAccount(id);
|
||||
const rec = await registryInternals.ensureAccount(id);
|
||||
return {
|
||||
threw: false,
|
||||
error: null,
|
||||
@@ -642,9 +646,9 @@ const identity = new IdentityStore(
|
||||
* docs coldEnsureAccount minted (real persistence, no RepoNotFound).
|
||||
*/
|
||||
async verifyShimPersisted(id: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
try {
|
||||
const rec = await storeRegistry.ensureAccount(id);
|
||||
const rec = await registryInternals.ensureAccount(id);
|
||||
return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
||||
} catch (e: any) {
|
||||
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
|
||||
@@ -668,7 +672,7 @@ const identity = new IdentityStore(
|
||||
* doc/type so the runner can assert the data landed.
|
||||
*/
|
||||
async watchShapeSeedAndSubscribe(handle: string, cls: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
const id = "@ws-" + handle;
|
||||
setCurrentUser(id);
|
||||
const doc = await storeRegistry.createEntityDoc(id, "protected");
|
||||
@@ -681,7 +685,7 @@ const identity = new IdentityStore(
|
||||
);
|
||||
// Wait until this session sees the index append (data persisted on the broker).
|
||||
for (let i = 0; i < 15; i++) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
const listed = await storeRegistry.listMyEntityDocs(id, "protected");
|
||||
if (listed.includes(doc)) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
@@ -739,7 +743,7 @@ const identity = new IdentityStore(
|
||||
* the handle; poll watchShapeSnapshot for the transition.
|
||||
*/
|
||||
watchShapeEmptyStart(handle: string, cls: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
const id = "@ws-empty-" + handle;
|
||||
setCurrentUser(id);
|
||||
const shape = {
|
||||
@@ -813,7 +817,7 @@ const identity = new IdentityStore(
|
||||
* what a unit test passing the NURI through a variable cannot prove.
|
||||
*/
|
||||
async documentInboxDeposit(ownerId: string, depositorId: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
registryInternals.resetRegistryCache();
|
||||
setCurrentUser(ownerId);
|
||||
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
|
||||
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
/**
|
||||
* @ng-eventually/client — **SDK-identical** surface.
|
||||
* @ng-eventually/client — the surface a consumer application codes against.
|
||||
*
|
||||
* This entry exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose (current +
|
||||
* anticipated: `inbox`). Import `ng` / `useShape` from here instead of the SDK
|
||||
* during the polyfill period; at migration the build alias is removed and these
|
||||
* resolve to the real SDK with **no code change**.
|
||||
* Everything here has a target-SDK counterpart, verified or assumed, listed in
|
||||
* `docs/api-contract.md`. Import `ng` / `useShape` from here rather than from the
|
||||
* SDK during the polyfill period; at migration the build alias is removed and
|
||||
* these resolve to the real SDK.
|
||||
*
|
||||
* The one non-SDK piece — the polyfill bootstrap (`configure`, capability
|
||||
* helpers, current user) — lives at `@ng-eventually/client/polyfill`, and is the
|
||||
* only thing removed at migration.
|
||||
* **This entry carries no machinery.** The earlier header claimed it exposed "ONLY
|
||||
* what `@ng-org/web` / `@ng-org/orm` expose", which was false as written: it also
|
||||
* shipped the whole `store-registry` module (account resolution, cap registers,
|
||||
* cache resets) and `accounts` (browser identity persistence, polyfill-era with no
|
||||
* SDK counterpart). Both leaked machinery onto the entry whose promise is that it
|
||||
* survives migration. `storeRegistry` is now the app-facing slice only
|
||||
* (`store-registry-api.ts`); `accounts` moved to `/polyfill`.
|
||||
*
|
||||
* The polyfill bootstrap — `configure`, the capability helpers, the current user,
|
||||
* identity persistence — lives at `@ng-eventually/client/polyfill`: everything an
|
||||
* application needs TODAY that will not exist tomorrow, kept apart so what goes
|
||||
* away is visible at the import line.
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
@@ -22,10 +31,7 @@ export { subscribeDoc, subscribeDocs, docChangeType } from "./subscribe";
|
||||
export type { DocChange, DocChangeType, Unsubscribe } from "./subscribe";
|
||||
export * as readModel from "./read-model";
|
||||
export type { UnionSubject } from "./read-model";
|
||||
export * as storeRegistry from "./store-registry";
|
||||
export type { AccountRecord, RegistrySession } from "./store-registry";
|
||||
export * as accounts from "./accounts";
|
||||
export type { AccountStorage } from "./accounts";
|
||||
export * as storeRegistry from "./store-registry-api";
|
||||
|
||||
// SPARQL injection-safety helpers — so the app can reuse the same escaping /
|
||||
// validation when it builds SPARQL by interpolation. `escapeLiteral` for string
|
||||
|
||||
@@ -227,3 +227,15 @@ export function resetCaps(): void {
|
||||
export { CapRegistry } from "./caps";
|
||||
export { shareCap } from "./inbox";
|
||||
export { connectedUser } from "./connect";
|
||||
|
||||
// --- identity persistence (polyfill-era, no SDK counterpart) ----------------
|
||||
//
|
||||
// Moved here from the SDK-identical entry on 2026-08-03. `accounts` persists WHICH
|
||||
// virtual user is connected, in browser storage — a notion that exists only because
|
||||
// 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 "./accounts";
|
||||
export type { AccountStorage } from "./accounts";
|
||||
// Config-shaped types the bootstrap needs; both describe the shim, not the SDK.
|
||||
export type { AccountRecord, RegistrySession } from "./store-registry";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* The app-facing slice of `store-registry` — and the reason it exists as a file.
|
||||
*
|
||||
* `store-registry.ts` holds two things that must not be exported together: the
|
||||
* placement/addressing calls a consumer application legitimately makes, and the
|
||||
* shim machinery that makes virtual users work at all (account resolution, the
|
||||
* durable cap registers, the inbox-ownership predicate, cache resets). Until now
|
||||
* `index.ts` did `export * as storeRegistry from "./store-registry"` and shipped
|
||||
* both, so an application could reach `ensureAccount`, `addLink` or
|
||||
* `resetRegistryCache` from the SDK-identical entry — machinery it must never call,
|
||||
* on the entry whose whole promise is "this survives migration unchanged".
|
||||
*
|
||||
* What is re-exported here is only what an application needs to do its own work,
|
||||
* and each has a target-SDK counterpart (see `docs/api-contract.md`). Everything
|
||||
* else stays reachable at `./store-registry` for the library's own modules, the
|
||||
* unit tests and the e2e harness — an internal path, not a published one.
|
||||
*
|
||||
* At migration this file disappears: placement becomes the user's real per-scope
|
||||
* stores and the calls below become native SDK ones.
|
||||
*/
|
||||
|
||||
export {
|
||||
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
|
||||
createEntityDoc,
|
||||
/** The entity documents this user owns in `scope` — with their caps recovered. */
|
||||
listMyEntityDocs,
|
||||
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
|
||||
resolveScopeGraph,
|
||||
/** 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,
|
||||
/** 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. */
|
||||
documentInboxAddress,
|
||||
} from "./store-registry";
|
||||
Reference in New Issue
Block a user