Files
ng-eventually/packages/client
Sylvain Duchesne 88914f50ae refactor(layout): ranger les modules par destin à la migration
Les 25 modules étaient à plat, nommés d'après ce qu'ils font mécaniquement
(`store-registry`, `read-model`, `reach`, `caps`). Rien dans l'arborescence ne
disait lesquels DEVIENDRONT le vrai SDK, lesquels tiennent lieu du travail que
le verifier fera nativement, et lesquels n'existent que parce qu'un wallet est
partagé — trois destins sans rapport.

Quatre dossiers, les deux fichiers d'entrée restant à la racine pour que
l'`exports` du paquet et le code du consommateur ne bougent pas :

- `model/` — le modèle d'adressage de la cible, transcrit : vocabulaire pur,
  pas d'I/O. Survit comme connaissance.
- `surface/` — ce que l'app touche, chaque symbole ayant un pendant cible
  documenté. Supprimé quand l'alias bascule ; le code de l'app est inchangé.
- `emulated-verifier/` — les doublures de ce que le verifier fait nativement :
  possession, dépôt des caps, frontière, non-livraison, traitement des inbox,
  registres de branche, ouverture de repo. **C'est le dossier où diverger du
  modèle est possible.** Le préfixe `emulated-` porte le sens : tient lieu de,
  jamais est — cette bibliothèque ne réside dans aucune couche de la cible,
  elle les référence.
- `shared-wallet/` — n'existe que parce qu'un wallet héberge toutes les
  identités. Aucun pendant, rien sur quoi s'aligner ; sa seule loi est de
  rester invisible depuis `surface/`. S'évapore, remplacé par rien.

`store-registry-api.ts` devient `surface/placement.ts` : il faisait déjà à la
main ce que la frontière de dossier fait structurellement — c'est la meilleure
preuve interne du bien-fondé de ce rangement.

Ce commit ne fait que déplacer et recâbler les imports (src, test, e2e). Les
scissions des modules à cheval suivent.

157 tests unitaires, typecheck src/test/e2e vert.
2026-08-04 12:46:44 +02:00
..

@ng-eventually/client

Two entry points — the data-plane is SDK-identical, the polyfill bootstrap is separate:

Import Surface
@ng-eventually/client The same signature as the SDK — ng, useShape, inbox (+ types). A drop-in for @ng-org/web / @ng-org/orm; as NextGraph matures it resolves to the real SDK (build alias removed) with no code change.
@ng-eventually/client/polyfill The only non-SDK surface — configure, setCurrentUser, and the capability surface (capFor, shareCap, getCaps). It falls away as NextGraph matures.

Reading is key possession, and the isolation here is still fake. The cap surface has the shape of the real model — you hold a document's ReadCap or you do not read it, and there is no authorization list anywhere — but nothing is encrypted yet and several read paths bypass the guard entirely. Nothing this library does may be described as "anonymous" or "private" until per-document encryption lands (P1b).

// bootstrap (the only non-SDK call) — inject the real SDK
import { configure } from "@ng-eventually/client/polyfill";
configure({ ng: realNg, useShape: realUseShape, sharedWallet, currentUser });

// from here on, a pure SDK surface:
import { ng, useShape, inbox } from "@ng-eventually/client";
await ng.doc_create(/* … */);
const set = useShape(MyShape, scope);   // filtered to what the identity may read
await inbox.post(targetInbox, ref);     // deposit (anticipated SDK API)

Principle — the polyfill compensates, it never extends

The polyfill's ONLY reason to exist is to bridge a NextGraph implementation gap or a bug. Every non-SDK surface must map to a capability NextGraph will provide natively, and must fall away at that point. The polyfill MUST NOT add functionality of its own — no bespoke features, no observability/tooling, no convenience API that isn't strictly "NextGraph will do this natively later." The test for any proposed addition: does it compensate a real, exhibited NextGraph gap or bug? If not, it does not belong here — build it in the consumer application, not in the polyfill. Corollary: a compensation whose gap is not actually exhibited on the target broker is dead weight, not defensive code — it should be removed, not kept "just in case."

What the polyfill adds on top of the real SDK (each emulated for now, native as NextGraph matures):

  • Shared-wallet identity (one wallet for everyone; the current identity id is relayed to the SDK).
  • Capability emulation — per-identity cap possession (capFor) and a read filter over it: you read the documents whose cap you hold. Creating a document files its cap; receiving one is an inbox deposit. There is no authorization list.
  • Anticipated methods (inbox post, shareCap) with their future-SDK shapes, emulated for now.

Generic: no application domain. The consumer application injects its shapes and performs the acts of sharing. The relationship concept ("who is connected to whom") is the consumer application's own — the client exposes only "share this one document's cap to that inbox".

The cap surface in three calls

import { capFor, shareCap, getCaps } from "@ng-eventually/client/polyfill";
import { storeRegistry } from "@ng-eventually/client";

// Creating a document records its cap and you hold it — nothing to declare.
const doc = await storeRegistry.createEntityDoc(myId, "protected");
capFor(doc);                       // → `${doc}:r:…` — you hold it

// Share it with one recipient, addressed by their inbox. They need no "receive"
// operation: their existing inbox.watch absorbs it.
await shareCap(capFor(doc)!, theirInbox);

// Publishing is TWO acts: place the data in your public store, and circulate its
// LINK. There is no discovery — you cannot be found, you can only be reached — so
// the link has to travel: into an inbox, or into a document the reader already
// holds. The bare NURI would name the document without opening it.
const link = getCaps().publishRepoLink(publicDoc);
await shareCap(link, theirInbox);

The one invariant to keep in mind: you never derive a cap from a bare reference. You look it up in what you hold, or you were given it. A did:ng:o:… without :r: names a document and grants nothing.

The types carry that invariant

Nuri and ReadCap are template literal types, not string aliases:

type Nuri    = `did:ng:${string}`
type ReadCap = `did:ng:${string}:r:${string}`

They are still strings — assignable to string, JSON-serializable, no wrapper — but the distinction is checked. A ReadCap goes wherever a Nuri is expected (a cap is a NURI with the key inside); the reverse does not compile:

await shareCap(doc, theirInbox);   // ✗ Argument of type '`did:ng:${string}`' is not
                                   //   assignable to '`did:ng:${string}:r:${string}`'

A string that comes from outside your code — storage, a URL, JSON, a form — is a plain string. Narrow it, do not cast it: a cast re-opens exactly the confusion the types close.

import { isNuri, hasReadCap } from "@ng-eventually/client";

const saved = localStorage.getItem("cap");
if (saved && hasReadCap(saved)) await shareCap(saved, theirInbox);   // ✓ narrowed

The runtime guards remain regardless — a JavaScript caller never meets the compiler, and a cast bypasses it — so passing a bare reference where a cap belongs throws with a message that says so.