Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
17 KiB
SDK reference — reading data with @ng-eventually/client
Audience: anyone using @ng-eventually/client (the app that consumes it, and
the lib itself when honoring the contract). This is the reference on the SDK's
read/reactivity surface — how you read data and how a read stays live.
@ng-eventually/client is written and consumed as if NextGraph were a finished,
mature SDK: documents per entity placed by scope, capabilities, inboxes, and a
reactive ORM. This file documents that finished-SDK contract. Where today's
emulation does not yet deliver it, that is called out in one clearly-separated
section at the end (§ Current emulation status) and in
nextgraph-current-state.md — that is
an emulation gap to close, not the SDK's design. Read the reference itself as the
target contract.
The ground truth for the finished contract is the real NextGraph platform
(nextgraph-rs, sibling clone at ../nextgraph-rs); the reactive primitives are
cited by file:symbol throughout so a future agent can re-verify cheaply.
TL;DR — the canonical read is reactive
Read data with the reactive ORM hook
useShape— subscribe to a shape over a scope, get the current value, and re-render on every change (yours or a remote peer's, synced through the broker). Subscription/push, never polling. One-shot reads are the exception, not the rule.
import { useShape } from "@ng-eventually/client";
import { EventShapeType } from "…/shapes/orm/…";
function EventList() {
// A live, reactive set. Re-renders whenever any Event doc in scope changes —
// locally or from a remote peer synced by the broker. No polling, no refetch.
const events = useShape(EventShapeType, { graphs: [scopeNuri] });
return <>{[...events].map((e) => <Row key={e["@id"]} event={e} />)}</>;
}
The reactivity model — subscription/push, never polling
NextGraph's philosophy is subscription-based push. You do not poll for changes; you subscribe once and the platform pushes an update to every subscriber the moment a document changes. A change is a new commit on a document's branch, and it reaches subscribers whether it was applied locally (your own write) or delivered from a remote peer and synced through the broker.
The load-bearing fact — verified in nextgraph-rs — is that both origins converge
on a single push point in the verifier:
- Local commit (your own SPARQL update / ORM write): the write path builds
BranchUpdateInfos and callsVerifier::update_graph(engine/verifier/src/commits/transaction.rs:646). - Remote commit (another session/peer, synced via the broker): the broker hands
the event to
Verifier::deliver(engine/verifier/src/verifier.rs:1718) →verify_commit→verify_async_transaction(engine/verifier/src/commits/transaction.rs:295), which calls the sameupdate_graph(transaction.rs:327). - The single choke point:
update_graphpushes anAppResponseV0::Patchto the branch's subscribers viaVerifier::push_app_response(engine/verifier/src/verifier.rs:252) — it looks up the branch inbranch_subscriptionsandsender.send(response).await— and fans out to the reactive ORM viaorm_backend_update(engine/verifier/src/orm/graph/handle_backend_update.rs:48), which sends anAppResponseV0::GraphOrmUpdateto each ORM subscription whose scope was touched.
So: one document, one commit, every subscriber pushed — the same code path for a
local edit and for a remote peer's edit arriving over the network. That is what makes
a useShape read reactive across peers with no polling.
The recommended read path — the reactive ORM hook useShape
useShape is the way to read. It subscribes to a shape (a typed view — the
SHEX/ORM shape) over a scope (one or more document NURIs / a subject set), returns
the current materialized set immediately, and re-renders the component on every
change to any document in scope.
Signature
useShape<T extends BaseType>(
shape: ShapeType<T>,
scope: Scope | string | undefined,
): DeepSignalSet<T>
shape— the ORM shape type (generated from a SHEX shape). Names the entity type and the properties to materialize.scope— where to read: a{ graphs, subjects }scope object or a NURI string.undefinedyields an empty read.- Returns a
DeepSignalSet<T>— a live reactive set. Iterate it like a set; the component re-renders whenever the set changes.
Verified surface in nextgraph-rs:
sdk/js/orm/src/frontendAdapters/react/useShape.ts (useShape, line 86) →
OrmSubscription (sdk/js/orm/src/connector/GraphOrmSubscription.ts), which calls
ng.orm_start_graph(...) with a callback, applies the initial materialized objects
and every subsequent patch to a DeepSignalSet
(applyPatchesToDeepSignal), and drives React re-render via
useDeepSignal (@ng-org/alien-deepsignals/react). Vue and Svelte adapters exist
alongside the React one (sdk/js/orm/src/frontendAdapters/{vue,svelte}/).
@ng-eventually/client re-exports useShape from
../src/use-shape.ts; import it from the SDK
(@ng-eventually/client), never from @ng-org/orm directly.
What you get, in order
- An initial value. On subscribe, the ORM materializes the current objects in
scope and delivers them first (
AppResponseV0::GraphOrmInitial,engine/verifier/src/orm/graph/initialize.rs:113). The hook returns them as the initialDeepSignalSet. - A stream of updates. On every subsequent commit affecting the scope — local or
remote — the ORM pushes a patch (
AppResponseV0::GraphOrmUpdate), the connector applies it to theDeepSignalSet, and the component re-renders. No refetch, no interval.
Under the hood — the streamed primitives
useShape is built on NextGraph's streamed request primitives. A consumer never
calls these directly, but they define the contract:
orm_start_graph(shape, scope, …, callback)— the reactive graph ORM subscription (sdk/js/lib-wasm/src/lib.rs:1951). ReturnsGraphOrmInitialthen a stream ofGraphOrmUpdate. This is whatuseShapeuses.orm_start_discrete(nuri, …, callback)— the reactive discrete (Yjs/Automerge document) ORM (lib-wasm/src/lib.rs:1929);DiscreteOrmInitialthenDiscreteOrmUpdate.doc_subscribe(nuri, …, callback)— a lower-level document subscription (lib-wasm/src/lib.rs:1908; verifierVerifier::create_branch_subscription,verifier.rs:352). Delivers an initialTabInfo+State(heads, full graph, discrete state, files —verifier.rs:470/:476) then a stream ofPatchon each commit. This is the raw reactive read;useShapeis the typed, ergonomic layer on top.- All of these are streamed (marked by
AppRequestCommandV0::is_stream(),engine/net/src/app_protocol.rs:762) and delivered through the one generic streamed bindingapp_request_stream_(lib-wasm/src/lib.rs:1385), which invokes a JS callback perAppResponseand returns a cancel function. The reactive surface is callback-based at the wasm boundary;useShapehides that behind a reactive signal.
Rule of thumb: to read,
useShape. It subscribes, gives you the value now, and keeps it live. Reach for a one-shot read only when you explicitly do not want to stay subscribed.
The one-shot read — the exception
Sometimes you want the current value once, with no live subscription (a batch, a
guard, a migration). NextGraph's one-shot read is a plain SPARQL query — non-streamed,
computes a result and returns once (sparql_query,
sdk/js/lib-wasm/src/lib.rs:352/553; no "subscribe to a query" exists —
sparql_query is not reactive).
In @ng-eventually/client the one-shot read is exposed as:
docs.sparqlQuery(sid, query, base?, anchor?)— a raw anchored SPARQL query (../src/docs.ts).anchor= the document NURI to read; the anchor restricts the query to that one repo's graph.readModel.readUnion(docs)— read a bounded, by-need set of document NURIs, each with its own anchored query, grouped per subject (../src/read-model.ts). This is the polyfill's listing primitive (see § Current emulation status andread-model.md).
One-shot reads do not re-render on change. To stay live over a one-shot read you must
re-run it on a change signal (e.g. re-call readUnion when a doc_subscribe
fires) — a manual assembly that exists only because of the emulation gap below; the
finished contract is useShape.
The write surface (at a glance)
You do not need the write internals to read, but reads and writes share the same document model, so briefly:
- Create a document:
docs.docCreate(sid, crdt, cls, dest, store?)(../src/docs.ts) — mirrorsng.doc_create. One document = one repo (did:ng:o:<RepoID>); there is no separateDocumenttype. - Write into it:
docs.sparqlUpdate(sid, query, anchor)— a SPARQLINSERT/DELETEscoped to the anchor document's graph. Or, at the ORM layer, the ORM update primitives (graph_orm_update). A write is a commit on the document's branch — which is exactly what everyuseShapesubscriber over that document is pushed. - Writes target one document, never "the union": a SPARQL update must name one
document's graph (
resolve_target_for_sparql(update=true)returnsInvalidTargetfor the union,engine/verifier/src/request_processor.rs:275).
Identity & scope (what a consumer needs)
Data is isolated per document (repo), and each document lives in a scope:
| Scope | Read | Write |
|---|---|---|
| Private | Owner only | Owner only |
| Protected | Owner + whoever the owner delivered the cap to | Owner + permissioned collaborators |
| Public | Whoever has the URL (the repo link) | Owner only |
Consequences a consumer must internalize:
- Reading is key possession, never an authorization list. You hold a document's
ReadCap(…:r:{cap}) or you do not read it — there is no "may X read Y?" to ask, here or upstream. A cap-lessdid:ng:o:…names a document without granting anything, which is what lets public content point at private content without disclosing it. Caps reach you two ways: creating a document files its own, and someone delivering one to your inbox (shareCap). Nothing derives a cap from a bare reference. - Isolation is per-document, not per-store. Holding a store's cap does not
grant read on the documents it contains — each document has its own ReadCap. Fine-
grained isolation therefore means one document per entity
(
engine/repo/src/types.rs, ReadCap granularity; seenextgraph-current-state.md§ Capability / ReadCap granularity). - Read isolation is cryptographic. A reactive/union read over a repo you hold no
cap for simply returns nothing (the repo is never decrypted); a targeted read of
an unheld repo raises
RepoNotFound. - Public means everyone reads, only the owner writes. There is no primitive by
which a non-owner appends to a document (public or otherwise): a write commit
requires repo membership plus a matching write permission, gated by
Repo::verify_permission(engine/repo/src/repo.rs:584— a non-member author isPermissionDenied) and cryptographically bound to the repo's write-cap secret. The permission enum (engine/repo/src/types.rs:1729,PermissionV0) hasWriteAsync/WriteSyncbut no add-only/append permission and no public-writable grant. To surface data to others without a shared write, use the inbox (any identity — even anonymous — can deposit into a document's native inbox; the owner materializes deposits) or make the document public-readable and let each identity own its own document.
The consumer asks the SDK for what it needs and trusts the result; it does not construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list helpers live in the consumer app; the SDK exposes the generic reactive/by-need read.
Current emulation status
This section is about where today's polyfill does NOT yet deliver the reactive contract above. It is an emulation gap to close, not the SDK's design. The reference above is the target; the finished SDK reads reactively via
useShapeeverywhere. Full detail:nextgraph-current-state.md,read-model.md,simulation.md.
Today, on a single shared wallet emulating the mature platform, four gaps diverge from the reactive contract:
-
Entity-list reads are one-shot, not reactive. The reactive ORM cannot be used as the listing primitive because the ORM fan-out over a set of per-entity / not-yet-synced document graphs hangs: a freshly-created or unsynced graph makes
RepoNotFoundabort the wholeorm_start_graph, so the subscription never emits its initial and never resolves (root cause verified —engine/verifier/src/request_processor.rsresolve_target→self.repos.get(...).ok_or(RepoNotFound); seenextgraph-current-state.md§ The ORM fan-out hang). So the lib reads entity lists withreadModel.readUnion— a bounded set of one-shot anchoredsparql_querys (read-model.md) — and reassembles reactivity by re-querying on a change signal (a lightweightdoc_subscribe/ single-store ORM used only as a signal source, then re-runreadUnion).useShaperemains valid for a single already-opened document; it is the per-entity fan-out that is unfit today. -
The inbox uses a polling watcher. The inbox is emulated (
AppRequestCommandV0::InboxPosthas no verifier arm today; no wasm helper seals a deposit), soinbox.watch(../src/inbox.ts) polls viasetInterval(default 1s) instead of subscribing. The finished contract is push (the broker already routes the inbox natively); these become subscriptions when the sealed-inbox path (inbox_post_link) lands. -
No cross-wallet / on-demand repo open. There is no JS primitive to sync an unknown repo by NURI+ReadCap today (
load_repo_from_read_capispub(crate), unexposed; theOpenRepobroker path is a TODO atengine/verifier/src/verifier.rs:1423). The mono-wallet polyfill sidesteps this: every account's docs aredoc_created in the same session, so they are already queryable. At the multi-store migration, opening a repo by cap becomes a native broker sync and the anchored read is unchanged. -
The subscription may not echo the writer's OWN local commit — HYPOTHESIS (high-confidence), confirmation in progress (2026-07-18); NOT confirmed, NOT fixed. Unlike gaps 1–3 (designed emulation stopgaps), this is a suspected defect in the polyfill's own reactive assembly. When a client does a local
sparqlUpdateon a doc it is itself subscribed to (subscribeDoc/ng.doc_subscribe), the subscription callback appears NOT to fire for its OWN local commit in the same session — so the reactive re-read chain (../src/watch-shape.tswatchShape→reread→../src/read-model.tsreadUnion) never runs, and consumers keep the STALE value until the next connection delivers a fresh initialState. Remote commits DO push correctly (verified: cross-browser reactive update works). A code review verified the consumer wiring is correct, the doc IS in the subscribed set, and a triggered re-read WOULD return the new value — leaving the self-commit echo as the only suspect link. That link is INFERRED, not observed: the realng.doc_subscriberuntime is not readable from source, and../src/subscribe.ts's own doc-comment CLAIMS local writes push aPatch— contradicted by the observation. (This also sits in tension with § The reactivity model above, which documents the target contract — one commit, every subscriber pushed, local or remote.) The requirement at stake is multi-user: a value change (e.g. a participant count) must propagate reactively to ALL viewers — other viewers (remote push, which works) AND the writer's own view (this suspect link). Treatment (PLANNED, not done): confirm first via the temporary instrumentation just added (../src/subscribe.ts≈:119logsdoc_subscribe FIRE <nuri> (State|Patch);../src/watch-shape.ts≈:341logsreread TRIGGER by <nuri>— line numbers volatile, grep the log strings); then, IF confirmed, fix polyfill-side — a local commit should notify the doc's activesubscribeDoccallbacks. Consumers must not compensate. Short entry:nextgraph-current-state.md§ Known open issues.
When these gaps close, the read path collapses to the reference above: useShape
everywhere, push everywhere, no polling and no re-query-on-signal assembly.