refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph

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. »
This commit is contained in:
Sylvain Duchesne
2026-08-10 17:14:25 +02:00
parent 49b046268e
commit 737729c9ce
88 changed files with 122 additions and 106 deletions
+153
View File
@@ -0,0 +1,153 @@
/**
* Low-level document + SPARQL primitives.
*
* These call the real injected `ng` (`getConfig().ng`) directly — never the
* public `ng` proxy (`makeNg`). This is a validated hard constraint, not a style
* choice: the public `ng` is a JS `Proxy` over `@ng-org/web`'s iframe-RPC proxy,
* and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling
* with **`DataCloneError: function ... could not be cloned`** — the footgun this
* rule exists to prevent. Reaching the real `ng` held in the config avoids the
* double-proxy. Do not import from `./ng-proxy`.
*
* Signatures mirror the real `@ng-org/web` `ng` surface (verified against the
* app's storeRegistry usage), so this is a drop-in for those raw calls.
*/
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
import { logAccess, enabled as accessLogEnabled } from "../shared-wallet/access-log";
import { isNuri, toNuri } from "../model/nuri";
import { assertMayReach, assertMayWrite } from "../emulated-verifier/reach";
import { fetchReadCap } from "../emulated-verifier/public-store";
import type { Nuri, NuriLike } from "../model/types";
// The low common point for ALL document access: every read in the SDK routes
// through `sparqlQuery`, every write through `sparqlUpdate` (+ container creation
// through `docCreate`) — each ultimately calling the real injected `ng` here. The
// access log is therefore instrumented HERE so no access path escapes it. Callers
// pass a semantic `label` (readDoc|readUnion|listMyEntityDocs|writeEntity|deposit
// |…); it is a lib-internal probe param, NOT forwarded to the real `ng` (the docs
// primitives forward the exact SDK signature — see test/docs.test.ts). When the
// log is OFF (default) the extra param is inert and costs one boolean read.
/** Count rows in a raw SPARQL SELECT result, tolerant of the possible shapes. */
function rowCount(result: unknown): number {
if (!result) return 0;
if (Array.isArray(result)) return result.length;
const anyRes = result as { results?: { bindings?: unknown[] } };
return anyRes.results?.bindings?.length ?? 0;
}
/**
* Create one document → its NURI.
*
* Mirrors `ng.doc_create(session_id, crdt, cls, dest, store_repo?)`. For a graph
* document in the (shared) private store: `docCreate(sid, "Graph", "data:graph",
* "store")` (store_repo left undefined → private store).
*/
export async function docCreate(
sessionId: string,
crdt: string,
cls: string,
dest: string,
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
// The BROKER boundary. `ng` is a permissive property bag (`NgLike`), so what
// comes back is `any` and this function's `Promise<Nuri>` would otherwise be an
// unchecked promise — every typed NURI downstream rests on it. Validate once,
// here, rather than let a non-reference propagate as a document.
if (typeof nuri !== "string" || !isNuri(nuri)) {
throw new Error(
`[ng-eventually] docCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
);
}
// **Creating a document gives you its cap.** Upstream that is not a courtesy but
// the mechanism: `doc_create` commits `AddRepo { read_cap }` to the store's Store
// branch, so the creator holds it from the first instant. Without this, a caller
// could create a document through this primitive and then be refused reading or
// writing it — which is what the e2e run against the live broker exposed.
//
// `physical.ts`'s counterpart deliberately does NOT do this: the shim's own
// documents belong to no virtual user, and `store-registry` files their caps
// itself, where it knows whose they are.
getCaps().mint(nuri);
// A container creation is a WRITE; the NURI only exists after the call.
logAccess("WRITE", nuri, "docCreate");
return nuri;
}
/**
* Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.).
*
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
* document NURI the update is scoped/base'd to (optional).
*/
export async function sparqlUpdate(
sessionId: string,
query: string,
anchorLike?: NuriLike,
label = "sparqlUpdate",
): Promise<void> {
const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate");
// The boundary, in two questions that are NOT the same one.
//
// Reaching is possession. Writing is OWNERSHIP — upstream the right to write is
// membership of the repo (`verify_permission`, reachable only from `Commit::verify`,
// so on commits and never on reads), and how you came by the READ key changes nothing
// about it. A public store hands its read cap to whoever asks; a cap deposited in your
// inbox is a Link someone gave you. Neither makes you a member.
//
// NO public-store fetch here, unlike the read below: asking the network for a read key
// has no bearing on a write.
if (anchor !== undefined) {
assertMayReach(anchor, "docs.sparqlUpdate");
await assertMayWrite(anchor, "docs.sparqlUpdate");
}
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
logAccess("WRITE", anchor ?? "(no anchor)", label);
return ng.sparql_update(sessionId, query, anchor);
}
/**
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
*
* Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the
* query base IRI (usually `undefined`); `anchor` is the document NURI to query.
*/
export async function sparqlQuery(
sessionId: string,
query: string,
base?: string,
anchorLike?: NuriLike,
label = "sparqlQuery",
): Promise<unknown> {
const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlQuery");
// The boundary: an ANCHORED read may only touch what the connected virtual user
// reaches. An anchorless query spans the local union — a different problem (it is
// O(wallet size), and the read path never uses it), not one this guard can bound.
//
// Asking the (emulated) network first, as `ensureRepoOpen` does: a document in a
// public store gives its cap to whoever asks, and this is a door an application can
// reach with nothing but a bare reference. Inert once the cap is held, memoised
// otherwise — see public-store.ts.
if (anchor !== undefined) {
await fetchReadCap(anchor);
assertMayReach(anchor, "docs.sparqlQuery");
}
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
const result = await ng.sparql_query(sessionId, query, base, anchor);
// Log AFTER the read so the row count (a strong leak signal: a doc rendering
// rows under an identity that should see nothing) can be appended. Skip the
// rowCount work entirely when the log is off.
if (accessLogEnabled()) {
// `rows` here are raw RDF triple bindings (the SPARQL `?s ?p ?o` result), NOT
// domain objects — one document's entity is spread across several triple rows.
// Spell that out so the log isn't mistaken for an object count (the app-level
// object/shape count is logged separately by useShapeQuery → dataStats).
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " triple-rows");
}
return result;
}
+647
View File
@@ -0,0 +1,647 @@
/**
* Inbox — a generic deposit + read/materialize mechanism the consumer reuses for
* its own purposes (same `inbox.post` API, same watcher — see the discovery-model
* decision). The mechanism itself knows no application domain: the consumer
* supplies the inbox document NURI and interprets the `payload`. (An example
* consumer mapping, purely illustrative: a consumer might use one inbox for a
* registration deposit and another for submitting a reference to an index.)
*
* ── Real target vs this emulation ─────────────────────────────────────────
* In real NextGraph, a message is sealed to the recipient's key and queued into
* their inbox; the recipient's own verifier unseals each queued message and
* applies it inline as it processes the inbox — there is no separate curator
* process. There is NO sender-side JS call for this today: the verifier has no
* `InboxPost` arm and `@ng-org/web` exposes no inbox method at all. (`inbox_post_link`,
* named elsewhere in these docs, is OUR proposal from `docs/fork-inbox-fallback.md` —
* no such symbol exists in `nextgraph-rs`. Do not cite it as a planned API.)
*
* Here, on one shared wallet where everything is readable, both sides run in-lib:
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
* document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
* - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose
* them. This in-lib read stands in for the recipient's own inbox processing
* until a sealed-inbox path is exposed to JS.
*
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
* never `makeNg`), so this module imports no `@ng-org` package.
*/
import { sparqlQuery } from "./docs";
import { depositInto } from "../emulated-verifier/register-write";
import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers";
import { userInbox, isKnownInbox, lookupAccount } from "../shared-wallet/account-registry";
import { escapeLiteral } from "./sparql";
import { hasReadCap, toNuri } from "../model/nuri";
import {
accessLogPrefix,
enabled as accessLogEnabled,
logAccess,
logStage,
shortNuri,
} from "../shared-wallet/access-log";
import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
// --- deposit model --------------------------------------------------------
/** One deposit as materialized from an inbox document. */
export interface Deposit {
/** The sender, if identified; `null` when the deposit was anonymous. */
from: PrincipalId | null;
/** The consumer-defined payload (opaque here — JSON-serialized in storage). */
payload: unknown;
/** Deposit timestamp (ms epoch). Caller may pass one for determinism. */
ts: number;
}
/** Options for {@link post}. `from` and `ts` are both optional. */
export interface PostOptions {
/**
* Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a
* principal id to identify the sender. Defaults to the current polyfill user
* ({@link getCurrentUser}) when the property is entirely absent, so callers
* that want anonymity must pass `from: null` explicitly.
*/
from?: PrincipalId | null;
/** The payload to deposit (interpreted only by the consumer). */
payload: unknown;
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
* keeps tests deterministic. */
ts?: number;
}
const SHIM = "urn:ng-eventually:inbox";
const P = {
type: `${SHIM}:Deposit`,
from: `${SHIM}:from`,
payload: `${SHIM}:payload`,
ts: `${SHIM}:ts`,
} as const;
// --- session access (shared with the storeRegistry) -----------------------
/** The inbox documents live in the shared wallet, so we reuse the registry's
* injected session provider for the sessionId. Disappears at migration. */
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
// --- diagnostic logging helper ---------------------------------------------
/**
* Best-effort, length-capped JSON rendering of a deposit payload for the
* inbox diagnostic log (see {@link enabled}/{@link logAccess}). This module
* stays domain-agnostic (see module header) — it never interprets payload
* fields, it only dumps them verbatim so the consumer's own shape (e.g. a
* Festipod participation: `{ participantId, eventId, … }`) is visible in the
* log without this module knowing that shape. Capped so one oversized payload
* can't blow up a log line; a payload that fails to stringify (e.g. a
* circular structure a caller mistakenly passed) falls back to `String()`.
*/
function summarizePayload(payload: unknown): string {
let s: string;
try {
s = JSON.stringify(payload) ?? String(payload);
} catch {
s = String(payload);
}
return s.length > 200 ? s.slice(0, 200) + "…" : s;
}
// --- SPARQL result helpers ------------------------------------------------
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
if (!result) return [];
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
return anyRes.results?.bindings ?? [];
}
// --- deposit (client side) ------------------------------------------------
/**
* Deposit a payload into `targetInbox`.
*
* Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate`
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
* graph, so concurrent deposits don't collide.
*
* `from` is bound to the current identity — it is authenticated, not
* caller-supplied. Omit it to stamp the current identity; pass `null` to deposit
* anonymously (a legitimate choice — identified if known, anonymous otherwise).
* A `from` naming another identity is rejected as a spoof: in the target the
* broker seals the sender from the wallet's own key, so a client cannot forge
* another's identity. This check is redundant once the seal enforces it, but
* until then it closes the spoof the shared wallet would otherwise allow.
*/
export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promise<void> {
const targetInbox = toNuri(targetInboxLike, "inbox.post");
const current = getCurrentUser();
let from: PrincipalId | null;
if (opts.from === undefined) {
from = current; // default: stamp the current identity
} else if (opts.from === null) {
from = null; // explicit anonymous deposit
} else if (opts.from === current) {
from = opts.from; // identifying as self — allowed
} else {
throw new Error(
"[ng-eventually] inbox.post: `from` must be the current identity or null " +
"(anonymous) — depositing as another principal is a spoof.",
);
}
const ts = opts.ts ?? Date.now();
const sid = await sessionId();
// A unique subject per deposit (in the inbox graph) — no collisions.
const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`;
const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null));
const fromTriple =
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
// NO explicit `GRAPH <…>` wrapper — write the anchored DEFAULT graph:
// `sparqlUpdate(sid, update, targetInbox)` scopes the write to that repo's
// default graph (same shape as read-model.ts readDoc/readUnion). This is the
// CANONICAL, always-safe shape and the one the anchored default-graph read
// queries. (Not a round-trip necessity on the current broker: the e2e harness
// `packages/polyfill/e2e/` verified that an anchored `GRAPH <plainNuri>` write
// ALSO round-trips here — it resolves to the same repo graph, no phantom graph.
// The no-GRAPH form is kept as a simplicity/safety convention; re-verify with
// that harness if the broker version changes.)
const update = `
INSERT DATA {
<${subject}> a <${P.type}> ;
<${P.payload}> "${payloadLiteral}" ;
<${P.ts}> "${ts}"${fromTriple} .
}`;
// The target must BE an inbox, and this is the one check standing between a deposit
// and an arbitrary write into someone else's document.
//
// Upstream the question does not arise: `InboxPost` seals to an inbox PUBKEY and the
// broker routes it by `inboxes: PubKey → RepoId` — addressing a plain repo with a
// deposit is not refused, it is unrepresentable. Here an inbox is a document like any
// other, so without this `inbox.post(someoneElsesDocument, …)` wrote four triples into
// it, through a published door that skips both guards by design. Found by re-running
// the adversary on the fix that un-published `depositInto` (2026-08-07) — moving that
// function was not enough, because `post` reaches the same door.
if (!(await isKnownInbox(targetInbox))) {
throw new Error(
"[ng-eventually] inbox.post: refused — this is not an inbox. A deposit is addressed " +
"to an inbox, never to a document; upstream the two cannot even be confused, " +
`because a deposit carries an inbox key and not a document reference. ${JSON.stringify(targetInbox)}`,
);
}
// A deposit crosses the boundary on purpose — see `register-write.depositInto`.
await depositInto(sid, update, targetInbox, "deposit");
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):
// who deposited WHAT into which inbox — the decoded payload, not just the
// triple-write. Gated by the same access-log flag; skip the JSON work when off.
if (accessLogEnabled()) {
logAccess(
"WRITE",
targetInbox,
"inbox deposit",
" from=" + (from ?? "anonymous") + " payload=" + summarizePayload(opts.payload ?? null),
);
}
}
/**
* Deposit into the inbox of a DOCUMENT — resolve where, then deposit there.
*
* The call an app makes to reach a document's owner: it needs the document (which it
* must be able to read) and nothing else. Where the inbox is, and whether the owner
* ever opened one, are the library's business.
*
* **No target-document field on the deposit, deliberately.** Upstream an inbox belongs
* to exactly one repo — the verifier routes by `inboxes: PubKey → RepoId` and unseals
* with that repo's key (`engine/verifier/src/verifier.rs:1677`) — and `InboxMsgBody`
* carries no document (`engine/net/src/types.rs:4265`), because the address already
* identifies it. Tagging deposits with their document would be an invention consumers
* would have to unlearn at migration, so this resolves the address and stops there.
*
* @throws if the document has no inbox — its owner never opened one, so there is
* nowhere for this to go. Throwing rather than returning quietly is the whole lesson of
* this path: a deposit that vanishes without an error is worse than a refusal, and it
* is exactly the bug per-document inboxes shipped with
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). When "no inbox" is an expected
* case for the caller, catch it — there is deliberately no published way to ask an
* address in advance, because an application must name a document or a person, never an
* inbox.
*/
export async function postToDocument(docLike: NuriLike, opts: PostOptions): Promise<void> {
const doc = toNuri(docLike, "inbox.postToDocument");
const target = await documentInboxAddress(doc);
if (target === undefined) {
throw new Error(
"[ng-eventually] inbox.postToDocument: this document has no inbox — either its owner " +
"never opened one, or you cannot read the document (the address rides on it): " +
JSON.stringify(doc),
);
}
return post(target, opts);
}
// --- cap delivery ---------------------------------------------------------
/**
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it
* is the same one at all three stages: `InboxMsgContent::Link` is the message
* (`engine/net/src/types.rs:4249-4261`, declared but payload-less so far),
* `AddLink { read_cap }` is where the recipient files it (`repo/types.rs:1934-1950`),
* `RemoveLink` withdraws it. So giving access is: deposit a Link, and on connection
* the recipient processes their inbox and files it.
*
* It travels the SAME channel as any other deposit, which is why key ROTATION needs
* no special case on the surface — a re-delivered cap is just another Link.
*/
const LINK_KIND = "urn:ng-eventually:inbox:link";
/** Links observed during the last read of an inbox, awaiting durable filing. */
const seenByInbox = new Map<Nuri, ReadCap[]>();
function capsSeenIn(inbox: Nuri): ReadCap[] {
return seenByInbox.get(inbox) ?? [];
}
/** The cap a deposit carries, if it is a Link rather than consumer data. */
function capOfPayload(payload: unknown): ReadCap | null {
const p = payload as { kind?: unknown; cap?: unknown } | null;
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
}
/**
* Share ONE document with ONE recipient.
*
* The unit of sharing is the DOCUMENT: never hand over a store's cap, which would
* give away everything the store contains, present and future. The recipient needs
* no dedicated operation to receive it — the cap arrives as a deposit that their
* existing {@link watch} absorbs into what they hold (see {@link read}).
*
* Reaching several recipients means calling this once per inbox, which is what the
* real model does too: each delivery is sealed to one recipient.
*
* Upstream this path is a GAP, not a disagreement — verified at both ends:
* - the field exists, `ContactDetails.read_cap: Option<ReadCap>`
* (`engine/net/src/types.rs:4233`), but building a message that carries one is
* `read_cap: if with_readcap { unimplemented!() }` (`types.rs:3786`);
* - and the receiver ignores it: `InboxMsgContent::ContactDetails` creates a fresh
* contact document and writes `ng:site`/`ng:protected` + `ng:*_inbox`, a
* `vcard:Individual` type, a `vcard:fn` name and an optional `vcard:hasEmail`,
* then sets the header title (`engine/verifier/src/inbox_processor.rs:778-845`) —
* but never `details.read_cap`. *(The list was "only the two `ng:` predicates"
* until 2026-08-10, which understated what the arm writes; the load-bearing part
* is the omission, not the length of the list.)*
*
* Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit
* variant carrying nothing** (`engine/net/src/types.rs:4251`).
*
* The shape is right; the implementation is absent at both ends, so we emulate it
* meanwhile.
*/
export async function share(doc: NuriLike, toUser: string): Promise<void> {
const target = toNuri(doc, "inbox.share");
// Names the DOCUMENT and the PERSON — the two things an application has. Neither the
// key nor the address appears, because a caller will handle neither once this is
// native: upstream the verifier fills `ContactDetails.read_cap` itself, and an inbox
// is resolved from a profile. This took `(cap, toInbox)` at first, then `(cap, toUser)`;
// both made the caller hold something it will not hold later.
const cap = getCaps().capFor(target);
if (!cap) {
throw new Error(
"[ng-eventually] inbox.share: this document is not yours to share — you hold no cap " +
`for it. A cap is looked up in what you hold, or it was delivered to you: ${JSON.stringify(target)}`,
);
}
// ── KNOWN DIVERGENCE: the protected inbox is hard-coded here ──────────────
// Upstream the choice is not fixed. A contact record picks its inbox from the PROFILE
// through which the person was reached: `a_or_b = if details.profile.is_public()
// { "site" } else { "protected" }` (`engine/verifier/src/inbox_processor.rs:787`,
// written as `ng:site_inbox` vs `ng:protected_inbox` at `:823-824`). Reach someone by
// their public profile and the deposit goes to their public store's inbox; by their
// protected profile, to the protected one.
//
// This library has no notion of "the profile by which I know this person", so it
// always uses the protected one. Minor today — a consumer names a user and gets one
// answer — but it flattens a distinction the model makes, and the day an application
// shares with someone met through a public profile, this picks the wrong inbox.
//
// Not fixable in isolation: it needs a notion this library does not have, and about
// which nothing has been established here. What IS verified: a wallet holds `sites`,
// a `SiteV0` has an `id: PubKey`, a `name`, a `site_type` (Individual | Org) and three
// stores (`engine/verifier/src/site.rs:23-40`); the `Identity` enum that would name
// the rest is entirely COMMENTED OUT upstream (`engine/repo/src/types.rs:586-595`).
// Do not build on an assumed profile model — there is none to read yet.
//
// (The private store has no inbox at all — `new_store_default` attaches one only
// `if !private`, `verifier.rs:2994` — hence `InboxScope`, which makes "the private
// inbox" unwritable rather than merely empty.)
// The recipient must EXIST. `userInbox` provisions on first sight, so sharing with a
// name nobody has signed in as used to succeed silently: it minted that name's three
// stores and an inbox, and the cap landed where nobody will ever look. A mistyped
// recipient is the ordinary case, and it produced no error at all.
//
// Upstream you cannot address a name you invented: a deposit is sealed to an inbox
// PUBKEY (`InboxMsg::new`, `engine/net/src/types.rs:4299`) that reached you through an
// inbound `ContactDetails` — someone has to have reached you first. Refusing is the
// faithful behaviour; provisioning was the invention.
//
// `lookupAccount`, not `resolveAccount`: the tolerant form answers `null` for a read
// that FAILED as well as for one that found nothing, so it would have told a user
// "nobody has signed in as bob" because a query timed out. A refusal must not be
// built on a value that conflates absence with ignorance.
if ((await lookupAccount(toUser)) === null) {
throw new Error(
`[ng-eventually] inbox.share: no such recipient — nobody has signed in as ` +
`${JSON.stringify(toUser)}. Sharing does not create the person you share with.`,
);
}
await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } });
}
/**
* The messages left on a document YOU own — the read side of {@link postToDocument}.
*
* Named by the DOCUMENT, like the deposit side: an owner reading their own messages has
* no more reason to handle an inbox address than a depositor does. Empty when the
* document has no inbox, which is a state and not an error.
*/
export async function readForDocument(docLike: NuriLike): Promise<Deposit[]> {
const doc = toNuri(docLike, "inbox.readForDocument");
const address = await documentInboxAddress(doc);
return address ? read(address) : [];
}
// --- the read guard ------------------------------------------------------
/**
* Refuse to READ an inbox that is not the current wallet's.
*
* Depositing into someone else's inbox is the one legitimate cross-wallet act (it
* is how a link reaches another wallet at all — see {@link post} / {@link share});
* READING one is not, and it is not symmetric with it. Since caps travel as
* deposits, an unguarded read let anyone who knew an inbox NURI collect the caps
* addressed to its owner, which defeats directed sharing entirely.
*
* Anonymous owns no inbox, so it can read none — an identity has to be established
* first. At migration this disappears: an inbox is sealed to its owner's key, and
* the guard is the cryptography.
*/
async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
if (getCurrentUser() === null) {
throw new Error(
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` +
"session — call `ensureIdentity()` first. Depositing (post/share) stays open.",
);
}
if (!(await isOwnInbox(targetInbox))) {
throw new Error(
`[ng-eventually] inbox.${op}: refusing to read an inbox that does not belong to ` +
"the connected wallet. You may DEPOSIT into anyone's inbox; you may only READ " +
"your own — otherwise the caps addressed to its owner would be collectable by " +
`whoever knows its NURI: ${JSON.stringify(targetInbox)}`,
);
}
}
// --- read --------------------------------------------------------------
/**
* Read every deposit currently in `targetInbox`, sorted by `ts` ascending. In
* real NextGraph the recipient's own verifier applies queued messages inline as
* it processes the inbox; here this read stands in for that until the
* sealed-inbox path is available. The consumer interprets each deposit's
* `payload`.
*
* Cap deliveries ({@link share}) are applied inline and NOT returned: they land
* in what the current holder holds, like the verifier applying a queued message.
* That is why receiving a cap needs no dedicated operation — a consumer already
* watching its inbox gets them, and the resulting change re-triggers the
* reads that were empty for want of that cap.
*/
export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.read");
await assertOwnInbox(targetInbox, "read");
// WHO this read belongs to, captured with the guard that authorised it — see the note
// beside the filing below, and `caps.holderKey`.
const owner = getCurrentUser();
const ownerKey = getCaps().holderKey();
const sid = await sessionId();
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
// (a cold reader that opens the repo before reading), NOT here — `inbox.watch`
// already holds the repo open via its own `subscribeDoc`, so opening a second
// bootstrap subscription from inside a watch's re-read would be redundant and can
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
// read leaves both callers correct: the watch path stays event-driven, and the
// cold direct-read path opens the repo explicitly before calling `read`.
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
// default graph, exactly where `post` writes.
const query = `
SELECT ?payload ?ts ?from WHERE {
?d a <${P.type}> ;
<${P.payload}> ?payload ;
<${P.ts}> ?ts .
OPTIONAL { ?d <${P.from}> ?from }
}`;
const result = await sparqlQuery(sid, query, undefined, targetInbox, "inboxRead");
const deposits: Deposit[] = [];
for (const row of readBindings(result)) {
const rawPayload = row.payload?.value ?? "null";
let payload: unknown;
try {
payload = JSON.parse(rawPayload);
} catch {
payload = rawPayload; // tolerate a non-JSON literal
}
const tsRaw = row.ts?.value ?? "0";
const ts = Number.parseInt(tsRaw, 10) || 0;
const fromValue = row.from?.value;
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
}
deposits.sort((a, b) => a.ts - b.ts);
// Links are infrastructure, not consumer data: they never reach the caller. They
// are only KEPT here (in memory, for this session) — FILING them durably is
// `processInbox`'s job, because reading an inbox must not quietly write to a
// user's store. Filing fires the registry's change signal, which is what makes a
// view that was empty for want of that cap re-read instead of staying stale.
const delivered: Deposit[] = [];
const links: ReadCap[] = [];
// The ownership guard ran at entry; the filing happens several awaits later, and filing
// resolves WHO is holding at that moment. So an application switching identity in the
// gap could have this inbox's caps land in the NEW holder's ring. A hazard read off the
// code, not a leak anyone reproduced — see `caps.holderKey`.
//
// Abandoning is the faithful answer: upstream an inbox is processed by ITS owner's
// verifier, and switching user is another session. Nothing is lost — an inbox is not
// consumed by reading, so the next connection under the right identity files them.
const stillOwner = getCurrentUser() === owner;
for (const d of deposits) {
const cap = capOfPayload(d.payload);
if (cap) {
if (stillOwner) getCaps().learnFor(ownerKey, cap);
links.push(cap);
continue;
}
delivered.push(d);
}
if (links.length > 0) seenByInbox.set(targetInbox, links);
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of
// each — the exact visibility needed to trace materialization at the owner
// side. Gated by the same access-log flag; skip the JSON work when off.
if (accessLogEnabled()) {
logAccess(
"READ",
targetInbox,
"inbox materialize",
" → " + delivered.length + " message(s)" +
(deposits.length !== delivered.length
? " (+" + (deposits.length - delivered.length) + " cap deliver(y/ies) absorbed)"
: ""),
);
for (const d of delivered) {
logAccess(
"READ",
targetInbox,
"inbox message",
" ts=" + d.ts + " from=" + (d.from ?? "anonymous") + " payload=" + summarizePayload(d.payload),
);
}
}
return delivered;
}
/** Alias for {@link read} — the name that reads as "process the inbox now". */
export const materialize = read;
/**
* COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at
* (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first
* `State` (the deterministic sync barrier — after it, presence is guaranteed and
* absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}.
*
* Why this over a plain {@link read}: on a FRESH session over the persistent
* wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's
* `self.repos`, so a plain anchored `read` resolves an unopened repo and silently
* returns 0 deposits — even for a deposit a remote session already synced to the
* broker. Gating on the sync barrier makes the read see the synced deposits. This
* is the same cold-read heal any cold direct reader needs.
*
* NOT for the `watch` path: {@link watch} already holds the repo open via its own
* `subscribeDoc`, so opening a second bootstrap subscription from inside a watch
* re-read would be redundant and could race the watch's own initial-`State`
* delivery. Use this from a COLD reader (materialize-at-connection), like
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
*/
export async function readSynced(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.readSynced");
// Marks the cold, connection-triggered entry point in the trace — the BARRIER
// line (open-repo.ts) and the "inbox materialize"/"inbox message" lines below
// (from the read() this wraps) follow right after, so a live session shows
// the whole owner-reconnect sequence together.
logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)");
await assertOwnInbox(targetInbox, "readSynced");
await ensureRepoOpen(targetInbox);
return read(targetInbox);
}
/**
* PROCESS an inbox: read it, and **apply** what it contains.
*
* Applying a {@link share} Link means filing it durably — `storeRegistry.addLink`,
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
* the cap survives the session. Upstream this is what a verifier does when it
* processes queued messages: an inbox is a **queue you consume**, not a store you
* re-read. Re-reading an inbox every session to recover caps is using a queue as a
* database, and it is the thing this replaces.
*
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
* {@link read} does — Links are never surfaced.
*/
export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.processInbox");
const deposits = await readSynced(targetInbox);
// `readSynced` already put every Link in memory for this session; now make
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
// are taken from what the read just observed.
for (const cap of capsSeenIn(targetInbox)) await addLink(cap);
seenByInbox.delete(targetInbox);
return deposits;
}
/**
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
* `onDeposits` fires once on the initial state push and again on every subsequent
* change to the inbox document — a local deposit OR a broker-synced remote one.
* Returns an unsubscribe function.
*
* On each push it re-reads the full deposit list ({@link read}) and invokes
* `onDeposits` only when the deposit count changed (grew), keeping the same
* "fires on change" contract the polling watcher had — same callback signature
* and same behaviour, just event-driven instead of `setInterval`.
*
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
* there is no polling. (The inbox document is a single doc, so this is immune to
* the ORM fan-out hang — see {@link subscribeDoc}.)
*/
export function watch(
targetInboxLike: NuriLike,
onDeposits: (deposits: Deposit[]) => void,
_opts?: { intervalMs?: number },
): () => void {
// Permissive in, precise out — like every other public entry. It took a bare `Nuri`
// until 2026-08-10, which contradicted the very reason no type guard is published.
const targetInbox = toNuri(targetInboxLike, "inbox.watch");
let stopped = false;
let lastCount = -1;
// Re-read on every push; fire onDeposits only when the set changed (grew).
const refresh = async (): Promise<void> => {
if (stopped) return;
try {
const deposits = await read(targetInbox);
const changed = deposits.length !== lastCount;
// Owner-side processing decision: did this push actually grow the
// deposit set (→ onDeposits fires, the polyfill's stand-in for
// materialization) or was it a no-op push (→ skipped)? This is the
// exact line to check for the "must reconnect an extra time" symptom:
// a push whose read still sees the OLD count means the barrier/read
// raced the write, not that watch itself failed to fire.
if (accessLogEnabled()) {
logAccess(
"READ",
targetInbox,
"inbox watch",
" → " + deposits.length + " message(s)" + (changed ? " (materializing)" : " (unchanged, skip)"),
);
}
if (!stopped && changed) {
lastCount = deposits.length;
onDeposits(deposits);
}
} catch (error) {
console.error(accessLogPrefix() + " watch read failed:", error);
}
};
// Subscribe to the inbox document: the initial State push fires the first read
// (so onDeposits fires once immediately, as before), each later Patch a re-read.
// The ownership guard runs inside `read`, so a watch on someone else's inbox
// yields nothing but logged refusals rather than their deposits.
const unsubscribe = subscribeDoc(targetInbox, () => void refresh());
return () => {
stopped = true;
unsubscribe();
};
}
@@ -0,0 +1,22 @@
/**
* Lifecycle re-exports — SDK-shaped forwarders so the app imports `init` /
* `initNg` from `@ng-eventually/polyfill` rather than from `@ng-org/*`. They
* delegate to the REAL functions injected at `configure()`. Passthrough today;
* a hook point later (e.g. opening the shared wallet on `init`).
*/
import { getConfig } from "../shared-wallet/bootstrap";
/** Forwards to the real `@ng-org/web` `init`. */
export function init(...args: any[]): any {
const f = getConfig().init;
if (!f) throw new Error("[ng-eventually] init() not injected — pass it to configure()");
return f(...args);
}
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */
export function initNg(...args: any[]): any {
const f = getConfig().initNg;
if (!f) throw new Error("[ng-eventually] initNg() not injected — pass it to configure()");
return f(...args);
}
+65
View File
@@ -0,0 +1,65 @@
/**
* The wrapped `ng`: a Proxy that forwards every method to the real SDK and
* overrides only what the broker/verifier will do natively at migration. The
* surface stays identical to `@ng-org/web`'s `ng`.
*/
import { getConfig, getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import type { Nuri } from "../model/types";
export function makeNg(): Record<string, any> {
return new Proxy({} as Record<string, any>, {
get(_target, prop: string) {
const { ng } = getConfig();
// session_start → open the SHARED wallet invisibly.
//
// `login` used to be listed here too. `@ng-org/web` exposes no such method —
// zero occurrences in the installed declarations and in `sdk/js/lib-wasm/src/lib.rs`
// — so the proxy FABRICATED a member: `ng.login` answered a function instead of
// `undefined`, and calling it threw. The one place this wrapper added to the SDK
// surface, against its own header. Removed 2026-08-03.
if (prop === "session_start") {
return (...args: any[]) => {
// TODO(polyfill): supply shared-wallet credentials so no wallet UI
// is shown. For now, passthrough.
return ng[prop]!(...args);
};
}
// sparql_update → write guard (emulated write-cap check).
// Mirrors the target broker/verifier: a write is refused unless the wallet
// holds the document's WRITE cap. Emulated per-document via CapRegistry.
// args = (session_id, query, anchor?) — `anchor` is the target doc NURI.
if (prop === "sparql_update") {
return (...args: any[]) => {
const anchor = args[2] as Nuri | undefined;
const caps = getCaps();
// Passthrough (no regression) unless a WRITE policy exists AND this
// specific document is governed by it. Ungoverned docs (mono-store
// default, no cap declared) flow through exactly as before.
if (
typeof anchor === "string" &&
caps.hasWritePolicy() &&
caps.governsWrite(anchor) &&
!caps.canWrite(anchor, getCurrentUser())
) {
return Promise.reject(
new Error(
`[ng-eventually] write denied: current user lacks the write cap for ${anchor}`,
),
);
}
return ng.sparql_update!(...args);
};
}
// TODO(anticipated API): a sealed inbox deposit + capability operations — expose
// here with their anticipated signatures, emulated for now.
// Everything else: passthrough to the real SDK, unchanged.
const real = ng[prop];
return typeof real === "function" ? real.bind(ng) : real;
},
});
}
@@ -0,0 +1,93 @@
/**
* 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 "../shared-wallet/account-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.
*
* **No inbox ADDRESS is published here**, deliberately (`userInbox`,
* `documentInboxAddress`, removed 2026-08-05). An application deposits with
* `inbox.postToDocument(doc, …)`, shares with `inbox.share(doc, toUser)` and reads
* its own with `inbox.readForDocument(doc)` — always naming a document or a person,
* never an address. Upstream an address is resolved from a profile and never handled by
* a caller, so exposing one taught a step that has to be unlearned. The example
* application is the check: it must never name an inbox.
*/
/**
* **No IDENTITY parameter here either**, and that is the same reasoning one step further
* (2026-08-10). The registry's own functions take `(id, scope)` — machinery needs to name
* a user. An application does not: upstream `doc_create(session_id, …)` carries no user at
* all, because a session IS one user's. Passing one's own identity to every placement
* call is therefore a gesture with no successor, and it forced the application to KNOW
* its identity — which it could only do by reading the access gate's private storage key.
*
* The identity comes from `ensureIdentity()`, which returns it; these calls take the
* connected one from the session, exactly as the real SDK will.
*/
import {
createEntityDoc as registryCreateEntityDoc,
listMyEntityDocs as registryListMyEntityDocs,
resolveWriteGraph as registryResolveWriteGraph,
} from "../shared-wallet/account-registry";
import { getCurrentUser } from "../shared-wallet/bootstrap";
import type { Nuri, Scope } from "../model/types";
/** WHO is acting. Absent means the application has not signed in yet — a caller error,
* and one worth naming rather than turning into an empty result. */
function connectedIdentity(op: string): string {
const id = getCurrentUser();
if (id === null) {
throw new Error(
`[ng-eventually] storeRegistry.${op}: no identity is set. Call \`ensureIdentity()\` ` +
"first — it settles who you are and returns it.",
);
}
return id;
}
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
export async function createEntityDoc(scope: Scope): Promise<Nuri> {
return registryCreateEntityDoc(connectedIdentity("createEntityDoc"), scope);
}
/** The entity documents this user owns in `scope` — with their caps recovered. */
export async function listMyEntityDocs(scope: Scope): Promise<Nuri[]> {
return registryListMyEntityDocs(connectedIdentity("listMyEntityDocs"), scope);
}
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
export async function resolveWriteGraph(scope: Scope): Promise<Nuri> {
return registryResolveWriteGraph(connectedIdentity("resolveWriteGraph"), scope);
}
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
export { resolveScopeGraph } from "../shared-wallet/account-registry";
export { openDocumentInbox } from "../emulated-verifier/branch-registers";
// No `linkTo` here, and its absence is deliberate (it existed 2026-08-06, one day).
//
// It returned a document's KEY where a caller would ask for its reference, which turns
// the access rule from "whoever has the reference AND the key reads" into "whoever has
// the reference reads" — see `docs/readcap-and-nuri-model.md` § 0. That is not a leak of
// hygiene, it is the rule changing: a document one circulates would grant everything it
// MENTIONS, and confidentiality could no longer be composed inside a shared document.
//
// An application names a document with the reference it already has (every call here
// returns bare ones), and grants access with `inbox.share(doc, toUser)`. What travels
// with a key in it is a deliberate act, not the result of asking for a link.
+244
View File
@@ -0,0 +1,244 @@
/**
* read-model — the listing primitive of the polyfill: read a bounded, by-need set
* of documents, each with its own anchored `sparql_query`, and return the triples
* grouped per subject. This is the mechanism documented in docs/read-model.md.
*
* ── Why per-doc anchored, rather than an anchorless union-scan ─────────────
* An anchored `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)`
* is restricted to the anchor repo's graph: `resolve_target_for_sparql(Repo)` →
* `Some(repo_graph_name)`, which becomes the query's default graph. A body with no
* `GRAPH` wrapper reads only that default graph → only that doc's triples, O(1) per
* doc, independent of how many other graphs the local store holds.
*
* The footgun this avoids: an anchorless query (`anchor` undefined → `UserSite` →
* `set_default_graph_as_union`) spans EVERY named graph currently in the session
* store. On a shared / bloated wallet that accumulates across runs, that is
* O(wallet size) → the observed ~90s timeouts. So the read path never union-scans
* all graphs — it reads exactly the bounded by-need set, one anchored query per doc.
*
* NB (verified, docs/read-model.md § probe step 4): an explicit `GRAPH ?g { … }`
* body iterates the named graphs regardless of the default graph, so an anchor does
* not bound such a body. The per-doc read therefore uses a default-graph body (no
* `GRAPH` wrapper) so the anchor's one-repo restriction actually applies.
*
* ── Why not the reactive ORM fan-out ──────────────────────────────────────
* `useShape({ graphs: […manyDocs] })` drives `orm_start_graph` over a fan-out of
* per-entity graphs; a freshly-created / not-yet-synced doc in that fan-out makes
* `RepoNotFound` abort the whole subscription → the readyPromise never resolves →
* the ~75s hang (docs/nextgraph-current-state.md § The ORM fan-out hang). Listing
* is instead a set of one-shot anchored `sparql_query`s. There is no reactive
* union query, so reactivity is assembled by re-querying on a change signal.
*
* ── Generic by construction ───────────────────────────────────────────────
* No application domain here: the consumer passes the doc NURIs to read (from
* the discovery index for public events, or its own scope docs for my-entities)
* and interprets the returned per-subject property bags. All NextGraph I/O routes
* through the T01.a `docs` primitives (the real injected `ng`), so this module
* imports no `@ng-org` package.
*
* At the real multi-store migration the per-doc anchored read is unchanged (native
* SPARQL, anchored to one repo); only bringing a repo into the session (open by cap)
* changes — the anchored query already resolves a same-session repo directly.
*/
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getCaps, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { mustNotAttempt } from "../emulated-verifier/reach";
import { ensureReposOpen } from "../emulated-verifier/open-repo";
import { assertNuri } from "./sparql";
import { toNuri } from "../model/nuri";
import { isMachinerySubject } from "../emulated-verifier/machinery";
import type { Nuri, NuriLike } from "../model/types";
// Keep the primitives referenced so tree-shaking never drops the import used by
// the (side-effecting) open step below; `docCreate`/`sparqlUpdate` are not used
// here but the module intentionally depends only on the docs primitive surface.
void docCreate;
void sparqlUpdate;
/** One subject read from a doc, with its properties (predicate → values). */
export interface UnionSubject {
/**
* The subject IRI (`?s`), exactly as the document carries it.
*
* Typed `string`, not `Nuri`: a subject is an ordinary RDF subject and may be ANY
* IRI. An application that writes its entity under the document's own NURI gets a
* NURI back here, but that is its convention, not this type's promise — a document
* may hold several subjects under IRIs of the consumer's choosing, and each comes
* back as written.
*
* The need that once made this `Nuri` is real and is met by {@link graph}: a
* consumer must be able to pass back what it just read without a cast —
* `shareNote(note.doc)`, `leaveMessage(note.doc)` — and a cast at that boundary
* would re-open exactly the confusion the template literal types exist to close.
* `graph` is the document reference, so that is the field to carry around.
*/
subject: string;
/**
* The document this subject was read from — the reference the caller passed to
* {@link readUnion}, unchanged. This is the anchor, it identifies the document
* stably, and it is what goes back into any call of this surface.
*/
graph: Nuri;
/** predicate IRI → the list of object values (literals or IRIs) for it. */
props: Record<string, string[]>;
}
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function bindings(
result: unknown,
): Array<Record<string, { value: string } | undefined>> {
if (!result) return [];
if (Array.isArray(result))
return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
return anyRes.results?.bindings ?? [];
}
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
/**
* Read one doc with an anchored default-graph query, tolerant per-doc.
*
* The anchor (`doc` NURI) restricts the query to that repo's graph as the default
* graph (`resolve_target_for_sparql(Repo)` → `Some(repo_graph_name)`); a body with
* no `GRAPH` wrapper reads exactly that default graph → only this doc's triples.
* This is O(1) in the doc's own size and independent of the rest of the (possibly
* bloated / shared) session store — it never iterates other graphs.
*
* COLD-START (fresh session, same persistent wallet): the repo is NOT in
* `self.repos` until something opens it, and an anchored query against an unopened
* repo silently returns 0 rows (never `RepoNotFound`). {@link readUnion} therefore
* opens the batch's repos ({@link ensureReposOpen}) BEFORE this read runs, so the
* anchored query resolves a same-session repo directly. A genuinely-absent repo
* still yields `[]` (in isolation, never aborting the others). Returns the doc's
* rows, or `[]` on failure.
*
* At the real multi-store migration this becomes a real sync: opening a per-user
* store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO).
*/
async function readDoc(
sid: string,
doc: Nuri,
): Promise<Array<Record<string, { value: string } | undefined>>> {
try {
const nuri = assertNuri(doc);
// Anchored to `nuri` → default graph = this repo. No `GRAPH ?g` wrapper, so
// the anchor's one-repo restriction applies (an explicit `GRAPH ?g` body would
// iterate all named graphs regardless of the anchor — see docs § probe step 4).
const res = await sparqlQuery(
sid,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
nuri,
"readDoc",
);
return bindings(res);
} catch (error) {
console.error("[read-model] read failed for", doc, error);
return [];
}
}
/**
* Read a BOUNDED, by-need set of docs — each with its OWN anchored query — and
* return the triples grouped per subject. `docs` are the NURIs to read (the
* consumer resolves them by need — index for public, own scope docs for mine).
* Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the
* batch.
*
* A document holding SEVERAL subjects yields several entries — one per distinct
* subject, carrying that subject as written, all sharing the document as their
* `graph`. Properties of different subjects are never merged. Placing one business
* entity per document stays the recommended practice (a key is per repo, so
* isolation needs a repo per entity), but it is a recommendation about writing: the
* read reports what is there.
*
* Never an anchorless union-scan over all graphs (which is O(wallet size) and wrong
* on a shared / bloated wallet — the footgun this path exists to avoid). Each doc is
* read with an anchored default-graph query, O(1) per doc, independent of wallet
* size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`.
*/
export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
const sid = await sessionId();
// Drop the empties BEFORE validating, not after: this call has always tolerated a
// list with holes in it — a scope index can carry a blank entry, and a caller
// building a list from optional values should not have to compact it. Validating
// first turned that tolerance into a throw, which took down a whole reconnect run.
// Empty is absence, and absence is not a malformed reference.
const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion"));
if (unique.length === 0) return [];
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
// target repos are not yet in `self.repos`, so an anchored read would return 0
// rows. Open/subscribe each repo ONCE (idempotent, per session) and await its
// initial-state push before the anchored reads. No-op once opened / when the
// injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts.
//
// Called on the WHOLE set, before the boundary is consulted, because opening is also
// where a document in a PUBLIC store hands over its cap (see public-store.ts): a
// document filtered out first would never get the chance to answer. `ensureRepoOpen`
// still refuses to open what this user may not touch — it asks, it does not enter.
await ensureReposOpen(unique);
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
// hold before reading anything: upstream you cannot address a repo you have no cap
// for, so asking about one is not "a read that will be refused", it is a read that
// has no meaning. (The passage points enforce rule 1 regardless — see reach.ts — so
// a lapse here is caught, not exploited.)
const reachable = unique.filter((d) => !mustNotAttempt(d));
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
const perDoc = await Promise.all(
reachable.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
);
// Possession gate, kept as defence in depth behind rule 2 above: `reachable`
// already excluded these, so this loop should never drop anything. The read is
// anchored per document, so the unit of possession is the document: the cap key is
// the doc NURI, whatever subjects that document turns out to carry.
const caps = getCaps();
// Keyed by (document, subject) — an entry is one subject INSIDE one graph, which is
// the identity upstream gives an object too: the ORM carries `@id` and `@graph` as
// two distinct read-only properties, and fabricates an `@id` when the writer leaves
// it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`). Several objects per
// graph is therefore the PROVIDED case, and `@id` is what tells them apart within a
// `@graph`. Two documents carrying the same subject IRI stay two entries: they are
// two objects, distinguished by their graph.
//
// Placing one business entity per document remains the recommended practice — a key
// is per repo, so isolating an entity requires a repo of its own. That is a
// recommendation about WRITING, and the read does not get to enforce it by making
// the other arrangement invisible.
const bySubject = new Map<string, UnionSubject>();
for (const { doc, rows } of perDoc) {
if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue;
// Anchored to `doc`, so every row belongs to `doc` — hence `graph` is the caller's
// reference to it. The subject comes back exactly as the document carries it.
for (const row of rows) {
// The polyfill's own compartments live as reserved SUBJECTS inside the very
// documents the consumer reads (the Header branch carrying a document's inbox
// address is the first). They are machinery, not this entity's properties —
// drop them here, once, for every compartment present and future.
const s = row.s?.value;
if (isMachinerySubject(s)) continue;
const p = row.p?.value;
const o = row.o?.value;
if (s === undefined || !p || o === undefined) continue;
// NUL cannot appear in an IRI, so the pair never collides with either half.
const key = `${doc}\u0000${s}`;
let entry = bySubject.get(key);
if (!entry) {
entry = { subject: s, graph: doc, props: {} };
bySubject.set(key, entry);
}
(entry.props[p] ??= []).push(o);
}
}
return [...bySubject.values()];
}
+107
View File
@@ -0,0 +1,107 @@
/**
* SPARQL string-building safety helpers — shared by every module that builds
* SPARQL by interpolation (inbox, store-registry).
*
* These exist because of SPARQL injection. When an untrusted value (an identity
* id, a payload) is spliced verbatim into a query, a `"` closes a literal and a
* `>` closes an IRI, letting the value inject arbitrary triples (or corrupt the
* shim graph, the trust root mapping accounts → document NURIs). Every value that
* reaches a query passes through one of these helpers first.
*
* Two positions, two strategies:
* - Literal position (`"..."`): {@link escapeLiteral}. Escape rather than reject,
* because literals legitimately carry arbitrary text (JSON payloads, display
* names). Escaping is lossless and reversible.
* - IRI position (`<...>`): two cases.
* · Trusted-shaped NURIs coming back from `ng` (`did:ng:...`): validate with
* {@link assertNuri} — they should never contain IRI-breaking chars; if one
* does, something upstream is wrong, so it throws rather than silently
* building a broken/injected query.
* · Untrusted values embedded into an IRI (an identity id used to mint an
* account-subject IRI): {@link escapeIri} percent-encodes every IRI-hostile
* character. Encode rather than reject so any id (spaces, unicode,
* punctuation) stays usable, while `<`, `>`, `"`, whitespace and control
* chars can never break out of the IRI.
*/
/**
* Escape a value for embedding inside a SPARQL string literal (`"..."`).
* Escapes backslash, double-quote and the C0 whitespace controls that would
* otherwise terminate or corrupt the literal. Lossless / reversible.
*/
export function escapeLiteral(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
/**
* Delimiter characters that must never appear raw inside a SPARQL/Turtle IRI
* ref (`<...>`): the space plus `< > " { } | ^ backtick \`. Whitespace beyond
* the space and all C0/C1 control characters are handled by the code-point
* check in {@link isIriForbidden}. Any of these would let a value break out of
* the `<...>` and inject arbitrary syntax.
*/
const IRI_FORBIDDEN_DELIMS = /[<>"{}|^`\\ ]/;
/** True if `ch` (a single code point) may not appear raw inside an IRI ref. */
function isIriForbidden(ch: string): boolean {
const code = ch.codePointAt(0)!;
return IRI_FORBIDDEN_DELIMS.test(ch) || code < 0x20 || code === 0x7f;
}
/**
* Percent-encode every IRI-hostile character in `value` so it is safe to embed
* inside a SPARQL IRI ref (`<PREFIX:${escapeIri(value)}>`). Use this for
* untrusted values (e.g. an identity id minted into an account-subject IRI):
* encoding keeps every id usable while making breakout impossible.
*
* NOTE: this encodes only the delimiter/whitespace/control set, so ordinary
* printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass
* through unchanged and the resulting IRI stays human-readable.
*/
export function escapeIri(value: string): string {
let out = "";
for (const ch of value) {
if (isIriForbidden(ch)) {
// Percent-encode each UTF-8 byte of the offending character. Also encode
// the chars encodeURIComponent leaves alone but which are IRI-hostile.
out += encodeURIComponent(ch).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
} else {
out += ch;
}
}
return out;
}
/**
* Assert that `nuri` is safe to embed verbatim inside a SPARQL IRI ref. NURIs
* that come back from `ng` are trusted-SHAPED (`did:ng:...` or `urn:...`) and
* should never carry IRI-breaking characters; if one does, we throw rather than
* emit a query that could be malformed or injected. Returns the value unchanged
* so it can be used inline: `<${assertNuri(doc)}>`.
*
* Generic in its argument so the caller's type flows THROUGH: passing a `Nuri`
* gives back a `Nuri`, not a widened `string`. This function checks characters,
* not the `did:ng:` shape (it legitimately accepts `urn:…` IRIs too), so it must
* not be the thing that mints a `Nuri` — that is {@link isNuri}'s job.
*/
export function assertNuri<T extends string>(nuri: T): T {
if (typeof nuri !== "string" || nuri.length === 0) {
throw new Error(`[sparql] invalid NURI (empty): ${JSON.stringify(nuri)}`);
}
for (const ch of nuri) {
if (isIriForbidden(ch)) {
throw new Error(
`[sparql] NURI contains IRI-forbidden characters, refusing to embed: ${JSON.stringify(nuri)}`,
);
}
}
return nuri;
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Reactive single-document subscription — the polyfill's typed wrapper over the
* platform's `doc_subscribe` primitive. This is the canonical NextGraph reactive
* read at the document granularity: subscribe once, get the initial state pushed,
* then a push on every subsequent commit to that document — whether the write was
* local (this session) or a broker-synced remote change. NO POLLING.
*
* ── Why call the REAL injected `ng` directly (never `makeNg`) ──────────────
* Same hard constraint as `docs.ts`: the public `ng` is a JS `Proxy` over
* `@ng-org/web`'s iframe-RPC proxy. `doc_subscribe` is a STREAMED method — the
* `@ng-org/web` RPC strips the callback (by positional index) BEFORE it posts to
* the iframe and drives it locally via a `MessageChannel` port (the function is
* never structured-cloned, so no `DataCloneError`). Layering our own Proxy on top
* risks re-wrapping that surface; reaching the real `ng` held in the config avoids
* the double-proxy exactly as the raw `docs` primitives do. Do not import from
* `./ng-proxy`.
*
* ── The primitive shape (verified against nextgraph-rs) ────────────────────
* `ng.doc_subscribe(repo_o: string, session_id, callback)`
* (`sdk/js/lib-wasm/src/lib.rs:1907`) is **per-document** — one repo NURI, one
* callback. It is `async`, resolving to a JS **unsubscribe function**. The
* callback is invoked `callback(appResponse)` with a serialized `AppResponse`:
* `{ V0: { State | Patch | TabInfo | ... } }`. It pushes an initial `State`
* (plus a `TabInfo`) on subscribe, then a `Patch` per verified commit on the
* branch. Returning `true` from the callback also cancels; we cancel by calling
* the returned unsubscribe fn.
*
* ── Why per-document, never `orm_start_graph(graphs:[…])` ──────────────────
* A single not-yet-synced repo in an ORM graph fan-out makes `RepoNotFound` abort
* the WHOLE subscription (`initialize.rs:125-128`), so the readyPromise never
* resolves → the ~75s hang. `doc_subscribe` is per-branch/per-doc and has no
* fan-out: an absent doc breaks only its own subscription. {@link subscribeDocs}
* builds a set of these with per-doc error isolation to preserve that property.
*/
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { assertMayReach } from "../emulated-verifier/reach";
import { toNuri } from "../model/nuri";
import type { Nuri, NuriLike } from "../model/types";
/**
* A push from the platform to a document subscriber. Loosely typed: the raw
* serialized `AppResponse` (`{ V0: { State | Patch | TabInfo | ... } }`). The
* consumer typically ignores the payload and uses the push purely as a
* change SIGNAL (re-query on change — the read-model pattern), so this stays
* permissive rather than modelling every AppResponse variant.
*/
export type DocChange = unknown;
/**
* The discriminant of a {@link DocChange} — the single variant key of the raw
* `AppResponse` payload (`{ V0: { State | Patch | TabInfo | … } }`). It is NOT a
* closed enum: the platform may push other variants, so this is a bare `string`
* (e.g. `"State"`, `"Patch"`, `"TabInfo"`), or `undefined` when the shape can't
* be read. Verified against the CONTRACT-3 e2e probe (`e2e/polyfill-entry.ts`): the
* variant is `Object.keys(resp.V0)[0]`. Exposed so a caller that needs the SYNC
* BARRIER (the first `State`, per CONTRACT 3) can distinguish it from the earlier
* `TabInfo`/`Patch` pushes — see `open-repo.ts`. Most callers ignore it and use
* any push as a plain change signal.
*/
export type DocChangeType = string | undefined;
/**
* Extract the variant key from a raw {@link DocChange}. Reads `resp.V0` (case-
* tolerant to `v0`) and returns its first key — the AppResponse variant name
* (`"State"` / `"Patch"` / `"TabInfo"` / …). Returns `undefined` if the payload
* is not a recognisable `{ V0: { <Variant>: … } }` object. Inspects the variant
* proplerly (no `any`-cast to force it) so a `State` push is identifiable.
*/
export function docChangeType(resp: DocChange): DocChangeType {
if (!resp || typeof resp !== "object") return undefined;
const outer = resp as { V0?: unknown; v0?: unknown };
const v0 = outer.V0 ?? outer.v0;
if (!v0 || typeof v0 !== "object") return undefined;
const keys = Object.keys(v0 as Record<string, unknown>);
return keys.length > 0 ? keys[0] : undefined;
}
/** An unsubscribe function — idempotent (calling it twice is a no-op). */
export type Unsubscribe = () => void;
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
/**
* Subscribe to ONE document. `onChange` fires on the initial state push and on
* every subsequent change to that doc (local write OR broker-synced remote
* change). Returns an unsubscribe function.
*
* The wrapper is synchronous-returning (an unsubscribe fn) even though the
* underlying `ng.doc_subscribe` is async: the real unsubscribe is captured when
* the promise resolves; if the caller unsubscribes before setup completes, the
* cancellation is honoured as soon as the real unsubscribe is available (and no
* further `onChange` fires after unsubscribe).
*
* `onChange` receives the raw payload AND its variant type ({@link docChangeType},
* e.g. `"State"`). The type is a NON-BREAKING second argument: existing callers
* that ignore it (the change-signal pattern — `discovery.ts`, `inbox.ts`) are
* unaffected; a caller that needs the sync barrier (`open-repo.ts`) reads it to
* act only on the first `State`.
*
* Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`).
*/
export function subscribeDoc(
nuriLike: NuriLike,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
const nuri = toNuri(nuriLike, "subscribeDoc");
// RULE 1 — a subscription IS an access: the push carries the document's state.
// Guarding the read paths while leaving this open would be a door beside the gate.
assertMayReach(nuri, "subscribeDoc");
return subscribeDocUnguarded(nuri, onChange);
}
/**
* The unguarded core. Exported for ONE importer — `shared-wallet/physical.ts`, which
* owns the machinery's entire privileged door — and for nobody else. It is not
* re-exported by either entry point; the `Unguarded` suffix is the warning.
*/
export function subscribeDocUnguarded(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
const { ng } = getConfig();
let stopped = false;
let realUnsub: (() => void) | null = null;
const cb = (resp: DocChange): void => {
if (stopped) return;
try {
onChange(resp, docChangeType(resp));
} catch (error) {
console.error("[subscribe] onChange handler threw for", nuri, error);
}
};
// Kick off the async subscription. Errors are isolated to this doc (they never
// reject a shared batch — see subscribeDocs). If setup fails, this doc simply
// never fires; the caller's unsubscribe stays a safe no-op.
void (async () => {
try {
const sid = await sessionId();
const unsub = (await ng.doc_subscribe(nuri, sid, cb)) as (() => void) | undefined;
if (stopped) {
// Unsubscribed before setup resolved — cancel immediately.
if (typeof unsub === "function") unsub();
return;
}
realUnsub = typeof unsub === "function" ? unsub : null;
} catch (error) {
console.error("[subscribe] doc_subscribe failed for", nuri, error);
}
})();
return () => {
if (stopped) return;
stopped = true;
if (realUnsub) {
try {
realUnsub();
} catch (error) {
console.error("[subscribe] unsubscribe failed for", nuri, error);
}
realUnsub = null;
}
};
}
/**
* Subscribe to a SET of documents, one {@link subscribeDoc} per NURI, with
* PER-DOC error isolation. `onChange(nuri, r)` fires for whichever doc changed.
* Returns a single unsubscribe that tears down all of them.
*
* The per-doc isolation is the point: a bad / not-yet-synced doc breaks only its
* own subscription and NEVER aborts the others (this is precisely what avoids the
* ORM fan-out hang — do NOT replace this with `orm_start_graph(graphs:[…])`). The
* set is deduplicated; an empty set returns a no-op unsubscribe.
*/
export function subscribeDocs(
nuris: Nuri[],
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
const unique = [...new Set(nuris.filter(Boolean))];
const unsubs = unique.map((nuri) => {
// Each subscription is independent: subscribeDoc already isolates its own
// async setup failure (logged, never thrown), so one bad doc cannot abort the
// construction of the others here.
try {
return subscribeDoc(nuri, (r, type) => onChange(nuri, r, type));
} catch (error) {
console.error("[subscribe] subscribeDocs: failed to subscribe", nuri, error);
return () => {};
}
});
return () => {
for (const u of unsubs) {
try {
u();
} catch (error) {
console.error("[subscribe] subscribeDocs: unsubscribe failed", error);
}
}
};
}
@@ -0,0 +1,17 @@
/**
* Wrapped `useShape`: same signature as `@ng-org/orm`. Once the cap emulation is
* in force, the returned set is a read-filtered VIEW (only items in documents the
* current holder has the ReadCap of); before the first cap is issued it passes the
* real set through unchanged. At migration the filtering disappears — the broker
* only delivers documents whose cap the wallet holds.
*/
import { getConfig, getCaps } from "../shared-wallet/bootstrap";
import { makeReadFilteredView } from "../emulated-verifier/read-filter";
export function useShape(shapeType: unknown, scope: unknown): unknown {
const set = getConfig().useShape(shapeType, scope) as object;
const caps = getCaps();
if (!caps.isEnforcing()) return set; // no cap issued yet → passthrough
return makeReadFilteredView(set, caps);
}
@@ -0,0 +1,386 @@
/**
* watch-shape — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
* one logical scope. This is the surface the consuming app will bind (phase B) with
* `useSyncExternalStore` — the polyfill deliberately exposes an OBSERVABLE, never a
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
*
* ── Why an observable, and why this exact shape ────────────────────────────
* It anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will
* natively distinguish "sync in progress" from "synced but empty". That distinction
* ALREADY exists lib-internally (`open-repo.ts` `getSyncState`: syncing / synced /
* timed-out); `watchShape` merely SURFACES it as a `useQuery`-minimal snapshot:
* `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`.
* `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads
* `{ data: [], isPending: false, isSuccess: true }` — the key distinction — while a
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
*
* ── What the observable OWNS (the whole read pipeline) ─────────────────────
* 1. Resolve the logical scope → the doc set: the CURRENT wallet's own per-entity
* documents for that scope (`storeRegistry.listMyEntityDocs`), and nothing
* else. There is no "everything public" to fold in — **you cannot discover,
* you can only follow links** (see docs/readcap-and-nuri-model.md §4ter-bis),
* and a link reaches you through an inbox or through a document you already
* hold. A document whose cap you were given is read by NAMING it
* (`readUnion`), not by turning up in a scope you never put it in.
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
* fallback). `isPending` holds until the barrier is reached for the current
* doc set AND the first `readUnion` has rendered.
* 3. `readUnion(docs)` — the read-model (cap filter already applied inside; we do
* NOT double-filter), then FILTER the union by the requested shape's `@type`
* (a `readUnion` union spans multiple types; each `watchShape` yields only the
* subjects of its shape). Non-domain: the type IRI is read from the SHEX
* ShapeType, not from any application concept.
*
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
* Reactivity is push-only (rule no-broker-polling). It has TWO sources: document
* pushes, and the KEYRING — a cap that arrives asynchronously (an inbox delivery
* absorbed by the consumer's `inbox.watch`) makes documents readable that were not,
* so `CapRegistry.onChange` re-reads. Without that, a view stays stale until an
* unrelated push happens to fire. On the document side, `subscribeDoc` on every doc
* in the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
* entity appends a NURI to the scope-index doc), so we ALSO subscribe to the
* scope-index document: a push there re-RESOLVES the scope and re-keys the
* subscribed set. Subscriptions are idempotent — an already-followed
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
* parallel channel.
*
* ── timed-out → isSuccess (best-effort), NOT isError ───────────────────────
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
* (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error.
* `isError` fires ONLY on a real thrown exception in the pipeline.
*/
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { ensureReposOpen, getSyncState } from "../emulated-verifier/open-repo";
import { readUnion, type UnionSubject } from "./read-model";
import { subscribeDoc, type Unsubscribe } from "./subscribe";
import { listMyEntityDocs, userStoreDoc } from "../shared-wallet/account-registry";
import type { Nuri, Scope } from "../model/types";
/**
* The RDF `type` predicate IRI. A SHEX shape pins its class via a triple
* constraint on this predicate; we filter the read union by it.
*/
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/**
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `watchShape` yields
* the generic per-subject property bags of the read-model (NO application domain);
* the app maps them to its own entity types in phase B.
*/
export interface ShapeQuery<T = UnionSubject> {
/** The subjects of the requested shape/scope. Empty array when none (never undefined). */
data: T[];
/** True while the sync barrier for the current doc set is not yet reached OR the
* first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */
isPending: boolean;
/** True once the barrier is reached (all docs `synced` OR `timed-out`) AND the
* first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with
* `data: []` — the distinction this surface exists for. */
isSuccess: boolean;
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`). */
isError: boolean;
/** The caught error when `isError`, else `undefined`. */
error: unknown;
}
/** The observable a caller binds with `useSyncExternalStore` (phase B). */
export interface ShapeObservable<T = UnionSubject> {
/** The current snapshot. STABLE across calls until it actually changes (so
* `useSyncExternalStore` does not loop): the same reference is returned until a
* state transition produces a new one. */
getSnapshot(): ShapeQuery<T>;
/** Register a change listener; returns an unsubscribe. The last listener's
* unsubscribe tears down the underlying doc subscriptions. */
subscribe(onChange: () => void): () => void;
/** Force a re-resolve + re-read now (e.g. an imperative refresh). Idempotent
* w.r.t. subscriptions; never polls. */
refetch(): void;
}
/**
* Derive the class IRI(s) a SHEX {@link ShapeType} pins on `rdf:type`, if any.
* A generated shape constrains its subject's type via a triple constraint on the
* `rdf:type` predicate whose `literals` carry the class IRI(s). Returns the set of
* those IRIs, or `null` when the shape pins NO type (then no type-filter is applied
* and every subject in the doc set flows through). Purely structural — reads only
* the SHEX schema, no application domain.
*/
function shapeTypeIris(shapeType: unknown): Set<string> | null {
try {
const st = shapeType as {
shape?: string;
schema?: Record<string, { predicates?: Array<{ iri?: string; dataTypes?: Array<{ literals?: unknown[] }> }> }>;
};
const shape = st?.shape && st.schema ? st.schema[st.shape] : undefined;
const preds = shape?.predicates ?? [];
const iris = new Set<string>();
for (const p of preds) {
if (p?.iri !== RDF_TYPE) continue;
for (const dt of p.dataTypes ?? []) {
for (const lit of dt.literals ?? []) {
if (typeof lit === "string") iris.add(lit);
}
}
}
return iris.size > 0 ? iris : null;
} catch {
return null;
}
}
/** Whether a subject satisfies the shape's `@type` constraint (or the shape pins none). */
function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): boolean {
if (!typeIris) return true; // shape pins no rdf:type → accept every subject
const types = subject.props[RDF_TYPE] ?? [];
return types.some((t) => typeIris.has(t));
}
/**
* Whether the sync BARRIER is reached for the whole doc set. Called only AFTER
* `ensureReposOpen(docs)` has resolved, so each doc has been requested; the only
* state that still holds the barrier open is `syncing` (subscribed, first `State`
* not yet received). `synced` and `timed-out` both count as reached (`timed-out` is
* best-effort, not an error). `unknown` means the injected `ng` has no
* `doc_subscribe` (the fake/no-op open path, which has NO barrier semantics) — after
* a completed open it can only mean that path, so it counts as reached (opened,
* nothing to wait on). An EMPTY doc set is trivially past the barrier.
*/
function barrierReached(docs: Nuri[]): boolean {
for (const d of docs) {
if (getSyncState(d) === "syncing") return false;
}
return true;
}
/**
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
* logical `scope` (`'public' | 'protected' | 'private'`). See the module header for
* the full pipeline. The returned observable is inert until its first
* {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is
* what kicks off resolution, opening and the first read; before then `getSnapshot`
* reports the initial pending snapshot.
*/
export function watchShape<T = UnionSubject>(
shapeType: unknown,
scope: Scope,
): ShapeObservable<T> {
const typeIris = shapeTypeIris(shapeType);
// The current, STABLE snapshot (same reference until a transition rebuilds it).
let snapshot: ShapeQuery<UnionSubject> = {
data: [],
isPending: true,
isSuccess: false,
isError: false,
error: undefined,
};
const listeners = new Set<() => void>();
let started = false;
// The docs currently subscribed for change signals, keyed by NURI → unsubscribe.
// Idempotent: a doc already here is not re-subscribed. Excludes the container
// (scope-index / discovery-index) subscriptions, held separately.
const docSubs = new Map<Nuri, Unsubscribe>();
// Container subscriptions (scope-index doc; discovery-index doc for `public`) —
// a push here means the doc SET may have changed → re-resolve.
const containerSubs = new Map<Nuri, Unsubscribe>();
// Unsubscribe from the held-caps change signal (see the subscription in `start`).
let capsUnsub: (() => void) | null = null;
// True while `resolveDocs` runs. Folding a repo link files a cap, which fires the
// held-caps signal; the resolution in progress already accounts for it, so the
// signal is ignored during that window instead of restarting the cycle.
let resolving = false;
// Monotonic token so a slow in-flight refresh cannot clobber a newer one.
let refreshToken = 0;
function emit(): void {
for (const l of listeners) {
try {
l();
} catch (error) {
console.error("[watch-shape] listener threw", error);
}
}
}
function setSnapshot(next: ShapeQuery<UnionSubject>): void {
snapshot = next;
emit();
}
/** Resolve the logical scope → the current doc set: the CURRENT wallet's own
* entity documents for that scope, and nothing else. Tolerant: a resolution
* failure yields whatever resolved.
*
* There is no "everything public" to fold in. You cannot discover; you can only
* follow links, and a link reaches you through an inbox or through a document
* you already hold — never through a shared index. A document someone gave you
* the cap for is read by naming it (`readUnion`), not by appearing in
* a scope you did not put it in. */
async function resolveDocs(): Promise<Nuri[]> {
const user = getCurrentUser();
const set = new Set<Nuri>();
resolving = true;
try {
if (user) {
try {
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
} catch (error) {
console.error("[watch-shape] listMyEntityDocs failed", error);
}
}
} finally {
resolving = false;
}
return [...set];
}
/** Subscribe to the CONTAINER document (the scope index) so a change to the doc
* SET re-resolves. Idempotent per NURI. */
async function ensureContainerSubs(): Promise<void> {
const containers: Nuri[] = [];
const user = getCurrentUser();
if (user) {
try {
containers.push(await userStoreDoc(user, scope));
} catch (error) {
console.error("[watch-shape] userStoreDoc failed", error);
}
}
for (const c of containers) {
if (!c || containerSubs.has(c)) continue;
// A push on a container doc means the set may have changed → full re-resolve.
containerSubs.set(c, subscribeDoc(c, () => void refresh()));
}
}
/** Re-key the per-doc change subscriptions to exactly `docs` (idempotent adds,
* prune removed). A push on any of these re-reads (data-only, no re-resolve). */
function syncDocSubs(docs: Nuri[]): void {
const wanted = new Set(docs.filter(Boolean));
for (const [nuri, unsub] of docSubs) {
if (!wanted.has(nuri)) {
try {
unsub();
} catch {
/* ignore */
}
docSubs.delete(nuri);
}
}
for (const nuri of wanted) {
if (docSubs.has(nuri)) continue;
docSubs.set(nuri, subscribeDoc(nuri, () => void reread()));
}
}
/** Read (union + shape filter) the CURRENT doc set and publish a snapshot.
* Derives isPending/isSuccess from the barrier + whether the read rendered. */
async function readAndPublish(docs: Nuri[], token: number): Promise<void> {
let subjects: UnionSubject[];
try {
subjects = await readUnion(docs);
} catch (error) {
if (token !== refreshToken) return;
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
return;
}
if (token !== refreshToken) return; // superseded by a newer refresh/reread
const data = subjects.filter((s) => matchesShape(s, typeIris));
const past = barrierReached(docs);
setSnapshot({
data,
isPending: !past,
isSuccess: past,
isError: false,
error: undefined,
});
}
/** Full cycle: resolve the scope, (re)establish container subs, open the docs
* (await the barrier), sync per-doc subs, then read + publish. */
async function refresh(): Promise<void> {
const token = ++refreshToken;
try {
await ensureContainerSubs();
const docs = await resolveDocs();
if (token !== refreshToken) return;
syncDocSubs(docs);
// Open/await the barrier (first State per doc, or timed-out). No-op once open.
await ensureReposOpen(docs);
if (token !== refreshToken) return;
await readAndPublish(docs, token);
} catch (error) {
if (token !== refreshToken) return;
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
}
}
/** A push on an already-open doc: re-read the CURRENT set only (no re-resolve,
* the set is unchanged). Reuses the docs we are subscribed to. */
async function reread(): Promise<void> {
const token = ++refreshToken;
const docs = [...docSubs.keys()];
await readAndPublish(docs, token);
}
function start(): void {
if (started) return;
started = true;
// A cap that arrives ASYNCHRONOUSLY (an inbox deposit absorbed by the
// consumer's `inbox.watch`) makes documents readable that were not. Without
// this the view would stay stale until some unrelated push happened to fire —
// so re-read whenever they change. This is the delivery channel key
// ROTATION uses too, which is why keeping an access needs no subscription
// obligation on the consumer's side.
capsUnsub = getCaps().onChange(() => {
if (!resolving) void refresh();
});
void refresh();
}
return {
getSnapshot(): ShapeQuery<T> {
return snapshot as unknown as ShapeQuery<T>;
},
subscribe(onChange: () => void): () => void {
listeners.add(onChange);
start();
return () => {
listeners.delete(onChange);
if (listeners.size === 0) {
// Last listener gone → tear down the underlying subscriptions. A later
// subscribe restarts a fresh cycle.
for (const u of docSubs.values()) {
try {
u();
} catch {
/* ignore */
}
}
for (const u of containerSubs.values()) {
try {
u();
} catch {
/* ignore */
}
}
docSubs.clear();
containerSubs.clear();
if (capsUnsub) {
capsUnsub();
capsUnsub = null;
}
started = false;
}
};
},
refetch(): void {
start();
void refresh();
},
};
}