737729c9ce
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. »
202 lines
18 KiB
Markdown
202 lines
18 KiB
Markdown
# ng-eventually
|
|
|
|
A generic polyfill layer over the [NextGraph](https://nextgraph.org) JS SDK.
|
|
|
|
NextGraph's JS SDK does not yet expose cross-wallet reads, capabilities, inboxes
|
|
or group stores. `ng-eventually` lets a consumer application behave as if those
|
|
existed today, by emulating them on top of a single shared wallet / broker. It is
|
|
generic: it contains no application domain — the consumer application injects its
|
|
shapes and the acts of granting access.
|
|
|
|
The name: *eventually* NextGraph will ship these features; until then this layer
|
|
fills the gap (and nods at eventual consistency / events).
|
|
|
|
## The boundary — mature face out, compensation in
|
|
|
|
The asymmetry is the point. The consumer application writes SDK-shaped code as if
|
|
NextGraph were finished: per-entity documents in public/protected/private stores,
|
|
capabilities, inboxes. This library owns the current-state NextGraph knowledge and
|
|
the simulation that fabricates that mature face — a shared-wallet emulation — so
|
|
the application never sees it. As NextGraph matures, this library changes; the
|
|
consumer application's code does not.
|
|
|
|
Docs (this library's own engineering doctrine, under [`docs/`](./docs/)):
|
|
|
|
- [`docs/nextgraph-current-state.md`](./docs/nextgraph-current-state.md) — the
|
|
authoritative reference on what the current SDK/broker do and do not expose
|
|
(the ground truth each polyfill compensates for).
|
|
- [`docs/simulation.md`](./docs/simulation.md) — how this lib emulates the mature
|
|
behaviour on one shared wallet (shim, per-document ReadCaps, emulated inbox,
|
|
write guard, the two axes, the double-proxy constraint).
|
|
- [`docs/read-model.md`](./docs/read-model.md) — the read model the polyfill
|
|
implements: you follow links, you never enumerate; listing via a bounded set of
|
|
per-doc anchored `sparql_query`s; reactivity via re-query on a change signal.
|
|
- [`docs/decisions/`](./docs/decisions/) — current-SDK ADRs (private-store scope,
|
|
SPARQL delete, shared-wallet identity).
|
|
- [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch /
|
|
self-host inbox path not taken (kept as a fallback).
|
|
- [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when
|
|
real NextGraph matures.
|
|
|
|
## What is emulated
|
|
|
|
Nothing in this library is a real NextGraph feature. Each behaviour below is
|
|
emulated — a stopgap fabricated on top of the current, immature NextGraph (one
|
|
shared wallet, everything physically readable). The consumer application always
|
|
sees the mature SDK face; the emulation lives entirely here.
|
|
|
|
The table reads: what the consumer application does, the real NextGraph target it
|
|
is written against, the current NextGraph implementation status (why a workaround
|
|
is needed), and how this lib emulates it today.
|
|
|
|
| Capability | What the consumer application does | Real NextGraph target | Current NextGraph status (why a workaround) | Current emulation |
|
|
|---|---|---|---|---|
|
|
| Multi-identity / per-identity wallet | Treats each identity id as its own wallet with its own documents | Each identity opens its own real wallet; native cross-wallet reads | Not-yet-implemented: the JS SDK exposes no cross-wallet read, so one session cannot read another identity's wallet | One shared wallet everyone opens; "identities" are virtual users — shim accounts keyed by an id, each mapped to its documents in `shared-wallet/account-registry.ts` |
|
|
| Three native stores per identity | Places entities by scope `public` / `protected` / `private` | The identity's three real native stores hold the entity documents | Not-yet-implemented: `doc_create`/ORM can target only the private (and protected) native store today; a `public`/arbitrary `StoreRepo` is not JS-constructible | Three emulated scope-index documents per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the one shared private store, and scope is a logical label |
|
|
| Per-document read isolation | Nothing to declare: creating a document records its cap on its store, and its creator holds it. Reading is `capFor(doc)` — you hold the key or you do not read | The broker/verifier delivers only documents the wallet holds a ReadCap for; accessing a document without the cap yields an empty result in a union read (a targeted read of an unheld repo errors with `RepoNotFound`) | The model itself is the point: reading is key possession, and there is no read-ACL to introspect — a client cannot ask "may this identity read this doc?" because that question does not exist upstream | Caps recorded per identity: `AddRepo` on the store's emulated Store branch for documents it creates, `AddLink` on its User branch for caps received; `emulated-verifier/caps.ts` caches them for the session. A read filter (`emulated-verifier/read-filter.ts`) plus the boundary (`emulated-verifier/reach.ts`) keep only documents whose cap is held. The cap value is the stand-in `OK` — enforcement is P1b |
|
|
| Directed read sharing | Owns the relationship concept ("who is connected to whom") itself, and on acceptance shares one document's cap to the other's inbox (`shareCap(cap, theirInbox)`) | The cap sealed to the recipient's inbox key (`ContactDetails.read_cap`), opened by their own verifier while processing the inbox | Not-yet-implemented — a **gap, not a disagreement**: the field exists but the message construction is `unimplemented!()`, its only caller passes "without read_cap", and the receiver discards the cap. The shape is right; the implementation is absent | `shareCap` deposits the cap into the recipient's inbox document; the recipient's existing `inbox.watch` absorbs it into what they hold. No "receive" operation, and no principal is ever named to the registry |
|
|
| Inbox (registration notifications) | `inbox.post` / `read` / `watch` | A message is sealed to the recipient's key and queued in their inbox; the recipient's own verifier unseals and applies each queued message inline while processing the inbox | Not reachable from JS: the verifier has no `InboxPost` arm, and no `inbox` method exists in `@ng-org/web`. (`inbox_post_link` is OUR proposed name from [`docs/fork-inbox-fallback.md`](docs/fork-inbox-fallback.md), not an announced NextGraph API — no such symbol exists in `nextgraph-rs`.) | Deposits written as RDF into an inbox document via SPARQL; `read`/`watch` read the deposits back — an in-lib stand-in for the recipient's own inbox processing |
|
|
| ~~Discovery of all public events~~ **REMOVED 2026-07-30** | Circulates the link itself — into inboxes, or into a document the reader already holds | **There is no discovery.** You cannot discover, you can only follow links: publishing = place the data in your public store **and** circulate the link, seen only by those who received it (a foundation of local-first) | Not a gap to be filled — a global index is not a NextGraph shape, and it would pool data across wallets | Nothing. `discovery.ts` and its global index were removed: they emulated a capability the target will never have. See [`docs/readcap-and-nuri-model.md`](docs/readcap-and-nuri-model.md) §4ter-bis |
|
|
| Reads / listing | Lists the documents it needs, by scope, and reads them | Native per-wallet reads over the real per-identity stores | Bug/perf: an anchorless union query spans every named graph in the session store, which on a shared / accumulating wallet is O(wallet size) and stalls | A bounded, by-need set of per-doc anchored `sparql_query`s (each anchored to one repo's default graph), independent of wallet size |
|
|
| Reactivity | Lists update on change | Native reactive reads | Not-yet-implemented: there is no reactive union query across graphs | Re-query the bounded per-doc anchored set on a lightweight change signal (`doc_subscribe` / ORM on an already-opened single store) |
|
|
| Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) |
|
|
| Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`shared-wallet/virtualUsers.ts` `IdentityStore` persists it); the read filter and inbox `from` read it |
|
|
| Write-guard | Writes refused without the write cap | The broker/verifier enforces the write cap natively — writing is repo membership (`verify_permission`, reachable only from `Commit::verify`) | Not-yet-implemented for delegation: this library emulates no `AddMember`/`AddPermission`, so only a document's OWNER writes — a repo's upstream state until someone is added | An ownership check at the write door (`emulated-verifier/reach.ts` `assertMayWrite`, reading authorship from the Store branch). The older write-cap proxy (`surface/ng-proxy.ts`) is **inert** — `grantWrite` has no production caller, so its policy set is always empty |
|
|
|
|
## Packages
|
|
|
|
| Package | Role |
|
|
|---|---|
|
|
| `@ng-eventually/polyfill` *(was `@ng-eventually/client` until 2026-08-07)* | The SDK-identical wrapper the app imports instead of `@ng-org/web` / `@ng-org/orm`. It adds the polyfills the broker/verifier will do natively (shared-wallet identity, capability enforcement, anticipated cap/inbox methods). As NextGraph matures, the app points back at the real SDK (build alias removed) and this package falls away. |
|
|
|
|
A global-index package is deferred. Data common to all of an application's users comes from a **singleton app**: a document or store shared by all users and hardcoded in the app, write-owned by the developer and delegable — but never to all users, so user contributions reach it **through an inbox** (nothing in NextGraph is freely writable by everyone). That is the direction the NextGraph developer has named; it is **not implemented**, and several points are still open (what exactly is hardcoded, how delegation travels, who materializes the inbox). So there is no second package for now — it will be introduced once the mechanism exists, and it will be separate from the SDK wrapper. See [`docs/nextgraph-current-state.md`](docs/nextgraph-current-state.md) § Apps & services.
|
|
|
|
## Design principle
|
|
|
|
The application code is written as if the target NextGraph existed. All
|
|
compensation lives here, beside the app. As NextGraph matures, this layer falls
|
|
away; the app code (SDK-shaped) is unchanged.
|
|
|
|
**Both halves are binding, and the second is the one that gets traded away.** The
|
|
SURFACE must be as close as possible to the future SDK — that much is obvious, it is
|
|
what the consumer codes against. But the IMPLEMENTATION must be as close as possible to
|
|
what NextGraph actually plans, and there is no exception to that. Where upstream's
|
|
behaviour is known, it is a specification, not a reference: **when it is known, hold to
|
|
it**. What "known" means here is narrow — read in `nextgraph-rs` or stated by the
|
|
NextGraph developer, never inferred from what an npm package happens to expose, and
|
|
never inferred from an absent implementation ("the engine has no X" says nothing about
|
|
whether the target will).
|
|
|
|
The pressure to deviate never announces itself as a deviation. It shows up as a cost, a
|
|
latency, an ergonomic wrinkle — a real one. Two instances, both caught only by asking
|
|
the question:
|
|
|
|
- *Every document has a native inbox* was written into the docs from general
|
|
reasoning. It is false, and it had already become an implementation.
|
|
- A per-document inbox was made to point at **the owner's** inbox, to avoid a measured
|
|
cost (9m37 → 21m30 on the consumer's suite). It emulates a many-to-one relation
|
|
upstream cannot express: the verifier routes by `inboxes: PubKey → RepoId` and unseals
|
|
with that one repo's key (`engine/verifier/src/verifier.rs:1677,1928`), and a message
|
|
carries no target document because it needs none. Reverted. The cost was then solved
|
|
without touching the shape — only documents meant to receive open an inbox.
|
|
|
|
The tell in both: an implementation choice that would make the consumer learn something
|
|
it must **unlearn** at migration. That is the thing this library exists to prevent, so
|
|
it outranks cost, latency and convenience. When the shape and the cost conflict, keep
|
|
the shape and attack the cost elsewhere — and if it truly cannot be solved, say so
|
|
rather than bend the model quietly.
|
|
|
|
### The three references, numbered bottom-up
|
|
|
|
"NextGraph" is not one layer, and conflating them is how a fact about one gets asserted
|
|
about another. They are **stacked**, each built on the one below, so they are numbered
|
|
from the bottom:
|
|
|
|
| # | Layer | Where |
|
|
|---|---|---|
|
|
| **3** | **JS SDK / ORM** | `@ng-org/orm`, `@ng-org/shex-orm` — source in `sdk/js/orm` (TypeScript) |
|
|
| **2** | **wasm binding** | `@ng-org/web` — source in `sdk/js/lib-wasm` (77 exported methods) |
|
|
| **1** | **Rust engine** | `engine/` — `repo`, `verifier`, `net`, `broker`, `wallet` |
|
|
|
|
**These are REFERENCES, not places we write code.** Every line this library ships lives
|
|
in the polyfill; none of these three layers is ours to touch, and `nextgraph-rs` is a
|
|
read-only source of truth. Saying "level 1" about a piece of our code means *"it is
|
|
aligned on the engine's model"* — never *"it lives in the engine"*.
|
|
|
|
**Which reference to align on: take the HIGHEST one that answers, and go down only when
|
|
it does not.**
|
|
|
|
- **Level 3 answers fully → do not implement it here.** Pass through. Compensation code
|
|
that doubles a working SDK function is code to delete later, and it diverges meanwhile.
|
|
- **Level 3 is absent or unsatisfactory → align on the level-2 call** that does the job.
|
|
Ergonomics are lost, semantics are kept — and migrating later means moving up one
|
|
step, not rewriting.
|
|
- **Nothing at level 2 either → align on the level-1 MODEL**: cardinalities, addressing
|
|
units, what a structure can and cannot express.
|
|
|
|
Level 1's facts are the hardest-won, but aligning there means **inventing a JS surface**,
|
|
since none exists yet. So always **say which level a choice came from**. A level-3
|
|
passthrough is a fact; a level-1 shape is a bet constrained by the engine. Presenting
|
|
them alike is what manufactures false certainty — `inbox_post_link` was cited across
|
|
eight files as a planned NextGraph API when it was only a name proposed in
|
|
[`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md).
|
|
|
|
Concretely for the inbox: level 3 has nothing, level 2 has **no `inbox` method at all**
|
|
(and the verifier has no `InboxPost` arm), so `inbox.*` is aligned on level 1 — the
|
|
engine's model (one inbox ↔ one repo, addressed by `(overlay, pubkey)`, no target
|
|
document in the message) with a JS surface of our own making.
|
|
|
|
This cascade answers *"we need X — what do we align on?"*. It is **not** a checklist to
|
|
run over what the target exposes: an unused binding method is not a debt, and "it was in
|
|
the unused list" is not a reason to investigate it.
|
|
|
|
> **Do not confuse these levels with the other "three levels" in this repo.**
|
|
> [`docs/readcap-and-nuri-model.md`](./docs/readcap-and-nuri-model.md) §4quinquies numbers
|
|
> *where a cap is stored* (wallet root key → the Store/User branch registers → the local
|
|
> in-memory cache). Same word, unrelated axis: these three are **layers of NextGraph to
|
|
> align on**, those three are **places a key lives**. When it is not obvious from the
|
|
> sentence, say "reference level" or "storage level".
|
|
|
|
- SDK-identical surface: the client wraps the real `ng` (a Proxy that forwards
|
|
everything and overrides only what must be emulated) and `useShape`. The real
|
|
SDK is injected via `configure()` (no hard import → build-alias safe and
|
|
testable).
|
|
- Authorization is emulated capabilities: documents carry grants; the client
|
|
enforces them generically (read filter + write guard). The app declares a
|
|
document, shares one document's cap to an inbox — the same acts it will
|
|
perform in the target. No policy is injected.
|
|
- Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet
|
|
emulation, reads the deposits back (`read` / `materialize` / `watch`) in place
|
|
of the recipient's own inbox processing.
|
|
- Tests of the polyfill (against a real broker) live in this repo, in **two** suites,
|
|
and the split is deliberate: `packages/polyfill/e2e/run.ts` (`test:e2e`) characterises the
|
|
primitives and the platform contracts, while `packages/polyfill/e2e/notebook.ts`
|
|
(`test:e2e:app`) drives the example application through the DOM, one browser page per
|
|
identity. Only the second can tell whether an application is *writable* — a harness
|
|
can pass a value between two identities through a variable, and an application cannot.
|
|
A consuming app can test its own features against a clean, mocked API.
|
|
|
|
## Status
|
|
|
|
Implemented. The polyfill mechanisms are wired against a real broker, not stubbed:
|
|
|
|
- Shared-wallet shim — `shared-wallet/account-registry.ts` (`(account, scope) → document NURI`,
|
|
`createEntityDoc` / `listMyEntityDocs` + per-user stores, cross-device via the RDF
|
|
shim anchored in the private store).
|
|
- Document / SPARQL primitive — `docs.ts`, calling the real injected `ng` directly
|
|
(avoids the `@ng-org` double-proxy `DataCloneError`).
|
|
- Emulated ReadCaps — `emulated-verifier/caps.ts` (`CapRegistry`, per-document, directed grants) +
|
|
read filter `emulated-verifier/read-filter.ts` (reactive-set `Proxy` view), applied by
|
|
`surface/use-shape.ts` only once a cap exists (`caps.isEnforcing()`).
|
|
- Write guard — `surface/ng-proxy.ts` (`sparql_update` override, emulated write cap).
|
|
- Inbox — `inbox.ts` (`post` / `read` / `materialize` / `watch`).
|
|
- Identity — `shared-wallet/virtualUsers.ts` (`IdentityStore`, injected storage).
|
|
- SPARQL hardening — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`).
|
|
|
|
The remaining `TODO` markers are narrow: the shared-wallet credential passthrough
|
|
in the `session_start` proxy branch, and the anticipated cap/inbox SDK signatures
|
|
to reconcile if the official API differs. See
|
|
[`docs/simulation.md`](./docs/simulation.md) for what each piece does and
|
|
[`docs/migration-guide.md`](./docs/migration-guide.md) for what changes as
|
|
NextGraph matures.
|