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:
Sylvain Duchesne
2026-08-04 12:16:59 +02:00
parent e5779ca850
commit d07b3642aa
6 changed files with 972 additions and 32 deletions
+24 -20
View File
@@ -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);