15 Commits

Author SHA1 Message Date
Sylvain Duchesne 0e57f20ca4 docs: consigner deux défauts vivants, dont une absence qui masque un échec 2026-08-20 11:05:34 +02:00
Sylvain Duchesne c61b181ed8 chore: le manifeste rattrape la version que le contrat annonce 2026-08-20 11:04:49 +02:00
Sylvain Duchesne 6a99585efc fix: dire le pari comme un pari, et distinguer deux manques d'inbox que rien ne distinguait 2026-08-20 10:47:39 +02:00
Sylvain Duchesne c507e79f8a docs: ce que NextGraph offre vraiment autour des inbox, et ce qui n'est qu'un nom réservé 2026-08-20 10:30:31 +02:00
Sylvain Duchesne fafac38907 docs: dire l'observation continue, sa limite, et ce qu'ensureIdentity ne promet pas 2026-08-17 14:47:40 +02:00
Sylvain Duchesne c7c6cb96ea fix: une tentative dépassée n'ouvre plus rien, et libère ce qu'elle avait ouvert 2026-08-17 14:01:07 +02:00
Sylvain Duchesne 7b35300723 fix: un rejet tardif ne ferme plus un canal vivant, un registre illisible ne perd plus toutes les inbox 2026-08-17 12:17:59 +02:00
Sylvain Duchesne 6eaff0b985 docs: une seule copie du polyfill par application, sinon deux identités courantes 2026-08-17 11:48:25 +02:00
Sylvain Duchesne 33212a8b00 fix: un abonnement en échec n'empoisonne plus le document, et un dépôt ne change plus de destinataire 2026-08-17 11:47:21 +02:00
Sylvain Duchesne 520c8c59a8 refactor(e2e): prendre les types navigateur chez les helpers plutôt que chez playwright 2026-08-17 11:12:26 +02:00
Sylvain Duchesne 935cce4d7b fix: un second doc_subscribe tuait le premier, et les inbox n'étaient lues qu'à la connexion 2026-08-17 11:11:04 +02:00
Sylvain Duchesne 6dfdf2f036 feat(e2e-helpers): republier les types Playwright que notre surface mentionne 2026-08-17 11:10:30 +02:00
Sylvain Duchesne 72bb72c0b7 docs: useShape ne démarre pas contre un broker antérieur au renommage 2026-08-17 11:05:38 +02:00
Sylvain Duchesne bf1e16e1f3 docs: un tag de dépôt ne dit pas quel paquet il a figé 2026-08-17 10:53:05 +02:00
Sylvain Duchesne 1957a8fe0c docs: des contrats à deux voix, et une politique de version qui dit la vérité
Le régime des contrats est devenu bidirectionnel : chaque partie rédige sa
position dans son dépôt et tire celle de l'autre. Le fournisseur écrit son
engagement, chaque consommateur écrit sa déclaration — ce qu'il appelle
vraiment, sous quelles contraintes, et avec quelles frictions.

Ce dernier point change nos usages : la section Frictions est le chemin par
lequel un consommateur expose un problème, le canal hors-bande ne portant que le
signal. Les huit points que Festipod nous a remontés auraient dû arriver par là,
et notre réponse appartient à l'engagement plutôt qu'à un échange qui s'évapore.

Trois interfaces existaient, une seule était documentée. Elles ont maintenant
leur dossier et leurs deux voix : la surface du polyfill, ng-e2e-helpers, et la
couche d'indexation. On a écrit les engagements dont nous sommes l'auteur et les
déclarations des consommateurs qui sont à nous — délibérément AUCUN
usage_festipod : ce n'est pas notre dépôt, et décider à leur place ce qu'ils
consomment viderait le mécanisme de son sens.

Et la Change policy s'inverse. Elle disait « ce paquet n'offre pas de stabilité
sémantique ». Refuser un schéma ne ralentit pas le mouvement — ça retire au
consommateur le seul outil pour le gérer, alors qu'on faisait déjà le travail
d'une publication versionnée sans lui en donner l'étiquette.

Semver, donc, et les majeurs sont le cas NORMAL : une surface qui converge vers
une cible qu'elle n'atteint pas encore casse souvent, et c'est le signal vrai.
Le texte dit les déclencheurs plutôt que le nom du schéma — retirer un symbole
publié ou resserrer un appel est un majeur, ajouter est un mineur. Version
pleine sur main, pré-version sur une branche : Festipod peut épingler
aujourd'hui sans qu'on lui promette du stable, et sans qu'on ait à lui retirer
son adresse à la fusion.

Au passage, la signature publiée de watchShape était fausse — elle annonçait un
argument unique là où l'appel en prend deux. Rapporté par Festipod, confirmé
indépendamment.
2026-08-17 10:10:12 +02:00
35 changed files with 3315 additions and 307 deletions
+3
View File
@@ -7,3 +7,6 @@ bun.lockb
# SDK e2e real-broker harness — dedicated wallet profile + bundle (never committed) # SDK e2e real-broker harness — dedicated wallet profile + bundle (never committed)
packages/polyfill/e2e/.dist/ packages/polyfill/e2e/.dist/
*.ngw *.ngw
# Per-developer contract access map — canonical identities are committed, local paths are not
.project/contracts.local.yaml
+4 -3
View File
@@ -2,7 +2,7 @@
type: overview type: overview
summary: What an application may rely on from @ng-eventually/polyfill, and what it will have to delete summary: What an application may rely on from @ng-eventually/polyfill, and what it will have to delete
triggers: triggers:
keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing] keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing, inbox, deposit]
paths: paths:
- "packages/polyfill/src/index.ts" - "packages/polyfill/src/index.ts"
- "packages/polyfill/src/surface/**" - "packages/polyfill/src/surface/**"
@@ -29,10 +29,11 @@ This library exists so an application can be **written today against the NextGra
The distinguishing question, asked at every choice — published signature as much as internal mechanism: **does the target do this?** If it does it differently, we do it their way, whatever that costs. *Would a caller have to unlearn it?* comes second, and measures how bad a divergence is — never whether one is allowed. See `rule_no-divergence-from-nextgraph`. The distinguishing question, asked at every choice — published signature as much as internal mechanism: **does the target do this?** If it does it differently, we do it their way, whatever that costs. *Would a caller have to unlearn it?* comes second, and measures how bad a divergence is — never whether one is allowed. See `rule_no-divergence-from-nextgraph`.
This repo is the **provider** of `contract_polyfill-surface`; consuming applications live in other repos and pull it. The per-symbol ruling, with an epistemic label on every target-side claim, stays here in `docs/api-contract.md` — that is maintainer material, not the engagement. This repo is the **provider** of `polyfill-surface`; consuming applications live in other repos, pull the engagement, and declare back what they actually consume — one `usage_<consumer>.md` beside the contract, in the interface folder. The per-symbol ruling, with an epistemic label on every target-side claim, stays here in `docs/api-contract.md` — that is maintainer material, not the engagement.
## Read first ## Read first
- `contract_polyfill-surface` — the engagement itself, written from the caller's point of view. - `polyfill-surface/contract_polyfill-surface` — the engagement itself, written from the caller's point of view.
- `rule_no-divergence-from-nextgraph` — the test that decides what may be built at all. - `rule_no-divergence-from-nextgraph` — the test that decides what may be built at all.
- `knowledge_what-an-app-deletes-at-migration` — the two fates a published symbol can have. - `knowledge_what-an-app-deletes-at-migration` — the two fates a published symbol can have.
- `knowledge_what-an-inbox-is-upstream` — what the target actually offers around inboxes, and where a choice of ours begins.
@@ -0,0 +1,28 @@
---
type: bug
severity: major
summary: An inbox watch that failed to open is re-opened only by a narrow set of later events, so a session whose activity does not produce one stays unwatched and never converges
opened: 2026-08-17
last_checked: 2026-08-17
---
# A lost inbox watch is never retried
While an identity is connected, every inbox it may read is watched, and a deposit is applied as it lands. When opening one of those watches **fails** — a broker hiccup at sign-in is enough — the failure is reported and the watch is dropped. It is then re-opened only by a later event, and the list of events that qualify is narrower than it looks:
- creating a document,
- opening an inbox on one,
- a `ReadCap` arriving through an inbox **still** being watched,
- the first read of a document in a public store.
**An ordinary write does not qualify.** A `sparqlUpdate` on a document the identity already owns fires none of these. So it is not only a read-only session that stays blind — it is any session whose activity stays inside what it already holds.
There is deliberately no polling: the deferred inbox processing states that as a design choice, and a timer here would be the wrong repair. What is missing is a recovery signal that does not depend on the identity performing a capability-bearing act.
**The visible consequence.** A document is shared with the affected identity while it sits connected, and nothing arrives. No error is raised, and the application sees an empty result rather than a failure — the same shape as having been given nothing at all.
**Workaround today:** a fresh `ensureIdentity()` re-enters and re-establishes the watches.
**Verifying it:** drive an identity to a state where one inbox watch fails to open, let the broker recover, deposit for that identity, and observe that nothing converges while it stays connected. A reproduction existed as a scratchpad probe during the work that found this and did not survive the session; rebuilding it from this description is the honest cost of that.
Predates the continuous-observation work — the earlier behaviour processed inboxes only at connection, so the same hiccup produced the same silence by a shorter route.
@@ -0,0 +1,21 @@
---
type: bug
severity: normal
summary: When the account record cannot be resolved, the inbox enumeration reports a COMPLETE list holding zero inboxes, so a total failure is indistinguishable from an identity that legitimately has none
opened: 2026-08-17
last_checked: 2026-08-17
---
# An empty inbox list can mean a failure
Enumerating the inboxes an identity may read reports, alongside the list, whether that list is **complete** or **short** — and a shortfall is logged so a lost watch is at least visible.
That signal has a hole. When resolving the account record yields nothing rather than raising, the enumeration reports a list that is **complete and empty**. Every inbox is missing, including the identity's own, and nothing distinguishes it from an identity that genuinely has none yet — which is a real and ordinary state, since a first-visit identity connects with no account.
So the worst outcome — watching nothing at all — is reported as the most benign one. This is the family this package has closed repeatedly: **a failure wearing the shape of an absence.**
The fix is not to make the enumeration throw. Reaching the registers is infrastructure and may fail; what is missing is that *resolving nothing* and *there being nothing* are the same answer here, and they are not the same event.
**Verifying it:** make the account resolution return nothing (rather than reject) and observe that the enumeration reports a complete list with no entries, and that no shortfall is logged.
Related, and distinct: [[bug_a-lost-inbox-watch-is-never-retried]] — that one is about a watch that failed to open; this one is about never learning there was a watch to open.
@@ -0,0 +1,52 @@
---
type: bug
summary: A live read through the published `useShape` never starts against a broker deployed before `orm_start` was renamed `orm_start_graph` — it stays empty and pending forever, with one console error as the only trace
severity: major
opened: 2026-08-17
last_checked: 2026-08-17
---
# `useShape` opens no live read against the deployed broker
The published `useShape` is a passthrough to `@ng-org/orm`'s hook (`packages/polyfill/src/surface/use-shape.ts`) — this package adds the read filter and forwards. Against the broker deployed today, the subscription that hook opens **never starts**: its set stays empty, its readiness never settles, no engine update ever arrives, and the only trace is one `console.error`. An application sees "this scope holds nothing", indefinitely, which is the exact confusion the rest of the read surface is built to prevent.
The cause is upstream, in NextGraph. **Nothing in this package can work around it** — the failure is a name lookup two hops below our surface.
## The mechanism — one method name, kept in three places by hand
VERIFIED 2026-08-17 by reading `/home/sylvain/projects/nextgraph/nextgraph-rs` at `213338f6`.
A live ORM read is one call, and it is streamed: the engine pushes into a callback rather than answering once. Nothing in the transport infers that — each hop looks the method up in a **hand-maintained table** mapping method name to the index of its callback argument, and a method missing from the table takes the plain request/response branch instead.
1. `sdk/js/orm/src/connector/GraphOrmSubscription.ts` — the `OrmSubscription` constructor is the only place a live graph read begins: `ng.orm_start_graph(scope.graphs, scope.subjects, shapeType, session.session_id, this.onBackendMessage)`. Five arguments, callback last. `useShape` reaches it through `OrmSubscription.getOrCreate` (`sdk/js/orm/src/frontendAdapters/react/useShape.ts`).
2. `sdk/js/web/src/index.ts` — the application-side `ng` proxy. `streamed_api` must contain the method, or `rpc` posts the arguments verbatim to the parent window, callback included. A function is not structured-cloneable, so that post throws.
3. `sdk/js/api-web/main.ts` — the broker-side proxy, an independent copy of the same table. Same fallback into `myWorker.postMessage`, same throw.
The hops between the two tables carry no table of their own and cannot repair a mismatch: `infra/ngnet/auth/src/main.ts` forwards `{method, args, streamed, port}` unchanged, and `engine/broker/auth/src/App.svelte` re-appends a callback **only** when `streamed` is true.
**The name changed.** Both tables keyed the method as `orm_start` until commit `d5ecd0fd` (2026-01-16, "refactor discrete orm for yjs") renamed it to `orm_start_graph` in both files at once — while the ORM had been calling `orm_start_graph`. A build made before that commit therefore does not recognise the method a live read needs.
**The application side is already correct**, and that is what isolates the defect. This package pins `@ng-org/web@0.1.2-alpha.13` (`packages/polyfill/package.json`), which is the version at `213338f6` — the rename is in it. `@ng-org/api-web` is `private: true` and has never been version-bumped past `0.1.2`: it is **not published**, it ships only inside the broker build. Its table is whatever the operator last deployed, and no consumer can pin it.
**Why it fails in silence:** `orm_start_graph` is awaited inside a `try` whose `catch` is `console.error(e)`, and `resolveReady()` is called on one path only — the arrival of initial data. A rejected start leaves the readiness promise pending forever and the set permanently empty. This is the same upstream property already recorded in `docs/api-contract.md` (a failed read *is* an eternal pending upstream), reached here through a different door.
## What is affected, and what is not
- **Affected:** the published `useShape`, and only it.
- **Not affected:** `watchShape`, `subscribeDoc`, `subscribeDocs`, `readUnion`. These open one `doc_subscribe` per document, and `doc_subscribe: 2` has been in both tables continuously under a stable name. `watchShape` is built on that path, not on the ORM subscription — it is a working reactive read today.
That is why the library's own suites and the reference application do not show it: nothing in this package calls `useShape` internally.
## Not verified
The deployed broker's table was **not read**. `https://nextgraph.net/redir/` returns a page containing none of the protocol markers, so it is not the bundle that holds it, and probing further was out of mandate. What is established is the mechanism and its exact precondition — a broker built before `d5ecd0fd` — not that the running deployment sits before it. The symptom was reported by the consuming application, not reproduced here.
## What to do
- **Do not build on live `useShape`.** Use `watchShape` for a reactive read, `readUnion` for a one-shot one. This is stated in the contract's `## Non-guarantees` so a consumer meets it without reading this leaf.
- **The fix is not ours.** It is a NextGraph deployment: rebuild and redeploy the broker from a source at or after `d5ecd0fd`. Nothing to change in this repository, and nothing to open upstream from here — report it, do not patch around it.
- **To check whether it is closed:** open a live `useShape` against the deployment and watch for an initial payload. A permanently empty set with one `console.error` naming `orm_start_graph` means the table is still stale.
## The shape worth remembering
A method name duplicated across three independently-deployed artifacts, with a **silent** fallback when they disagree, is a defect that cannot be caught by any one repository's tests. Renaming it in the two tables in one commit fixed the source; it did not fix anything already running. Anything this package routes through a streamed method inherits the same exposure — that is the reason `subscribeDocs` composing per-document `doc_subscribe` is worth more than it looks.
@@ -1,158 +0,0 @@
---
type: contract
summary: The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer
---
# contract_polyfill-surface — `@ng-eventually/polyfill`
## Scope
This package is a polyfill of NextGraph's SDK.
This package covers placement (creating and listing an application's documents by scope), reading (a document's subjects, one-shot or reactive), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
### Deployment requirements
An application using this package must:
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first.
## Surface
Full typed shape: the package's `types` entry, `@ng-eventually/polyfill`. A type is published only when a published signature uses it. The load-bearing signatures:
```ts
// ── bootstrap ────────────────────────────────────────────────────────────
export function configure(c: EventuallyConfig): void;
export interface EventuallyConfig {
ng: NgLike; // the `ng` object from @ng-org/web
useShape: UseShapeLike; // `useShape` from @ng-org/orm
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any;
}
// ── identity — one await before the application renders ──────────────────
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
// ── addressing ───────────────────────────────────────────────────────────
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
export type Scope = "public" | "protected" | "private";
// ── placement: where an application's documents live ─────────────────────
export const storeRegistry: { // no identity parameter — a session is one user's
createEntityDoc(scope: Scope): Promise<Nuri>;
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
resolveScopeGraph(scope: Scope): Promise<Nuri>;
resolveWriteGraph(scope: Scope): Promise<Nuri>;
openDocumentInbox(doc: NuriLike): Promise<Nuri>;
};
// ── reading ──────────────────────────────────────────────────────────────
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> }
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
export function watchShape(query: ShapeQuery): ShapeObservable;
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
// ── low-level document / SPARQL primitives ───────────────────────────────
export const docs: {
// `sessionId` is `string | number` — upstream's own declared type (`Session.session_id`).
// It is RELAYED, never converted: the wasm side deserializes a `u64`, and stringifying it
// fails for real (`Deserialization error of session_id JsValue("1")`).
docCreate(sessionId: string | number, crdt: string, cls: string, dest: string, store?: unknown): Promise<Nuri>;
sparqlQuery(sessionId: string | number, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
// Returns the commits the update produced, as upstream does (it typed this `void` until
// 2026-08-14 while already relaying the value). A caller that ignores it is unaffected.
sparqlUpdate(sessionId: string | number, query: string, anchor?: NuriLike, label?: string): Promise<unknown>;
};
// ── inbox: giving to read, and depositing ────────────────────────────────
export const inbox: {
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
readForDocument(doc: NuriLike): Promise<Deposit[]>;
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
// `materialize` (a second published name for `read`) was REMOVED on 2026-08-14 —
// an alias with no call site, and no counterpart upstream. Use `read`.
};
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
// ── the wrapped SDK objects ──────────────────────────────────────────────
export const ng: NG; // call this instead of the `ng` passed to `configure`
// `NG` is upstream's own type (`@ng-org/web`), 88 typed
// members; it was `Record<string, any>` until 2026-08-14
export function init(...args: any[]): any; // likewise — not the `init` passed to `configure`
export function initNg(...args: any[]): any;
```
## Guarantees
Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. No type guard is published.
A returned reference carries no key — not `createEntityDoc`, not `listMyEntityDocs`, not `UnionSubject.subject` / `.graph`. A reference found inside a document yields a name, not a key.
You read a document whose key you hold: you created it, it was shared with you, or it sits in a public store, which serves its read key to whoever asks. No call answers "may I read this?".
What was shared with you becomes readable after `ensureIdentity()`.
`readUnion` returns one entry per distinct subject present in a document. `subject` is that subject's IRI exactly as written, and is a `string`, because a subject may be any IRI; `graph` is the document reference you passed in, and is the `Nuri` to hand back to this surface. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Several objects in one document are allowed. Recommended placement is one document per business entity: access is granted per document.
`urn:ng-eventually:` is reserved. Triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned.
Only a document's owner writes to it. Holding its read key never grants a write.
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
**You never resolve an inbox address, on either side.** You deposit by naming a document (`inbox.postToDocument`), and you read what was left on your own by naming it too — `inbox.readForDocument(doc)` at any time, or `inbox.readSyncedForDocument(doc)` on a page that has just loaded. The second is the one to call when an empty answer has to MEAN empty: a session that has just loaded has synced neither the document nor its inbox, and an unsynced read of either comes back empty with no error — so the ungated form can answer `[]` for a document whose inbox holds messages. `readSyncedForDocument` waits for both before answering. `inbox.readSynced` is the same guarantee on an inbox you already hold the address of, which no application does: it takes an address, so nothing here hands you one.
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
It resolves **only once that work has actually completed**: if what was shared with you could not be restored, or a queue could not be drained, it throws instead of returning. So a resolved call means everything shared with you is readable — and a rejected one must not be rendered past, since the interface would show an empty account rather than an empty screen.
`ensureIdentity()` mounts a full-screen barrier on every top-level load, and takes it down itself — past the broker round-trip it never appears. A person who comes back to the page from that round-trip finds the barrier live again, prefilled, and confirming it hands the page over a second time. The application's own page is never reloaded and nothing outside the barrier is touched.
**The session is the package's, not yours.** You never build one, and no call takes one. Call this package's `init` (not the one you passed to `configure`): it captures the session the SDK delivers to `init`'s callback and keeps it, then calls your callback with that same event untouched — so an application that wants the `session_id` for the `docs` primitives reads it there, and one that does not may pass no callback at all. Identity normalisation is the package's too: `@Alice`, `alice ` and `ALICE` are one person.
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
**`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about.
**A reactive read says "nothing" and "I could not find out" differently.** `watchShape` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds no document of that shape — the distinction the surface exists for. Until 2026-08-17 a scope whose listing did not answer published that very snapshot, so an interface showed "you have created nothing" for "the store did not answer"; it now publishes `isError` carrying the error. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
The same rule reaches what a call hands BACK, not only what it looked up first: **`listMyEntityDocs` returns a listing whose documents you can open, or it throws.** It reads which documents are in the store and what opens each, and it throws if either did not answer — including when the documents came back and their keys did not. Nothing about a keyless listing is visible to you: it is the same `Nuri[]`, and the difference would only appear at the next read, empty, long after the cause. An empty array therefore means this account created nothing.
## Non-guarantees
**No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name.
**No revocation.** `inbox.share` cannot be undone.
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
**No delegated writing.** A received key never grants a write, and no call adds a writer to a document.
**No mailbox model.** Do not build on the raw deposit list.
**No cross-broker reference.** A returned reference resolves for users of the same broker.
**`openDocumentInbox` does not coalesce across PAGES.** Two tabs — or two sessions of the same person — that open the same document's inbox in the same moment can each create one, and the document is left with two: its owner drains one while deposits arrive in the other. Nothing raises, nothing reports it, and neither page can detect it afterwards. It is not an oversight to be patched later: a branch MERGES records rather than refusing the second, so there is no "create only if absent" to build the guarantee on, and the address a depositor reads is a separate record from the one the owner resolves — so the two cannot even be made to agree on which of the pair won. Open a document's inbox from one place: the page that creates the document, or one call the rest of the interface waits on.
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
## Change policy
This surface changes, and shrinks. The package does not offer semantic-version stability.
Re-pull this contract at every upgrade.
@@ -0,0 +1,47 @@
---
type: knowledge
summary: What NextGraph actually offers around inboxes — a document may be given one, nothing above the engine exposes them, and most message variants are reserved names carrying nothing
last_checked: 2026-08-17
---
# What an inbox is upstream
Read at the source in `nextgraph-rs`. Every claim here points at a symbol rather than a line, so it stays verifiable by `git grep` after the code moves.
## A document may have an inbox, and must be given one
`Repo` upstream **is** a document — not a store. Its `inbox` field is an `Option<PrivKey>`: at most one inbox per document, and a document has none until something creates it. The creating act is an **`AddInboxCap` commit**, verified and applied like any other — see `update_inbox_cap_v0` in the verifier and the `CommitVerifier for AddInboxCap` impl.
The automatic creation inside `complete_site_store` is narrower than it looks: it gives an inbox to the site's **non-private stores** only. Reading that as *"only stores can have inboxes"* is a mistake this project has now made twice — the site path is one caller of a general capability, not the rule.
So an inbox on an ordinary document is **aligned with the target**, not an emulation of something absent.
## Nothing above the engine exposes any of it
Run the cascade for *"post, observe, or process an inbox message"* and every level above the engine is silent. The ORM has no occurrence of `inbox` at all; neither the web API nor the wasm binding exposes one — the binding's single mention is an unused import — and there is no generic `app_request` escape hatch, since every command carries its own dedicated wrapper.
Anything built here therefore aligns on the **engine's model**, because levels 2 and 3 answer nothing.
## Most message variants are reserved names, not shapes
`InboxMsgContent` declares eight variants. `ContactDetails` and `SocialQuery` carry data and have processors. **`Link`, `Patch`, `ServiceRequest`, `ExtRequest`, `RemoteQuery` and `DialogRequest` carry nothing at all** — they are bare unit variants, so they cannot hold a reference, name a servicer, or describe an operation.
That distinction decides how much they bind us, and it is why the provenance taxonomy in [[rule_no-divergence-from-nextgraph]] separates a reserved name from a declared shape.
## Processing is closed, and so is posting
`process_inbox` is `pub(crate)`, its match ends in `NotImplemented`, and its only caller is `Verifier::inbox`. There is no trait, registry or hook: **nothing outside the engine can contribute a processor** for a message type.
Posting is no better wired. `AppRequestCommandV0::InboxPost` is declared with its constructor and has **no handler arm** — the identifier appears nowhere else in the tree.
## A message waits for its recipient, and there is no delegate
An inbox's readers are `UserId`s, and messages queue until that user comes online. The only *"someone else runs it"* notion upstream is `Headless(Credentials)` — a server-side verifier holding a **user's own** credentials, which is that user running elsewhere, not a service delegated over someone's inbox.
## What follows for us
The **container** is settled by the target: a document may be given an inbox, one at most. Build on that freely.
The **content** of a deposit and **who processes it, when** have no upstream answer — the variants that would carry them are empty, and the processor set admits nothing from outside. So these are ours to choose, and a choice here is a bet that must be named as one rather than presented as alignment.
The standing choice, deliberate and provisional: **a deposit is a reference to a document**. It is enough for what is built on it today, and it is expected to evolve — most plausibly toward whatever shape the target eventually gives its own variants.
@@ -0,0 +1,209 @@
---
type: contract
summary: The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer
---
# contract_polyfill-surface — `@ng-eventually/polyfill`
## Scope
This package is a polyfill of NextGraph's SDK.
This package covers placement (creating and listing an application's documents by scope), reading (a document's subjects, one-shot or reactive), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
### Deployment requirements
An application using this package must:
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first;
- **declare this package exactly once**, so that everything in the application resolves to one copy of it — see the single-copy rule under `## Non-guarantees`, which is the one packaging mistake this surface cannot protect you from.
**Obtaining it.** This package is not published to a public registry, and it is not distributed as built output: its published entry point is TypeScript source, so whatever builds the application is what compiles it, and a toolchain that accepts only JavaScript cannot consume it as it stands. By which channel the source reaches a given application is agreed with that application rather than fixed here; what this contract fixes is the version you pin and the rules below.
## Surface
Full typed shape: the package's `types` entry, `@ng-eventually/polyfill`. A type is published only when a published signature uses it. The load-bearing signatures:
```ts
// ── bootstrap ────────────────────────────────────────────────────────────
export function configure(c: EventuallyConfig): void;
export interface EventuallyConfig {
ng: NgLike; // the `ng` object from @ng-org/web
useShape: UseShapeLike; // `useShape` from @ng-org/orm
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any;
}
// ── identity — one await before the application renders ──────────────────
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
// ── addressing ───────────────────────────────────────────────────────────
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
export type Scope = "public" | "protected" | "private";
// ── placement: where an application's documents live ─────────────────────
export const storeRegistry: { // no identity parameter — a session is one user's
createEntityDoc(scope: Scope): Promise<Nuri>;
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
resolveScopeGraph(scope: Scope): Promise<Nuri>;
resolveWriteGraph(scope: Scope): Promise<Nuri>;
openDocumentInbox(doc: NuriLike): Promise<Nuri>;
};
// ── reading ──────────────────────────────────────────────────────────────
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> }
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
// TWO positional arguments — the same pair `useShape` takes. `ShapeQuery` is what you
// READ BACK (the snapshot), never what you pass in.
export function watchShape<T = UnionSubject>(shapeType: unknown, scope: Scope): ShapeObservable<T>;
export interface ShapeObservable<T = UnionSubject> {
getSnapshot(): ShapeQuery<T>; // stable reference until the state changes
subscribe(onChange: () => void): () => void; // returns its own unsubscribe
refetch(): void; // re-resolve and re-read now; never polls
}
export interface ShapeQuery<T = UnionSubject> {
data: T[]; isPending: boolean; isSuccess: boolean; isError: boolean; error: unknown;
}
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
// ── low-level document / SPARQL primitives ───────────────────────────────
export const docs: {
// `sessionId` is `string | number` — upstream's own declared type (`Session.session_id`).
// It is RELAYED, never converted: the wasm side deserializes a `u64`, and stringifying it
// fails for real (`Deserialization error of session_id JsValue("1")`).
docCreate(sessionId: string | number, crdt: string, cls: string, dest: string, store?: unknown): Promise<Nuri>;
sparqlQuery(sessionId: string | number, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
// Returns the commits the update produced, as upstream does (it typed this `void` until
// 2026-08-14 while already relaying the value). A caller that ignores it is unaffected.
sparqlUpdate(sessionId: string | number, query: string, anchor?: NuriLike, label?: string): Promise<unknown>;
};
// ── inbox: giving to read, and depositing ────────────────────────────────
export const inbox: {
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
readForDocument(doc: NuriLike): Promise<Deposit[]>;
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
// `materialize` (a second published name for `read`) was REMOVED on 2026-08-14 —
// an alias with no call site, and no counterpart upstream. Use `read`.
};
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
// ── the wrapped SDK objects ──────────────────────────────────────────────
export const ng: NG; // call this instead of the `ng` passed to `configure`
// `NG` is upstream's own type (`@ng-org/web`), 88 typed
// members; it was `Record<string, any>` until 2026-08-14
export function init(...args: any[]): any; // likewise — not the `init` passed to `configure`
export function initNg(...args: any[]): any;
```
## Guarantees
Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. No type guard is published.
A returned reference carries no key — not `createEntityDoc`, not `listMyEntityDocs`, not `UnionSubject.subject` / `.graph`. A reference found inside a document yields a name, not a key.
You read a document whose key you hold: you created it, it was shared with you, or it sits in a public store, which serves its read key to whoever asks. No call answers "may I read this?".
What was shared with you becomes readable after `ensureIdentity()`.
`readUnion` returns one entry per distinct subject present in a document. `subject` is that subject's IRI exactly as written, and is a `string`, because a subject may be any IRI; `graph` is the document reference you passed in, and is the `Nuri` to hand back to this surface. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Several objects in one document are allowed. Recommended placement is one document per business entity: access is granted per document.
`urn:ng-eventually:` is reserved. Triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned.
Only a document's owner writes to it. Holding its read key never grants a write.
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
**You never resolve an inbox address, on either side.** You deposit by naming a document (`inbox.postToDocument`), and you read what was left on your own by naming it too — `inbox.readForDocument(doc)` at any time, or `inbox.readSyncedForDocument(doc)` on a page that has just loaded. The second is the one to call when an empty answer has to MEAN empty: a session that has just loaded has synced neither the document nor its inbox, and an unsynced read of either comes back empty with no error — so the ungated form can answer `[]` for a document whose inbox holds messages. `readSyncedForDocument` waits for both before answering. `inbox.readSynced` is the same guarantee on an inbox you already hold the address of, which no application does: it takes an address, so nothing here hands you one.
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
It resolves **only once that work has actually completed**: if what was shared with you could not be restored, it throws instead of returning, and a rejected call must not be rendered past — the interface would show an empty account rather than an empty screen. A single queue that could not be drained is reported and does not reject: reaching your queues is infrastructure and must succeed, applying one deposit is data and must never cost you the session. So a resolved call means your own capabilities are restored; it does not promise that every deposit waiting for you has been applied, and those that were not stay in their queue.
`ensureIdentity()` mounts a full-screen barrier on every top-level load, and takes it down itself — past the broker round-trip it stays down, provided the identifier reached the other side. A person who comes back to the page from that round-trip finds the barrier live again, prefilled, and confirming it hands the page over a second time. The application's own page is never reloaded and nothing outside the barrier is touched.
**What decides which identity you get.** No call takes an identifier — not `configure`, not `init`, not `ensureIdentity` — so an application never chooses one, never keeps one, and never hands one over. It is settled once per page load, from the page itself, and the answer depends on which side of the broker round-trip is asking:
- **Before the round-trip, on your own top-level page — the barrier decides.** Whatever is already known fills the field, and the person may change it; the identity is the value they confirm. Confirming publishes that value into the address bar as `?ng-id=` and records it in this browser.
- **After the round-trip, on the page the broker loads back — `?ng-id=` decides**, and the barrier stays down. The parameter also wins over anything the browser remembers, on either side: it is read first, and reading it replaces what was remembered. Arrive with neither — a URL that dropped the parameter, and a partition that remembers nothing — and the barrier asks on that side too.
- **Failing both — whatever this browsing context last recorded.** This is the only path on which an identity is adopted with nobody confirming it. If the address bar lost `?ng-id=` while a *different* identifier was on record here, that different identity is adopted, and nothing is raised anywhere; if nothing was on record either, the barrier asks again on that side too.
**`?ng-id=` exists because it is the only thing that crosses.** Your page before the round-trip and your page after it sit in two separate storage partitions — nothing the browser remembers on one side is visible on the other, and the address bar is the sole channel between them. The package writes the parameter itself, without navigating. **An application that owns its URL must let it survive**: a router that drops query parameters it does not know, or a redirect that rebuilds the URL, sends the round-trip off without the identifier, and the consequence is the silent mis-identification above rather than an error. In a context that can neither write the address bar nor use storage, the barrier simply asks on both sides.
**Being remembered is a prefill, never a decision.** A top-level reload asks again every time, with the field already filled — one click, no typing. A remembered identifier is therefore not a signed-in state, and two tabs, two browsers or two devices do not share one: each keeps its own record, and only a URL carrying `?ng-id=` puts a second context under the same identity.
**The session is the package's, not yours.** You never build one, and no call takes one. Call this package's `init` (not the one you passed to `configure`): it captures the session the SDK delivers to `init`'s callback and keeps it, then calls your callback with that same event untouched — so an application that wants the `session_id` for the `docs` primitives reads it there, and one that does not may pass no callback at all. Identity normalisation is the package's too: `@Alice`, `alice ` and `ALICE` are one person.
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
**`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about.
**A reactive read says "nothing" and "I could not find out" differently.** `watchShape` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds no document of that shape — the distinction the surface exists for. Until 2026-08-17 a scope whose listing did not answer published that very snapshot, so an interface showed "you have created nothing" for "the store did not answer"; it now publishes `isError` carrying the error. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
The same rule reaches what a call hands BACK, not only what it looked up first: **`listMyEntityDocs` returns a listing whose documents you can open, or it throws.** It reads which documents are in the store and what opens each, and it throws if either did not answer — including when the documents came back and their keys did not. Nothing about a keyless listing is visible to you: it is the same `Nuri[]`, and the difference would only appear at the next read, empty, long after the cause. An empty array therefore means this account created nothing.
**A deposit made while a person is looking at the page arrives while they are looking at it.** For as long as an identity is connected, every inbox it may read is watched and what lands in one is applied as it lands — its own inbox, and the inbox of every document it has opened one on, including a document whose inbox it opens later in the same session. So a `ReadCap` sent with `inbox.share` becomes usable in the recipient's live session with no reload and no call from the application, and a `watchShape` that was empty for want of that `ReadCap` re-reads and publishes the document it now opens. Until 2026-08-17 only the backlog waiting at connection was applied, and a deposit made in front of its recipient converged only when that person reloaded the page.
**The watching is in place by the time `ensureIdentity()` resolves**, whatever else that call made of its own work: a connection that could not restore something still rejects, and the identity it settled is watched all the same. It lasts exactly as long as that identity stays connected — changing identity or clearing it stops it, and whoever connects next is watched in their own right, so nothing of the previous one keeps applying.
**Failing to apply one inbox denies nothing.** It is reported on this package's own log stream (`console.error`, carrying this package's prefix) and never gated by `debugAccessLog` — a diagnostic may be opt-in, a failure may not. The deposit stays in its queue, so the next arrival on that inbox, or the next connection, applies it; the other inboxes were never involved, and nobody is refused anything.
**Any number of subscriptions on one document coexist.** Opening a document, watching an inbox and following a scope no longer silence one another. Until 2026-08-17 a second `subscribeDoc` on a document killed the first, silently — nothing rejected, the first caller's unsubscribe still appeared to work, and what an application saw was a view that stopped re-reading and an inbox that stopped notifying, with no trace anywhere near the cause. A subscriber that joins a document somebody else already opened is handed the initial `State` its own subscription would have pushed it, so joining late is not the same as never firing; and unsubscribing silences that caller and no other, including when it happens from inside a push.
## Non-guarantees
**No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name.
**Naming an identity proves nothing about who named it.** Any visitor may type any identifier at the barrier, or arrive on a URL that already carries one, and act as that identity — the wallet and its password are handed out on the barrier itself. The identifier is a choice of space, never a proof: anyone who knows one can act as it, so do not treat `ensureIdentity()`'s answer as an authenticated subject.
**No live read through `useShape` against a deployed broker.** The reactive subscription it opens is dispatched by method name at two hops below this package, and a broker deployed before that name last changed does not recognise it: the read never starts, its set stays empty, its readiness never settles, and one console error is the only trace — indistinguishable from "this scope is empty". The cause is upstream in NextGraph and the remedy is a broker redeployment; nothing in this package can work around it. Use `watchShape` for a reactive read and `readUnion` for a one-shot one — neither goes through that path.
**No revocation.** `inbox.share` cannot be undone.
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
**No delegated writing.** A received key never grants a write, and no call adds a writer to a document.
**No mailbox model.** Do not build on the raw deposit list.
**No cross-broker reference.** A returned reference resolves for users of the same broker.
**`openDocumentInbox` does not coalesce across PAGES.** Two tabs — or two sessions of the same person — that open the same document's inbox in the same moment can each create one, and the document is left with two: its owner drains one while deposits arrive in the other. Nothing raises, nothing reports it, and neither page can detect it afterwards. It is not an oversight to be patched later: a branch MERGES records rather than refusing the second, so there is no "create only if absent" to build the guarantee on, and the address a depositor reads is a separate record from the one the owner resolves — so the two cannot even be made to agree on which of the pair won. Open a document's inbox from one place: the page that creates the document, or one call the rest of the interface waits on.
**TWO COPIES OF THIS PACKAGE IN ONE APPLICATION MISBEHAVE SILENTLY, and nothing here can detect it.** What this package remembers it keeps in the package itself, not in any handle you hold: which documents are subscribed and who is listening for them, which identity the session settled on, which documents are open. None of that is shared between two copies. An application that ends up with two therefore runs two of everything — a document subscribed through one copy is invisible to the other, so changes to it simply never arrive; and the identity settled in one is not the identity the other acts as, so the same call writes as one user or the other depending on which copy it reached. Nothing raises, nothing warns, and no call can report it: from every entry point a second copy is indistinguishable from the first, and the symptoms surface far from the cause as missing updates and writes attributed to the wrong person. This is a packaging property, not a behaviour to code around — **declare this package once, as one dependency of the application**, and let your own code and every library built on it resolve to that copy. A library that builds on this package should declare it a *peer* rather than a dependency for exactly this reason, so that the application remains the one place it is named.
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
**A watch on one inbox that could not be opened does not come back on its own.** Opening it can fail — a broker that does not answer in that moment — and the failure is reported rather than passed over, but what follows is event-driven and this package deliberately never polls. The watch is opened again at the next moment this identity comes to hold something it did not: it creates a document, it opens an inbox on a document, a `ReadCap` reaches it through an inbox still being watched, it reads a public-store document for the first time. A session that does none of those goes on without that inbox — deposits made into it are not applied, nothing raises, and they wait unconsumed for the next connection. The exposed case is the identity that only ever READS, since it produces none of those events, where an identity that goes on creating recovers as a by-product of its own work. Awaiting `ensureIdentity()` again applies what is waiting — it drains every one of this identity's queues before it resolves, and shows no barrier a second time — but the watching itself comes back only on a fresh page.
## Change policy
**Semver, and majors are the normal case.** This surface converges on a NextGraph that does not ship yet, so most steps toward the target remove or narrow something — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
What each level means here, in this package's own terms:
- **major** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession` and `inbox.materialize` all left this way), **or** an existing call narrows: it now throws where it returned (`listMyEntityDocs` refuses a listing whose documents you could not open; `ensureIdentity` rejects rather than resolve on an incomplete restore), or it reports a state you did not have to handle before (`watchShape` publishing `isError` where it used to publish a synced-empty snapshot). A signature change a caller must react to counts; one that only accepts more than before does not.
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.2` is this package, `ng-e2e-helpers/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is `1.0.0-dev.2`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.2`, the string you pinned, never the version it is heading for. `1.0.0-dev.2` added the continuous inbox observation and the coexisting document subscriptions above and moved no signature — a minor, landing inside the pre-release line because `1.0.0` has not been cut.
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it, and a symbol is never left published-but-dead as a courtesy. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
@@ -39,9 +39,14 @@ Answering *does the target do this?* once, in conversation, does not survive the
| `passthrough` | upstream's own symbol, same name and shape | | `passthrough` | upstream's own symbol, same name and shape |
| `aligned` | our name or ergonomics, upstream's semantics and model | | `aligned` | our name or ergonomics, upstream's semantics and model |
| `declared-not-wired` | upstream **defines** the shape; nothing constructs it yet; we implement it | | `declared-not-wired` | upstream **defines** the shape; nothing constructs it yet; we implement it |
| `reserved-name` | upstream has declared a NAME with no shape behind it — it binds nothing, so building on it is a bet |
| `invention` | nothing at any level answers — a deliberate, documented bet | | `invention` | nothing at any level answers — a deliberate, documented bet |
| `divergent` | upstream does this, and we do it differently | | `divergent` | upstream does this, and we do it differently |
A test requires the annotation on every published symbol; it cannot check that the citation is *true*, and that is fine — a citation is refutable by a reader in seconds, prose is not. The classification also applies to unpublished code that **claims to resemble the target**: the mistake this section exists for was made on an internal function, and being unexported protected nobody. A test requires the annotation on every published symbol; it cannot check that the citation is *true*, and that is fine — a citation is refutable by a reader in seconds, prose is not. The classification also applies to unpublished code that **claims to resemble the target**: the mistake this section exists for was made on an internal function, and being unexported protected nobody.
**A reserved name is not a declared shape, and the gap between them is the whole point.** `declared-not-wired` is strong *because the shape constrains us*: implementing it is realising something the target wrote down. A name with nothing behind it constrains nothing, so what we build under it is our own invention wearing the target's vocabulary — the most dangerous kind, because the borrowed name makes it read as alignment.
The tell is mechanical: **open the declaration and look for a payload.** Several of `InboxMsgContent`'s variants — `Link`, `Patch`, `ServiceRequest`, `ExtRequest`, `RemoteQuery`, `DialogRequest` — are bare unit variants carrying nothing at all, while `ContactDetails` and `SocialQuery` carry data and have processors. Presenting the first group as *"the strongest thing to align on"* was an error made here, in the same breath as citing this rule; see [[knowledge_what-an-inbox-is-upstream]] for what those variants do and do not offer.
**Why this exists.** A maintainer read that `AddLinkV0` is defined upstream but constructed nowhere, and reported our implementation of it as our own invention — reasoning from the absence while quoting, in the same session, the rule against exactly that. The correct reading was `declared-not-wired`, which is the best alignment available. Prose said otherwise elsewhere too: `docs/api-contract.md` claimed `docs.*` was a 1:1 passthrough while two of its signatures diverged, and stated a member count that was simply wrong. Both errors are the same one — a claim about the target that nothing forced anyone to check. **Why this exists.** A maintainer read that `AddLinkV0` is defined upstream but constructed nowhere, and reported our implementation of it as our own invention — reasoning from the absence while quoting, in the same session, the rule against exactly that. The correct reading was `declared-not-wired`, which is the best alignment available. Prose said otherwise elsewhere too: `docs/api-contract.md` claimed `docs.*` was a 1:1 passthrough while two of its signatures diverged, and stated a member count that was simply wrong. Both errors are the same one — a claim about the target that nothing forced anyone to check.
+6 -1
View File
@@ -2,9 +2,10 @@
type: overview type: overview
summary: What each end-to-end suite judges, the shortcut that kept two defects invisible, and the failure mode that is not ours to fix summary: What each end-to-end suite judges, the shortcut that kept two defects invisible, and the failure mode that is not ours to fix
triggers: triggers:
keywords: [e2e, harness, playwright, chromium, journey, suite, profile, broker page, notebook, wallet profile] keywords: [e2e, harness, playwright, chromium, journey, suite, profile, broker page, notebook, wallet profile, ng-e2e-helpers]
paths: paths:
- "packages/polyfill/e2e/**" - "packages/polyfill/e2e/**"
- "packages/ng-e2e-helpers/**"
vocabulary: vocabulary:
- term: journey - term: journey
gloss: one named end-to-end story in the applicative suite, reported pass or fail as a whole gloss: one named end-to-end story in the applicative suite, reported pass or fail as a whole
@@ -20,8 +21,12 @@ Three suites drive real browsers against the real broker and the real wallet app
They are also expensive and, on a machine whose network moves, unreliable in ways that look exactly like product defects. Most of what this concept holds exists so an agent does not spend hours diagnosing a product bug that is a transport failure — or, worse, trust a green run that measured nothing. They are also expensive and, on a machine whose network moves, unreliable in ways that look exactly like product defects. Most of what this concept holds exists so an agent does not spend hours diagnosing a product bug that is a transport failure — or, worse, trust a green run that measured nothing.
The generic half of that machinery is a package of its own, `ng-e2e-helpers`, and this repo **publishes** it: a NextGraph application in any repository pulls `ng-e2e-helpers/contract_ng-e2e-helpers` and declares back what it consumes. Our own suites are one of those consumers, and declare it in the same folder — same pair, no boundary to cross.
## Read first ## Read first
- `ng-e2e-helpers/contract_ng-e2e-helpers` — the engagement, written from the caller's point of view.
- `ng-e2e-helpers/usage_polyfill-e2e` — what our own suites actually call, and where the fit still costs them code.
- `knowledge_what-each-suite-judges` — which suite answers which question. - `knowledge_what-each-suite-judges` — which suite answers which question.
- `rule_never-shortcut-the-sign-in` — the shortcut that hid a 404 and a regression at once. - `rule_never-shortcut-the-sign-in` — the shortcut that hid a 404 and a regression at once.
- `caveat_a-dropped-pipe-kills-a-run` — the failure mode that is not ours. - `caveat_a-dropped-pipe-kills-a-run` — the failure mode that is not ours.
@@ -0,0 +1,166 @@
---
type: contract
summary: The machinery ng-e2e-helpers offers a NextGraph application's end-to-end suite — wallets, the broker crossing, per-run profiles, bounds, and a bounded report
---
# contract_ng-e2e-helpers — `ng-e2e-helpers`
## Scope
This package is the end-to-end testing machinery a NextGraph application needs to get a real person into itself: minting a wallet by driving the wallet application, crossing the broker, and coming back inside the iframe the application runs in — plus per-run browser profiles, bounds that turn a hang into a named failure, and a run report whose size does not depend on what failed.
It covers **only what is generic to NextGraph**. It knows nothing about any one application, and nothing about any compatibility layer: an application calling the NextGraph SDK directly is its intended consumer. What is specific to your repository — the page that carries your application, how you build your bundle, which journeys you run — is yours to write, and this package deliberately offers no place to put it.
It is not a test runner, not an assertion library, and not a fixture system. You keep your own runner and your own `main()`.
### Deployment requirements
`playwright` and `@ng-org/web` are **peer dependencies**, and you own both versions: browser binaries have to match the driver, and the SDK the export page opens a session with must be the one your application and your broker agree on. Import the browser types from the helpers that return them rather than from `playwright` directly — a second resolution of the driver produces a structurally different `BrowserContext`, and a context you opened then cannot be handed back to the helper that opens contexts.
A machine running this needs a real Chromium, network reach to the wallet application and to the broker, and a writable temporary directory for the per-run profiles.
## Surface
Full typed shape: the package's `types` entry, `ng-e2e-helpers`. It also ships one executable, `ng-mint-wallet`, for provisioning a deployment's wallet file outside any run. `BrowserContext`, `Frame`, and `Page` — every Playwright type this surface mentions, no more — are re-exported as types from the same entry point, so a consumer can type its own helper functions against these signatures without a second, independently-versioned import of `playwright`. The load-bearing signatures:
```ts
// ── bounds: a hang becomes a named failure ───────────────────────────────────
export function within<T>(what: string, ms: number, task: () => Promise<T>): Promise<T>;
export class DeadlineExceeded extends Error {} // thrown by `within` — names `what`
export class BrowserGone extends Error {} // the browser died; not the task's fault
export function enclosingBound(steps: readonly number[], margin: number): number;
export function closeQuietly(what: string, close: () => Promise<unknown>): Promise<void>;
export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void;
export function browserLost(reason: string): void; // declare it, once
export function lossDeclared(): string | null; // has it been declared?
export function firstLine(e: unknown): string;
export const CLOSE_MS: number, CONTEXT_ACTION_MS: number, CONTEXT_NAVIGATION_MS: number;
// ── measurement: how a bound gets its number ─────────────────────────────────
export function measured<T>(what: string, bound: number, task: (ms: number) => Promise<T>): Promise<T>;
export function record(what: string, ms: number, ok: boolean, bound: number): void;
export function timingsWanted(): boolean; // true under E2E_TIMINGS=1
export function printTimings(): void;
// ── browser and profiles: one run owns its own ───────────────────────────────
export interface RunProfile { readonly dir: string; readonly purpose: string; discard(): void }
export function newRunProfile(purpose: string): RunProfile;
export function isAlive(pid: number): boolean;
export function launchWatchedContext(label: string, dir: string): Promise<BrowserContext>;
export function closeContext(label: string, ctx: BrowserContext): Promise<void>;
export function newPage(label: string, ctx: BrowserContext): Promise<Page>;
export const LAUNCH_MS: number, NEW_PAGE_MS: number;
// ── the wallet: minted, carried, imported ────────────────────────────────────
export interface WalletCredentials { readonly name: string; readonly password: string }
export const DEFAULT_WALLET_NAME: string; // the password never has a default
export function mintWalletProfile(purpose: string, c: WalletCredentials): Promise<RunProfile>;
export function mintWalletProfileKeepingContext(
purpose: string, c: WalletCredentials): Promise<{ ctx: BrowserContext; profile: RunProfile }>;
export function mintWalletBytes(password: string, name?: string): Promise<Uint8Array>;
export function createWalletInContext(ctx: BrowserContext, c: WalletCredentials): Promise<void>;
export function emptyProfileContext(
purpose: string): Promise<{ ctx: BrowserContext; profile: RunProfile }>;
export function exportWalletBytes(ctx: BrowserContext, walletPassword: string): Promise<Uint8Array>;
export function exportWalletFile(ctx: BrowserContext, ngwPath: string, pw: string): Promise<number>;
export function importWalletFile(page: Page, ngwPath: string, password: string): Promise<void>;
// ── the broker crossing ──────────────────────────────────────────────────────
export function setupBrokerPage(page: Page, appUrl: string, walletPassword: string): Promise<Frame>;
export function completeBrokerLogin(page: Page, appUrl: string, pw: string): Promise<Frame>;
export const BROKER_LOGIN_MS: number, BROKER_ROUND_TRIP_MS: number;
export function brokerRedirectFor(appUrl: string): string;
export const BROKER_SCREENS: readonly BrokerScreenSpec[]; // the screens, in test order
export const WALLET_APP, WALLET_CREATION, WALLET_IMPORT; // the wallet application's own pages
export type BrokerScreen = "choose-broker" | "login-offered" | "wallet-list"
| "password" | "working" | "error";
export type { BrokerScreenSpec, ScreenSignature, ScreenResponse, TextPattern };
// ── serving your application to the browser ──────────────────────────────────
export function serveOnEphemeralPort(
handler: (req: IncomingMessage, res: ServerResponse) => void,
): Promise<{ url: string; close: () => void }>;
// ── failures that are not the application's ──────────────────────────────────
export function browserTrouble(label: string, ctx: BrowserContext): Promise<string | null>;
export function frameTrouble(id: string, page: Page, frame: Frame, marker: string): Promise<string | null>;
export const BROWSER_PROBE_MS: number, FRAME_PROBE_MS: number;
// ── the report ───────────────────────────────────────────────────────────────
export interface JourneyDeclaration { readonly name: string; readonly checks: readonly string[] }
export type Prerequisite = () => Promise<string | null> | (string | null);
export interface JourneySpec {
readonly name: string; // must be a declared journey
readonly needs?: readonly Prerequisite[]; // each answers null, or why it cannot start
readonly run: () => Promise<void>;
}
export interface SuiteOptions {
readonly label: string;
readonly journeys: readonly JourneyDeclaration[];
readonly journeyBound: number;
readonly diagnose?: () => Promise<string | null>;
}
export interface SuiteReport {
check(name: string, ok: boolean, detail?: string): void; // throws on an undeclared name
journey(spec: JourneySpec): Promise<void>;
finish(fatal: string | null): never; // prints, then leaves
}
export function declareSuite(options: SuiteOptions): SuiteReport;
// ── playwright types this surface mentions, re-exported so a second import isn't needed ─────
export type { BrowserContext, Frame, Page };
```
## Guarantees
**A bound names what it was waiting for.** `within(what, ms, task)` either settles the task or throws a `DeadlineExceeded` carrying `what` verbatim — never a bare timeout with no subject. `armSuiteDeadline` does the same for a whole run, and reports before leaving. `enclosingBound(steps, margin)` returns a number no smaller than the sum of the bounds it encloses, so an enclosure can never fire before the step that actually hung.
**A run owns its profile and discards it.** `newRunProfile` returns a directory belonging to this run alone; `discard()` kills whatever still holds it and removes it, is idempotent, and also runs when the process leaves — including when the run is killed. Two runs never share local state, so nothing a previous run left can make this one pass or fail.
**A minted wallet is new.** `mintWalletProfile`/`mintWalletBytes` walk the wallet application to create a wallet that did not exist a moment ago. `mintWalletBytes` keeps only the bytes and discards the profile it minted in; `mintWalletProfile` keeps the profile for the length of the run. The password is always a parameter and never has a default; the name defaults to `DEFAULT_WALLET_NAME` because nothing in NextGraph keys off it.
**The crossing dispatches on the screen it can see**, not on a fixed sequence: `setupBrokerPage` reads the page against `BROKER_SCREENS` in order, answers it, and returns the application's `Frame` once the crossing has completed. It identifies the application by ORIGIN. A terminal screen ends the crossing as a failure rather than an action.
**An empty-profile context starts with nothing local.** `emptyProfileContext` gives a context whose profile holds no wallet and no repo cache, which is the reconnection cold start: the wallet's repos are on the broker and not in this profile. `importWalletFile` then puts the wallet in — but not the repos' cache — so the next session over it still hits that cold start.
**A closed server is closed.** `serveOnEphemeralPort` tracks its open sockets and destroys them on `close()`, so a connection nobody hung up cannot be blamed on whatever goes wrong next.
**A known failure mode is named instead of the innocent operation.** `browserTrouble`/`frameTrouble` answer a string when the browser or the application frame is the actual cause, and `null` when they are not. `SuiteOptions.diagnose` puts that answer in FRONT of a journey's own reason, never in place of it. A frame that is attached, on the right URL, and empty is reported as trouble — that is the state a naive check misses.
**The report's size does not depend on what failed.** Journeys and their checks are declared up front to `declareSuite`; `check` throws on a name the journey did not declare, `journey` runs one journey bounded and isolated so it cannot change the report's shape, and `finish` reports every declared check the run did not get to before printing and exiting. A journey whose prerequisite is provably dead is reported as such instead of being driven.
**Errors are values where a caller can act on them.** `browserTrouble`/`frameTrouble`/`Prerequisite` answer `string | null`, never throw for the condition they detect. `DeadlineExceeded` and `BrowserGone` are exported classes, so `instanceof` is a supported way to tell a hang from a dead browser.
## Non-guarantees
**No assertion library, no runner, no fixtures.** `SuiteReport.check` records a boolean you computed; nothing here decides what is true.
**`finish` never returns** — it exits the process. Do not put cleanup after it; put it in the `discard()`/`closeQuietly` path.
**No message text is stable.** The strings from `browserTrouble`, `frameTrouble`, `DeadlineExceeded` and the report are for a human reading a run. Do not parse them, match on them, or branch on them.
**No timing promise.** Every exported `*_MS` constant is a bound sized from a measurement on one machine, not a service level: they change when the measurements change, and a run slower than one of them is a failed run, not a broken guarantee. `E2E_TIMINGS=1` reprints the measurements so you can resize your own.
**Nothing survives a run.** Profiles are discarded, and no artifact, cache, or wallet is carried from one run to the next. The one exception is a file you write yourself with `exportWalletFile` or `ng-mint-wallet`.
**No concurrency contract.** The suite machinery assumes one run per process. Two suites sharing a process share `armSuiteDeadline`, the timings table and the loss declaration.
**No wallet-application version pinning.** The crossing is driven against the wallet application as deployed at the broker you point it at. When that application changes its screens, `BROKER_SCREENS` is what has to be updated — a crossing that fails there is not necessarily your application's fault, and is not this package's promise to hold across upstream redesigns.
## Change policy
**Semver, and majors are the normal case.** This machinery tracks a NextGraph and a wallet application that are both still moving, so a screen inventory, a bound, or a call shape changes whenever they do — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
What each level means here, in this package's own terms:
- **major** — an exported symbol is removed or renamed, **or** an existing call narrows: it now throws where it returned, requires an argument that was optional, or returns something a caller must newly handle. A change to what a `SuiteReport` accepts (a check name, a journey shape) is a major, because your suite is written against it. A signature change a caller must react to counts; one that only accepts more than before does not.
- **minor** — a symbol is added and nothing existing moves: a new helper, a new screen in `BROKER_SCREENS`, a new optional field on `SuiteOptions`.
- **patch** — a fix that changes neither the exported surface nor anything above under `## Guarantees` — including a re-sized `*_MS` constant, which is explicitly disclaimed above.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `ng-e2e-helpers/v1.0.0-dev.2` is this package, `polyfill/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it. What exists today is `1.0.0-dev.2`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: ng-e2e-helpers@1.0.0-dev.2`, the string you pinned, never the version it is heading for.
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
@@ -0,0 +1,55 @@
---
type: usage
summary: What the polyfill's own end-to-end suites call from ng-e2e-helpers, under which constraints, and where the fit still costs them code
against: ng-e2e-helpers@1.0.0-dev.2
---
# usage_polyfill-e2e — the polyfill's end-to-end suites
The consumer is the end-to-end suite of `@ng-eventually/polyfill`: four entry points under that package's `e2e/` — a batch of SDK checks against the broker, an applicative suite driving the example application, a reactivity probe, and a cold-start reproduction — plus the page module they share.
This consumer lives in the same repository as the provider. That changes nothing about what is declared here: the engagement is what these suites are written against, and anything they call that is NOT listed below is theirs to fix if it moves.
## Consumed surface
**Bounds**`within`, `armSuiteDeadline`, `closeQuietly`, `enclosingBound`, `firstLine`. `within` is the most-called entry of the whole package here: every bridge call into the application iframe goes through it.
**Measurement**`measured`. Nothing reads `record`, `timingsWanted` or `printTimings` directly; the `E2E_TIMINGS=1` output is consumed as printed output, not as a call.
**Browser and profiles**`launchWatchedContext`, `closeContext`, `newPage`, and the type `RunProfile`. `newRunProfile` and `isAlive` are never called: profiles are always obtained through a wallet call.
**Wallet**`mintWalletProfile` (the batch wallet, minted once per run), `mintWalletProfileKeepingContext` (the cold-start reproduction, which needs the very first application session over a never-used wallet), `emptyProfileContext` + `importWalletFile` (the reconnection journey), `exportWalletBytes` (serving the wallet from the application's own bundle), and the type `WalletCredentials`.
**Broker crossing**`setupBrokerPage` in all four suites, `completeBrokerLogin` in the applicative one.
**Serving**`serveOnEphemeralPort`, for both the SDK page and the example application's bundle.
**Known failure modes**`browserTrouble`, `frameTrouble`.
**Report**`declareSuite` and the types `JourneyDeclaration`, `Prerequisite`. Used by the applicative suite only.
**Constants**`BROKER_ROUND_TRIP_MS`, `NEW_PAGE_MS`, both as inputs to `enclosingBound` rather than as waits of their own.
Everything else the package exports is offered and NOT consumed here — the screen inventory (`BROKER_SCREENS`, `WALLET_APP`, `WALLET_CREATION`, `WALLET_IMPORT`, `brokerRedirectFor`, and the screen types), `createWalletInContext`, `exportWalletFile`, `mintWalletBytes`, `DEFAULT_WALLET_NAME`, `browserLost`, `lossDeclared`, the exported error classes, and the remaining `*_MS` constants. It is safely evolvable as far as these suites are concerned.
## Constraints
**The browser types come from the helpers.** `Frame`, `Page` and `BrowserContext` are taken from `ng-e2e-helpers`, which re-exports them, rather than from `playwright` directly — so these suites never depend on resolving the same copy of it, which is the position a consumer in another repository is in anyway.
**Every suite mints its own physical wallet, per run.** Nothing is carried between runs and no wallet is reused, so a run's result never depends on a previous one. The batch wallet's NAME is stable and its identity is not: two runs sharing the name share nothing else.
**The password is supplied, never defaulted.** These suites also read the barrier's displayed password back off its own screen and pass it to `importWalletFile`, which is the only way to prove that what the barrier shows is what opens the file. A defaulted password would make that check vacuous.
**An enclosing bound is at least the sum of what it encloses.** These suites compute every enclosure with `enclosingBound` rather than picking a round number, because an enclosure that fires first reports "the journey timed out" and never names the step that hung.
**A wait must not be entered unbounded.** Playwright's `frame.evaluate` waits forever, so no bridge call is made outside `within`.
## Frictions
**`frame.evaluate` has no bound, and three suites each wrote the same wrapper.** A `sdk(frame, method, …args)` helper — `within(…, BRIDGE_MS, () => frame.evaluate(…))` — is duplicated verbatim in three of the four entry points. The bound is the single most important one in each of them, and the package offers nothing to obtain it from. A bounded `evaluate` helper here would delete the duplication and stop each new suite re-deriving it.
**"Measured and bounded" is one intent and two calls.** Sizing a bound from its own measurement is the discipline this package prescribes, yet the applicative suite composes `measured(what, ms, (bound) => within(what, bound, task))` by hand — and the indexing layer's suite, in another repository, wrote the same line byte for byte. A helper that pairs them belongs on the engagement.
**`declareSuite` fits a journey suite and not a check batch.** Only the applicative suite uses it; the other three declare a local `record`/`check` pair and print their own summary, because their shape is a flat list of checks discovered as the run goes rather than named journeys declared up front. The up-front declaration is exactly what makes the report bounded, so this is not a request to relax it — but three of four suites falling outside it suggests the package covers one of two real shapes.
**The screen inventory is exported and unreachable in practice.** `BROKER_SCREENS` and the `WALLET_*` page descriptions are published, but a suite that crosses the broker with `setupBrokerPage` never needs them, and one that wanted to extend the crossing has no supported way to hand a modified inventory back in. As it stands they read as internals that happen to be exported.
@@ -8,7 +8,7 @@ summary: Deciding which identity acts needs no session; connecting does — conf
Two acts of different nature hide behind "sign in": Two acts of different nature hide behind "sign in":
- **`settle`** — decide which identity is acting, from the URL, from storage, or by asking at the `barrier`, then persist it. Pure DOM and storage. **No session required.** - **`settle`** — decide which identity is acting, from the URL, from storage, or by asking at the `barrier`, then persist it. Pure DOM and storage. **No session required.**
- **connect** — put back in that identity's hands everything it can already open: what it OWNS as well as what was shared with it, then drain its queues. **Requires a live session.** - **connect** — put back in that identity's hands everything it can already open: what it OWNS as well as what was shared with it, drain its queues, and then keep applying what arrives in them for as long as it stays connected. **Requires a live session.**
The wallet keeps those two in different places, and connecting once replayed only the second — so an application that reloaded and went straight to a document it had made itself was refused its own document. Whatever else changes here, connecting must replay **every** durable register, not the one that happens to be read on the path being tested. The wallet keeps those two in different places, and connecting once replayed only the second — so an application that reloaded and went straight to a document it had made itself was refused its own document. Whatever else changes here, connecting must replay **every** durable register, not the one that happens to be read on the path being tested.
@@ -49,3 +49,13 @@ A deposit that cannot be applied is not consumed by failing. So a single unusabl
The distinction to keep: **reaching** the queues, and the restore itself, are infrastructure — if they fail, the session genuinely cannot proceed, and rejecting is right. **Applying one item** is data — it must be reported loudly, the remaining queues must still be drained, and the session must still be granted. The distinction to keep: **reaching** the queues, and the restore itself, are infrastructure — if they fail, the session genuinely cannot proceed, and rejecting is right. **Applying one item** is data — it must be reported loudly, the remaining queues must still be drained, and the session must still be granted.
The general shape, worth carrying to any similar rule: when a blanket "every failure surfaces" is imposed, ask which failures are *retried by simply trying again later* and which are *permanent for the actor*. The permanent ones must never gate something the actor cannot otherwise obtain. The general shape, worth carrying to any similar rule: when a blanket "every failure surfaces" is imposed, ask which failures are *retried by simply trying again later* and which are *permanent for the actor*. The permanent ones must never gate something the actor cannot otherwise obtain.
## Connecting does not end when the drain does
The drain is the backlog; being connected is a regime. An identity that stays connected has its inboxes watched throughout, so a deposit made in front of it is applied as it arrives rather than waiting for a reload, and an inbox opened later in the session joins the watched set.
That third step runs whatever became of the two before it, and the failure it was added for says why. A restore that rejected used to skip it, leaving an identity connected — recording who acts is synchronous and had already taken effect — with nothing watching its inboxes for the rest of the session. One broker hiccup while signing in then cost that person every deposit made from then on, in silence, long after the broker had recovered. What the caller is told has not moved: reaching the queues still rejects. What changed is that being connected now *means* being watched, whatever the connection made of its own work.
The counterpart is that the watching belongs to one identity and dies with it. Recording a different identity — or none, which is a disconnection and not merely "no new work" — stops it, because every step of it resolves the current holder as it runs: left going, it would read the previous identity's registers under the new one and file the previous one's `ReadCap`s into the new one's hands. Whoever connects next starts their own.
There is deliberately no timer anywhere in this: the regime is push-driven, and its known cost — a watch that failed to open is re-opened only by a later event, so a session that only reads never gets it back — is stated to applications under the contract's non-guarantees rather than papered over with polling.
+36 -5
View File
@@ -1,11 +1,42 @@
# Inter-repo contracts. `publish:` is this project's engagement toward its consumers — # Inter-repo contracts. `publish:` is this project's engagement toward its consumers —
# listing a leaf here IS the act of publishing it; an unlisted `contract_` leaf is a draft. # listing a leaf here IS the act of publishing it; an unlisted `contract_` leaf is a draft.
# #
# This project is a PROVIDER only for now: the applications that consume the polyfill live in # This project is a PROVIDER of two interfaces, each hosted in an interface folder inside
# their own repos and pull `polyfill-surface` from here. Nothing is consumed in the other # its owning concept, with one `usage_<consumer>.md` per consumer beside the engagement:
# direction — the NextGraph clone this library aligns on is a read-only reference we #
# verify against, not a contract anyone stamps. # polyfill-surface (concepts/app-contract/polyfill-surface/)
# consumers: the Festipod application (its own repo), `ng-helpers`
# ng-e2e-helpers (concepts/e2e-harness/ng-e2e-helpers/)
# consumers: this repo's own polyfill e2e suites (same repo — declared in place,
# nothing to pull), `ng-helpers`, any NextGraph application
#
# `consume:` here is the PROVIDER side of the pair: we pull each consumer's own declaration
# back, so we can see what is actually relied on and read its Frictions. We consume no
# engagement — the NextGraph clone this library aligns on is a read-only reference we verify
# against, not a contract anyone stamps.
#
# `pullFrom:` values are CANONICAL remote identities, because this file travels with the
# branch. Per-developer local access lives in `.project/contracts.local.yaml`, which is
# gitignored and must never be committed.
publish: publish:
# paths are relative to `.project/` # paths are relative to `.project/`
polyfill-surface: concepts/app-contract/contract_polyfill-surface.md polyfill-surface: concepts/app-contract/polyfill-surface/contract_polyfill-surface.md
ng-e2e-helpers: concepts/e2e-harness/ng-e2e-helpers/contract_ng-e2e-helpers.md
consume:
# Festipod is NOT listed: it has not declared a usage leaf, and we do not author one for
# a consumer whose repo is not ours. An interface with no declared consumer degrades to
# the one-document mode, which is what `polyfill-surface` is for Festipod today.
- contract: ng-helpers-uses-polyfill-surface
type: git
pullFrom: git@gitea.reconnexion.apps.gueraud.net:Sylvain/ng-helpers.git/.project/concepts/indexing/polyfill-surface/usage_ng-helpers.md
ref: main
into: concepts/app-contract/polyfill-surface/
- contract: ng-helpers-uses-ng-e2e-helpers
type: git
pullFrom: git@gitea.reconnexion.apps.gueraud.net:Sylvain/ng-helpers.git/.project/concepts/indexing/ng-e2e-helpers/usage_ng-helpers.md
ref: main
into: concepts/e2e-harness/ng-e2e-helpers/
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ng-e2e-helpers", "name": "ng-e2e-helpers",
"version": "0.0.0", "version": "1.0.0-dev.2",
"type": "module", "type": "module",
"description": "End-to-end testing machinery for a NextGraph application: mint and carry a wallet, cross the broker, per-run browser profiles, bounded waits that name what they were waiting for, and a run report whose size does not depend on what failed.", "description": "End-to-end testing machinery for a NextGraph application: mint and carry a wallet, cross the broker, per-run browser profiles, bounded waits that name what they were waiting for, and a run report whose size does not depend on what failed.",
"main": "./src/index.ts", "main": "./src/index.ts",
+5
View File
@@ -94,3 +94,8 @@ export {
type ScreenSignature, type ScreenSignature,
type TextPattern, type TextPattern,
} from "./nextgraph-ui"; } from "./nextgraph-ui";
// Re-exported so a consumer can type its own helpers against our signatures without a second,
// independently-versioned import of `playwright` — exactly the set our own exported functions
// mention (`launchWatchedContext`, `setupBrokerPage`, `frameTrouble`, and their neighbors).
export type { BrowserContext, Frame, Page } from "playwright";
+3 -1
View File
@@ -28,7 +28,6 @@
* can circulate. * can circulate.
*/ */
import { type BrowserContext, type Frame, type Page } from "playwright";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
@@ -55,7 +54,10 @@ import {
serveOnEphemeralPort, serveOnEphemeralPort,
setupBrokerPage, setupBrokerPage,
within, within,
type BrowserContext,
type Frame,
type JourneyDeclaration, type JourneyDeclaration,
type Page,
type Prerequisite, type Prerequisite,
type RunProfile, type RunProfile,
} from "ng-e2e-helpers"; } from "ng-e2e-helpers";
@@ -29,8 +29,7 @@
* shared wallet (as faithfulReconnect does) and writes from it. * shared wallet (as faithfulReconnect does) and writes from it.
*/ */
import type { Frame, Page, BrowserContext } from "playwright"; import { launchWatchedContext, setupBrokerPage, type RunProfile, type Frame, type Page, type BrowserContext } from "ng-e2e-helpers";
import { launchWatchedContext, setupBrokerPage, type RunProfile } from "ng-e2e-helpers";
import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page"; import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
type Check = { name: string; ok: boolean; detail?: string }; type Check = { name: string; ok: boolean; detail?: string };
+1 -2
View File
@@ -21,8 +21,7 @@
* Run: `bun run e2e/repro-fresh-wallet.ts`. * Run: `bun run e2e/repro-fresh-wallet.ts`.
*/ */
import type { Frame, Page, BrowserContext } from "playwright"; import { mintWalletProfileKeepingContext, setupBrokerPage, type RunProfile, type Frame, type Page, type BrowserContext } from "ng-e2e-helpers";
import { mintWalletProfileKeepingContext, setupBrokerPage, type RunProfile } from "ng-e2e-helpers";
import { WALLET, buildBundle, serveHarness } from "./harness-page"; import { WALLET, buildBundle, serveHarness } from "./harness-page";
type Check = { name: string; ok: boolean; detail?: string }; type Check = { name: string; ok: boolean; detail?: string };
+3 -1
View File
@@ -15,7 +15,6 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import type { Frame, Page, BrowserContext } from "playwright";
import { import {
armSuiteDeadline, armSuiteDeadline,
closeContext, closeContext,
@@ -27,6 +26,9 @@ import {
setupBrokerPage, setupBrokerPage,
within, within,
type RunProfile, type RunProfile,
type Frame,
type Page,
type BrowserContext,
} from "ng-e2e-helpers"; } from "ng-e2e-helpers";
import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page"; import { WALLET, buildBundle, mintBatchWallet, serveHarness } from "./harness-page";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ng-eventually/polyfill", "name": "@ng-eventually/polyfill",
"version": "0.0.0", "version": "1.0.0-dev.2",
"type": "module", "type": "module",
"description": "Polyfill of the NextGraph JS SDK over @ng-org/web + @ng-org/orm, with emulated capabilities and inbox. Drop-in; remove at migration.", "description": "Polyfill of the NextGraph JS SDK over @ng-org/web + @ng-org/orm, with emulated capabilities and inbox. Drop-in; remove at migration.",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -482,20 +482,92 @@ export async function readInboxCapsFor(doc: Nuri): Promise<Nuri | undefined> {
*/ */
// @provenance myInboxes kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the User branch answers 'which inboxes may I read'. The document-inbox half of this list is declared-not-wired — see `readInboxCapPairs` // @provenance myInboxes kind=aligned level=1 ref=engine/repo/src/types.rs:AddInboxCapV0 — the User branch answers 'which inboxes may I read'. The document-inbox half of this list is declared-not-wired — see `readInboxCapPairs`
export async function myInboxes(): Promise<Nuri[]> { export async function myInboxes(): Promise<Nuri[]> {
const { inboxes, incomplete } = await enumerateMyInboxes();
// The whole list or the failure that stopped it — the contract this function has always
// had, and the one `connect.connectedUser` is built on: not knowing which queues exist is
// the session failing to establish, and a short list would silently leave a delivered
// share un-drained. The original error is re-thrown, not wrapped: its caller reads it.
if (incomplete !== null) throw incomplete.error;
return inboxes;
}
/**
* What could be listed of {@link myInboxes}, and whether that is ALL of it.
*
* `incomplete` is `null` when the list is whole, and otherwise carries what stopped the rest
* from being listed. Not an error CODE and not a flag on the array: the point is that a
* caller cannot read this answer without meeting the question "was there a failure", which
* is exactly what a short array on its own let everybody skip.
*/
export interface InboxEnumeration {
/** Every inbox this holder may read that COULD be listed — possibly not all of them. */
inboxes: Nuri[];
/**
* What stopped the rest from being listed, or `null` when nothing did and WHICH of the
* two registers stopped it, because the two shortfalls are not the same condition.
*
* `"account-record"` means the user's OWN two inboxes are missing, which is where a share
* addressed to a PERSON lands: the identity has nothing watched that a stranger can reach,
* and the document half was never even attempted (the account record is what it reads
* through). `"user-branch"` means those two are in hand and only the per-document inboxes
* are missing. Told apart because a caller that reports one of them has to be able to say
* that the OTHER one has now happened `error` alone cannot, and the observation
* de-duplicating on "already said something" swallowed exactly that.
*/
incomplete: { error: unknown; register: InboxRegister } | null;
}
/**
* WHICH of the two registers behind {@link enumerateMyInboxes} could not be read.
*
* Named after the register, not after the failure, because that is what a reader has to go
* and look at: the account record in the doc-shim, and the User branch of the private store.
*/
export type InboxRegister = "account-record" | "user-branch";
/**
* {@link myInboxes}, for the caller that can use a PARTIAL answer the live observation.
*
* The list is built from two independent registers: the account record, which names the
* user's own two store inboxes, and the User branch, which names one per document it opened
* an inbox on. Built in one `try`, one unreachable register discarded BOTH halves the two
* user inboxes were already in hand when the second read threw, and the throw dropped them
* on the floor. A broker hiccup spanning sign-in therefore left the identity connected with
* ZERO inboxes watched, not with the one register it could not reach missing: every deposit,
* including the ones addressed to the person by name, waited for the next connection.
*
* So each half answers for itself, and what came back is returned WITH the failure rather
* than instead of it. That is the line this package draws everywhere: reaching a register is
* infrastructure and may fail, but a failure must never come back looking like an absence
* hence {@link InboxEnumeration.incomplete}, which the caller has to look at.
*/
export async function enumerateMyInboxes(): Promise<InboxEnumeration> {
const holder = getCurrentUser(); const holder = getCurrentUser();
if (holder === null) return []; if (holder === null) return { inboxes: [], incomplete: null };
const out: Nuri[] = []; const out: Nuri[] = [];
// BOTH of the user's inboxes — public and protected — since upstream a site carries try {
// one on each of those two store repos (`engine/verifier/src/site.rs:127-152`). // BOTH of the user's inboxes — public and protected — since upstream a site carries
if ((await resolveAccount(holder)) !== null) { // one on each of those two store repos (`engine/verifier/src/site.rs:127-152`).
for (const scope of ["public", "protected"] as const) out.push(await userInbox(holder, scope)); if ((await resolveAccount(holder)) !== null) {
for (const scope of ["public", "protected"] as const) {
out.push(await userInbox(holder, scope));
}
}
} catch (error) {
// The account record is what the other half reads THROUGH (`readInboxCapPairs` resolves
// the same record to find the private store), so there is no second half to attempt.
return { inboxes: out, incomplete: { error, register: "account-record" } };
} }
for (const { inbox } of await readInboxCapPairs()) { try {
// The record entitles this holder to read it — see the note above. for (const { inbox } of await readInboxCapPairs()) {
fileOwnInbox(holder, inbox); // The record entitles this holder to read it — see the note above.
out.push(inbox); fileOwnInbox(holder, inbox);
out.push(inbox);
}
} catch (error) {
return { inboxes: out, incomplete: { error, register: "user-branch" } };
} }
return out; return { inboxes: out, incomplete: null };
} }
/** /**
@@ -7,7 +7,7 @@
* like "the share did not work" rather than "nobody processed the queue". So the * like "the share did not work" rather than "nobody processed the queue". So the
* moment an identity is connected ({@link setCurrentUser}), this runs. * moment an identity is connected ({@link setCurrentUser}), this runs.
* *
* Two steps, in order, and the order matters: * Three steps, in order, and the order matters:
* *
* 1. **Restore** replay the durable registers back into what this user holds. All of * 1. **Restore** replay the durable registers back into what this user holds. All of
* them: the Store branches of its three stores (the emulated `AddRepo { read_cap }` * them: the Store branches of its three stores (the emulated `AddRepo { read_cap }`
@@ -16,9 +16,15 @@
* a handful of reads, no inbox needed. * a handful of reads, no inbox needed.
* 2. **Process** drain the user's inbox (`inbox.processInbox`), which files any * 2. **Process** drain the user's inbox (`inbox.processInbox`), which files any
* new Link durably and puts it among what the user holds. * new Link durably and puts it among what the user holds.
* 3. **Keep applying** start watching those same inboxes, for as long as this identity
* stays connected (`emulated-verifier/inbox-observer.ts`). Step 2 is the backlog;
* this is the regime. It runs whatever became of the first two, including a step 1 that
* REJECTED: what is watched is *being connected*, and `setCurrentUser` has already made
* that true by the time any of this runs.
* *
* Restoring first means a reconnecting user can read its documents immediately, without * Restoring first means a reconnecting user can read its documents immediately, without
* waiting on the inbox round-trip. * waiting on the inbox round-trip; watching last means the backlog is applied before the
* first push arrives to apply it again.
* *
* Restoring means ALL the registers, and that is a scar * Restoring means ALL the registers, and that is a scar
* Until 2026-08-16 this step read the Links and nothing else, so a fresh page put back the * Until 2026-08-16 this step read the Links and nothing else, so a fresh page put back the
@@ -46,12 +52,21 @@
* both are answered by the same place `AddInboxCap` records on the User branch * both are answered by the same place `AddInboxCap` records on the User branch
* (`engine/repo/src/types.rs:1969`) so `storeRegistry.myInboxes()` enumerates * (`engine/repo/src/types.rs:1969`) so `storeRegistry.myInboxes()` enumerates
* them and this drains each in turn. * them and this drains each in turn.
*
* And then it KEEPS going
* Step 2 drains the backlog; it does not end the obligation. Upstream the backlog is the
* exception (`from_queue`) and the rule is that a session applies each message as it
* arrives, so connecting ends by starting the continuous observation of the same inboxes
* (`emulated-verifier/inbox-observer.ts`), which lasts as long as this identity is
* connected. Until 2026-08-17 there was no step 3, and a deposit made while its recipient
* sat connected in front of the application converged only when that person reloaded.
*/ */
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { lookupAccount } from "../shared-wallet/account-registry"; import { lookupAccount } from "../shared-wallet/account-registry";
import { accessLogPrefix, shortNuri } from "../shared-wallet/access-log"; import { accessLogPrefix, shortNuri } from "../shared-wallet/access-log";
import { myInboxes, readLinks, restoreOwnCaps } from "./branch-registers"; import { myInboxes, readLinks, restoreOwnCaps } from "./branch-registers";
import { startObservingInboxes } from "./inbox-observer";
import { processInbox } from "../surface/inbox"; import { processInbox } from "../surface/inbox";
import type { Nuri } from "../model/types"; import type { Nuri } from "../model/types";
@@ -126,7 +141,7 @@ const inFlight = new Map<string, Promise<void>>();
* Not fixed with a retry, a timeout or a flag on purpose: deciding *what to do* about a * Not fixed with a retry, a timeout or a flag on purpose: deciding *what to do* about a
* broker that cannot answer belongs to the caller, and it can only decide if it is told. * broker that cannot answer belongs to the caller, and it can only decide if it is told.
*/ */
// @provenance connectedUser kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream the verifier restores and processes with nothing for a caller to await; this is the awaitable form, for a deterministic start // @provenance connectedUser kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream the verifier restores, applies the queue handed over at connection and keeps applying what follows, with nothing for a caller to await; this is the awaitable form of the first two, for a deterministic start
export async function connectedUser(): Promise<void> { export async function connectedUser(): Promise<void> {
const holder = getCurrentUser(); const holder = getCurrentUser();
if (holder === null) return; if (holder === null) return;
@@ -151,7 +166,7 @@ export async function connectedUser(): Promise<void> {
// Captured with the identity, handed back at filing time — see `caps.holderKey`. // Captured with the identity, handed back at filing time — see `caps.holderKey`.
const holderKey = getCaps().holderKey(); const holderKey = getCaps().holderKey();
const run = (async (): Promise<void> => { const restoreAndDrain = async (): Promise<void> => {
// Connecting must not PROVISION. `ensureAccount` would create the user on // Connecting must not PROVISION. `ensureAccount` would create the user on
// first sight, so connecting an identity that does not exist yet would // first sight, so connecting an identity that does not exist yet would
// silently mint its stores and their caps — arming the whole emulation as a // silently mint its stores and their caps — arming the whole emulation as a
@@ -165,7 +180,17 @@ export async function connectedUser(): Promise<void> {
// success. That is how sharing broke once (`.project/concepts/sign-in/` // success. That is how sharing broke once (`.project/concepts/sign-in/`
// `knowledge_settling-is-not-connecting`), and conflating absence with ignorance is // `knowledge_settling-is-not-connecting`), and conflating absence with ignorance is
// the same fault `inbox.share` was fixed for on 2026-08-10. // the same fault `inbox.share` was fixed for on 2026-08-10.
if ((await lookupAccount(holder)) === null) return; if ((await lookupAccount(holder)) === null) {
// Nothing to restore and no queue to drain — but this identity is CONNECTED, and it
// will acquire both during the session: provisioning is lazy here, so a person's very
// first visit connects with no stores at all and gets its account the moment the
// application creates anything. Watching from now on provisions nothing (`myInboxes`
// answers `[]` for an identity with no account) and is what makes that first session
// behave like every other one — the alternative was a brand-new user watched from
// their SECOND visit onwards, which is precisely the person most likely to be sent
// something. (Started below, for every outcome of this function alike.)
return;
}
if (!stillConnected()) return; if (!stillConnected()) return;
// 1. Durable first, and ALL of it — see the header. The documents this user MADE, from // 1. Durable first, and ALL of it — see the header. The documents this user MADE, from
// the Store branches of its own stores, and then the caps it was GIVEN, from the // the Store branches of its own stores, and then the caps it was GIVEN, from the
@@ -202,6 +227,44 @@ export async function connectedUser(): Promise<void> {
reportUndrained(inbox, error); reportUndrained(inbox, error);
} }
} }
};
const run = (async (): Promise<void> => {
try {
await restoreAndDrain();
} finally {
// 3. …and from here on, KEEP applying. The backlog above is the special case, not the
// rule: upstream a session is handed each inbox message as it arrives and applies it
// inline, and only the messages waiting at connection are the "queue"
// (`from_queue`). Draining once and stopping made a deposit wait for the recipient
// to reload the page. See `emulated-verifier/inbox-observer.ts`.
//
// After the restore and the drain, not before: connecting owes the backlog first,
// and the observation subscribes to the same inboxes that loop just read.
//
// ── Unconditional on how they WENT, and that is the whole point ──
// An inbox that could not be drained is reported and denies nobody their session,
// and it must not deny them the observation of the OTHER inboxes either — nor of
// itself, since the next push is a fresh attempt at exactly the deposit that failed.
//
// The same holds one step up, and until 2026-08-17 it did not: a restore that
// REJECTED skipped this line, and the identity was left CONNECTED — `setCurrentUser`
// is synchronous and had already taken effect — with nothing watching its inboxes
// for the rest of the session. `startObservingInboxes` has no other caller, so one
// broker hiccup at sign-in cost that person every deposit made from then on, in
// silence, long after the broker had recovered.
//
// It does not blur the rule this function is built on. The rule is about what the
// CALLER is told — failing to reach the queues rejects, failing to apply one is
// reported — and rejecting is exactly what still happens: the error raised above
// propagates through this `finally` untouched. What changes is that being connected
// now means being watched, whatever the connection made of its own work.
//
// Awaited, and it never rejects: what connecting starts, connecting finishes, so a
// caller that got its promise back knows the watching is in place — not merely
// requested. (It does not wait for the applying that watching then triggers.)
if (stillConnected()) await startObservingInboxes();
}
})(); })();
inFlight.set(holder, run); inFlight.set(holder, run);
@@ -0,0 +1,83 @@
/**
* ONE drain at a time per inbox the serializer both things that drain an inbox go through.
*
* Two of them exist in this package, and they are not coordinated by anything else: the
* CONTINUOUS observation of the connected identity's own inboxes
* (`emulated-verifier/inbox-observer.ts`) and the deferred timer that stands in for an
* ABSENT owner (`emulated-verifier/inbox-processor.ts`). Both can be pointed at the same
* inbox in the same page a deposit into an inbox whose owner is also connected here arms
* the timer AND pushes to the observation.
*
* Draining writes what it applies (`branch-registers.addLink`, on the owner's User branch),
* so two passes over one queue interleave their reads and writes and apply the same records
* twice. Sequencing them is the same reasoning `connect.connectedUser` drains its inboxes
* one after another for, and it belongs HERE rather than in either caller: an invariant that
* holds only while both callers remember it is not an invariant.
*
* It is a queue, not a lock: a run that arrives while another is going is not dropped, it
* follows. Dropping would be wrong the second run exists because something new landed.
*/
import { accessLogPrefix, shortNuri } from "../shared-wallet/access-log";
import type { Nuri } from "../model/types";
/** Drains in flight or queued, per inbox — the chain a new run appends itself to. */
const running = new Map<Nuri, Promise<void>>();
/**
* Where a drain's failure goes.
*
* It cannot throw: nothing awaits these neither the timer nor a push is a caller and an
* unhandled rejection would take down whatever runtime it is in over work the application
* never requested. But "nowhere" is not the alternative. A drain that swallows its failure
* is indistinguishable from one that succeeded, which is precisely the defect family removed
* from this package on 2026-08-13, and a CONTINUOUS drain that swallowed would repeat that
* silence for as long as the session lasts. So it goes to the package's own log stream the
* same `console.error` with the same `[<identity>][polyfill]` prefix `startConnect` and
* `inbox.watch` use and it is NOT gated by the access-log flag: a diagnostic may be
* opt-in, a failure may not.
*
* One failed item denies nothing, here as at connection: the inbox is not consumed by
* failing, the deposit stays in it, and the next push or the next connection applies it.
*/
function reportFailure(inbox: Nuri, error: unknown): void {
console.error(
accessLogPrefix() + " could not apply what is in this inbox — its deposits stay in it " +
"and the next attempt will try again: " + shortNuri(inbox) + ":",
error,
);
}
/**
* Run `process` for `inbox`, behind any run of the same inbox still in flight.
*
* Resolves when THIS run has finished (or failed, having reported itself), so a caller
* that wants to wait can while a caller that does not simply drops the promise.
*/
// @provenance drainInboxSerially kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream one verifier owns an inbox and applies its messages one at a time as they arrive; this is that serialization, for the two paths this package drains from
export function drainInboxSerially(inbox: Nuri, process: () => Promise<void>): Promise<void> {
const previous = running.get(inbox) ?? Promise.resolve();
const next = previous
// A previous run that FAILED must not cancel this one: its failure was reported where
// failures go, and the deposits it did not apply are exactly what this run is for.
.catch(() => undefined)
.then(process)
.catch((error: unknown) => reportFailure(inbox, error))
.then(() => {
if (running.get(inbox) === next) running.delete(inbox);
});
running.set(inbox, next);
return next;
}
/**
* Wait until no drain is in flight. The seam a suite needs to observe convergence without
* waiting for it and the one `runScheduledInboxProcessingNow` awaits after firing its
* windows. Loops, because a drain can be chained behind the one being awaited.
*/
// @provenance drainsSettled kind=invention level=none ref=none — a test/lifecycle seam over the queue above; upstream nothing exposes "is the verifier done applying"
export async function drainsSettled(): Promise<void> {
while (running.size > 0) {
await Promise.all([...running.values()]);
}
}
@@ -0,0 +1,467 @@
/**
* While an identity is connected, its inboxes are WATCHED and what arrives is APPLIED.
*
* The regime this restores, and the one it replaces
* Upstream, applying an inbox is not something that happens at connection it is what a
* session DOES. A sealed message reaches the recipient's own verifier as it arrives
* (`LocalBrokerMessage::Inbox` `session.verifier.inbox(msg, from_queue)`,
* `sdk/rust/src/local_broker.rs`), which unseals it and applies it inline
* (`engine/verifier/src/inbox_processor.rs`); `from_queue` distinguishes the backlog handed
* over at connection from the messages that follow, and BOTH go through the same door. So
* the backlog is the special case, and the continuity is the rule.
*
* This package had emulated only the backlog. `inbox.processInbox` was called from exactly
* one place `connect.connectedUser`, at connection and nothing anywhere applied a
* deposit after that. A share deposited into an inbox whose owner was sitting connected in
* front of it converged only when that person reloaded the page, and the deposit's own
* session covered the gap by usurping the owner's identity on a twenty-second timer
* (`emulated-verifier/inbox-processor.ts`) which does nothing at all if the depositor
* closes their tab, and tells the connected owner nothing either way.
*
* So: for as long as an identity is connected, every inbox it may read is subscribed to,
* and every push over one of them applies what is in it.
*
* Watching is not applying
* `inbox.watch` looks like this and is not: it NOTIFIES an application that made a call and
* named one inbox, and it applies nothing. This applies, on the whole set, without anybody
* asking because processing an inbox is the library's job and not the app's, which is the
* same ruling `connect.ts` opens with.
*
* Subscription, never a poll
* Every half is push-driven. The inboxes themselves are subscribed to individually. WHICH
* inboxes exist is itself a subscription, on two channels that do not overlap:
*
* - the REGISTER an inbox opened mid-session appends an `AddInboxCap` record to the
* User branch of the private store (`branch-registers.openDocumentInbox`), so a push on
* that document re-enumerates `myInboxes()`;
* - WHAT THIS IDENTITY HOLDS (`CapRegistry.onChange`) the same signal `watchShape`
* re-reads on. It answers the case the register cannot: an identity that connects
* before it has an account at all. Provisioning is lazy here, so a person's FIRST visit
* connects with no stores, no private store to follow and no inbox to watch; the
* account appears later, the moment the application creates anything, and filing its
* caps is what says so. Without this channel a brand-new user was watched from their
* SECOND visit onwards which is exactly the person most likely to be sent something.
*
* So a document whose inbox this identity opened five minutes into the session is watched
* like the rest, and so is the very first inbox of a person who had none when they arrived.
*
* It belongs to ONE holder, and stops when that holder does
* Everything below resolves the CURRENT holder when it runs `myInboxes`, `processInbox`
* and everything under them ask `getCurrentUser()` at the moment they execute. So an
* observation started for Alice must not still be firing when Bob is connected: it would
* read Alice's registers under Bob, or file Alice's capabilities into Bob's ring. The
* observation is therefore stamped with its holder, every step re-checks it, and switching
* identity (or disconnecting) tears it down the same rule, and the same reason,
* `connect.connectedUser` carries `stillConnected()` for.
*/
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log";
import { enumerateMyInboxes, type InboxEnumeration, type InboxRegister } from "./branch-registers";
import { drainInboxSerially, drainsSettled } from "./inbox-drain";
import { lookupAccount } from "../shared-wallet/account-registry";
import { processInbox } from "../surface/inbox";
import { subscribeDoc, subscribeDocReportingSetupFailure, type Unsubscribe } from "../surface/subscribe";
import type { Nuri, PrincipalId } from "../model/types";
/**
* One identity's live observation. Held whole rather than as loose module variables so
* that a step started under it can ask "am I still the current one" by IDENTITY of the
* object, not by comparing a name that a re-connection may have restored in between.
*/
interface Observation {
/** WHO this observation belongs to. Every step re-checks it before acting. */
holder: PrincipalId;
/** One subscription per inbox observed, keyed by it. */
inboxes: Map<Nuri, Unsubscribe>;
/** The subscription on the register that says WHICH inboxes exist. */
register: Unsubscribe | null;
/** Unsubscribe from the held-caps change signal — the second "which inboxes" channel. */
holdings: Unsubscribe | null;
/** True while a re-enumeration is running, so its own effects do not restart it. */
enumerating: boolean;
/** A trigger that arrived mid-cycle: the running one repeats once rather than lose it. */
enumerateAgain: boolean;
/**
* WHICH shortfall was reported last, or `null` when the last list came back WHOLE so a
* register that stays unreadable is reported once, not once per cycle.
*
* Every signal this observation listens to runs a full cycle, and a persistent shortfall
* is re-read by each of them, so the condition is reported as many times as the identity
* is nudged: one deposit produced three copies of the same line. That is what buries the
* report that matters under the report that repeats. Cleared the moment a list comes back
* WHOLE, so a shortfall that goes away and returns is a new occurrence and says so.
*
* The register, and not a bare "already said something"
* There are TWO shortfalls, they are disjoint, and one is far worse than the other: the
* account record failing leaves this identity with NOTHING watched, its own two inboxes
* included, while the User branch failing leaves those two watched and only the
* per-document inboxes missing ({@link InboxEnumeration.incomplete}). A boolean here made
* the second silence the first the condition CHANGED, to a materially different one, and
* the log said nothing because something had already been said. Keyed by register, a
* change of shortfall is what it is: news.
*/
shortfallReported: InboxRegister | null;
/** Enumerations and applications in flight — what {@link observationSettled} waits on. */
pending: Set<Promise<void>>;
}
let observation: Observation | null = null;
/**
* Where a failure to WATCH goes distinct from a failure to APPLY, which
* `inbox-drain.ts` reports.
*
* Not a rejection: nothing awaits this work, exactly as at connection. And not silence, for
* the same reason as everywhere else in this package an observation that cannot be
* established leaves deposits unapplied for the whole session, and the only thing a person
* would notice is that a share never arrives.
*/
function reportUnobserved(what: string, error: unknown): void {
console.error(
accessLogPrefix() + " connected, but " + what + " — deposits made from now on may not be " +
"applied until the next connection:",
error,
);
}
/**
* What a shortfall on `register` leaves unwatched, in the words the log uses.
*
* The two lines have to READ differently, not merely be counted differently: a reader who
* sees the second one after the first has to be able to tell that the condition changed, and
* two identical sentences make an escalation look like a repeat. Both keep the phrase the
* report has always ended on, because it is the one thing this line is scanned for.
*/
function shortfallSaid(register: InboxRegister): string {
return register === "account-record"
? "the inboxes to watch could not all be listed (the account record — your own two " +
"inboxes, where a share addressed to you by name lands, are NOT among the ones watched)"
: "the inboxes to watch could not all be listed (the register of the inboxes opened on " +
"documents — your own two are watched)";
}
/** Is `obs` still the live observation, for the identity it belongs to? */
function current(obs: Observation): boolean {
return observation === obs && getCurrentUser() === obs.holder;
}
/** Track a piece of in-flight work so {@link observationSettled} can wait for it. */
function track(obs: Observation, work: Promise<void>): void {
obs.pending.add(work);
void work.finally(() => obs.pending.delete(work));
}
/**
* Apply whatever is in `inbox`, for the holder this observation belongs to.
*
* Through the shared per-inbox queue, so this cannot interleave with a drain the deferred
* timer started on the same inbox (nor with another push of its own): two passes over one
* queue apply the same records twice. A failure is reported there and stops nothing the
* next push over this inbox tries again, and the other inboxes were never involved.
*/
async function applyWhatArrived(obs: Observation, inbox: Nuri): Promise<void> {
if (!current(obs)) return;
await drainInboxSerially(inbox, async () => {
// Re-checked INSIDE the queue: this run may have waited behind another one, and the
// identity can have moved while it waited. Running it for the wrong holder is not a
// near-miss — it reads someone else's registers and files into someone else's ring.
//
// This check is NECESSARY and it is not SUFFICIENT, and until 2026-08-17 this comment
// claimed it was. It cannot be: the identity can move after it passes, while
// `processInbox` is mid-read. The claim was that `processInbox` "resolves the holder at
// each step, so the new holder is refused an inbox that is not theirs somewhere in the
// middle" — but its ownership guard runs ONCE, at entry, and a switch after that reached
// the filing with nobody left to refuse it. What it filed was the previous holder's cap,
// into the new holder's ring, durably. The guard that makes this run safe is therefore
// the one INSIDE `processInbox`, which captures the holder its guard authorised; this one
// only spares the work when the switch is already visible.
if (!current(obs)) return;
try {
await processInbox(inbox);
} catch (error) {
// An identity that moved MID-DRAIN can throw here too — the switch may land before
// `processInbox`'s own ownership guard, which then refuses the new holder an inbox that
// is not theirs. (Landing after it, the run ABANDONS quietly instead and returns; both
// leave the deposit where it is.) Abandoning is what this package has always called
// "not a failure", and reporting it would put a broker-looking error in the log every
// time a page switches user. Nothing is lost: an inbox is not consumed by being
// abandoned, so the deposit is still there for its owner's next connection. Same rule,
// same words, as the drain loop in `connect.connectedUser`.
if (!current(obs)) return;
throw error;
}
});
}
/**
* (Re-)enumerate the inboxes this identity may read and subscribe to the ones not yet
* watched. Idempotent: an inbox already observed is left alone, so a re-enumeration costs
* one read and nothing else.
*
* Never removes: the list only grows within a session (an `AddInboxCap` record is durable),
* and dropping a subscription on a guess would silently stop applying an inbox.
*
* A list that came back SHORT still gets watched
* `enumerateMyInboxes` and not `myInboxes`: the list is read from two independent registers
* and one of them being unreachable used to discard the other's answer too, so a broker
* hiccup spanning sign-in left the identity connected with ZERO inboxes watched rather than
* with one register's worth missing. Whatever came back is watched here and now including,
* crucially, the user's own two inboxes, which are where a share addressed to a PERSON
* lands, and therefore the channel by which a read-only identity converges at all.
*
* The shortfall is reported, never passed over: an incomplete list is not a complete one,
* and the inboxes it is missing are watched on the next enumeration which is what the
* register push and the holdings signal are for. Applying what lands in a user inbox is
* itself one of those signals (it files a cap), so the ordinary case repairs itself.
*
* Once per OCCURRENCE, though, not once per enumeration see
* {@link Observation.shortfallReported}. The retry that repairs it is also what re-reads it,
* so a condition that persists is re-discovered by every signal; reporting each discovery
* says "it happened again" about the single thing that never stopped happening. An
* occurrence is per REGISTER, because the two shortfalls are two conditions: the list going
* from "missing the document inboxes" to "missing your own two" is not the same fault
* continuing, it is a worse one starting.
*/
async function watchTheInboxes(obs: Observation): Promise<void> {
if (!current(obs)) return;
let listed: InboxEnumeration;
try {
listed = await enumerateMyInboxes();
} catch (error) {
// Not knowing WHICH inboxes exist AT ALL is the whole observation failing, not one queue.
reportUnobserved("the inboxes to watch could not be listed", error);
return;
}
if (!current(obs)) return;
if (listed.incomplete === null) {
obs.shortfallReported = null;
} else if (obs.shortfallReported !== listed.incomplete.register) {
obs.shortfallReported = listed.incomplete.register;
reportUnobserved(shortfallSaid(listed.incomplete.register), listed.incomplete.error);
}
for (const inbox of listed.inboxes) {
if (obs.inboxes.has(inbox)) continue;
try {
// The push is the signal; the drain is the work. The FIRST push is the initial
// `State`, which drains an inbox connection has usually just drained — idempotent,
// and the alternative (skip the first) would lose a deposit that landed in the gap
// between the two.
//
// The failure channel is not decoration: `subscribeDoc` returns SYNCHRONOUSLY and its
// one real `doc_subscribe` is opened afterwards, so a rejection there reaches nobody.
// Without it, this map held an entry for an inbox that was never watched, the loop
// above skipped it at every later enumeration, and the only observable difference from
// a healthy session was that shares stopped arriving.
obs.inboxes.set(
inbox,
subscribeDocReportingSetupFailure(
inbox,
() => track(obs, applyWhatArrived(obs, inbox)),
(error) => watchFailed(obs, inbox, error),
),
);
logStage("OBSERVING " + shortNuri(inbox) + " for " + obs.holder);
} catch (error) {
reportUnobserved("this inbox could not be watched: " + shortNuri(inbox), error);
}
}
}
/**
* The subscription on `inbox` could not be OPENED report it, and forget it.
*
* **Forget it**, because the entry this observation holds is the whole record of "already
* watched": leaving a dead one in place is how one rejection at connection turned into a
* session-long silence. Removed, the next enumeration subscribes again as if it had never
* been attempted.
*
* And that is ALL it does: recovery waits for the next SIGNAL
* Until 2026-08-17 this also re-enumerated on the spot, once per inbox. That call runs
* synchronously out of the very rejection it is reacting to, so it asks the broker that has
* just refused, in the same turn, with nothing having changed and spends the one extra
* attempt it was allowed doing it. Deleting it left the whole suite green, which is the
* measure of what it achieved.
*
* What actually repairs this is an event, and this module already listens to the two that
* exist: the register push (`watchTheRegister`) and the held-caps signal
* (`CapRegistry.onChange`), each of which re-enters {@link enumerate} and finds this inbox
* unwatched. Applying anything at all fires the second one, so a session that is doing
* something catches up on its own. A session that is doing nothing waits for its next
* connection where an unapplied deposit has always waited, since an inbox is not consumed
* by being unread. Polling for it is ruled out here for the same reason it is in
* `inbox-processor.ts`: this regime is push-driven, and a timer is what it replaced.
*/
function watchFailed(obs: Observation, inbox: Nuri, error: unknown): void {
if (!current(obs)) return;
const stale = obs.inboxes.get(inbox);
obs.inboxes.delete(inbox);
if (stale) {
try {
stale();
} catch (thrown) {
console.error(accessLogPrefix() + " releasing a failed inbox observation failed:", thrown);
}
}
reportUnobserved("this inbox could not be watched: " + shortNuri(inbox), error);
}
/**
* Follow the register that says which inboxes exist, so one opened MID-SESSION is picked
* up. See the module header: `openDocumentInbox` appends its record to the User branch of
* the private store, and a same-session write pushes to its own subscribers.
*
* Idempotent, and RETRIED rather than given up on: an identity that connects before it has
* an account has no private store to follow yet, and acquires one the first time the
* application creates anything. Re-entered from the holdings signal, it catches up then.
*/
async function watchTheRegister(obs: Observation): Promise<void> {
if (!current(obs) || obs.register !== null) return;
let store: Nuri | undefined;
try {
// `lookupAccount`, not `resolveAccount`: the tolerant form answers `null` for a read
// that FAILED exactly as for one that found nothing, and the two could not mean more
// different things here — "this identity has no account yet" is a state to wait
// quietly through, "the broker did not answer" is a failure to report. The same
// distinction `connect.connectedUser` and `inbox.share` are built on.
store = (await lookupAccount(obs.holder))?.docPrivate;
} catch (error) {
reportUnobserved("the register of your inboxes could not be reached", error);
return;
}
// No account yet — nothing to follow, and asking for one would PROVISION it, which
// connecting deliberately does not do (`connect.connectedUser`). The holdings signal
// brings us back here the moment there is something to follow.
if (!store || !current(obs) || obs.register !== null) return;
try {
obs.register = subscribeDoc(store, () => track(obs, enumerate(obs)));
} catch (error) {
reportUnobserved("an inbox opened later in this session will not be watched", error);
}
}
/**
* The whole "which inboxes am I watching" cycle: catch up on the register, then on the
* inboxes. Both halves are idempotent, so this is what every trigger runs.
*
* A trigger that arrives while a cycle is running does not start a second one the cycle
* FILES caps for the inboxes it lists (`myInboxes` `fileOwnInbox`), which is one of the
* signals that runs it, so overlapping runs would pile up on their own effects. It is
* REMEMBERED instead of dropped: the running cycle repeats once more at the end, because a
* signal that arrived mid-cycle may be about something that cycle had already read past.
*
* That terminates rather than ping-ponging: filing a cap this identity already holds fires
* nothing (`caps.file` notifies only on a genuinely new one), so the repeat that finds
* nothing new sets no flag and the loop ends.
*/
async function enumerate(obs: Observation): Promise<void> {
if (!current(obs)) return;
if (obs.enumerating) {
obs.enumerateAgain = true;
return;
}
obs.enumerating = true;
try {
do {
obs.enumerateAgain = false;
await watchTheRegister(obs);
await watchTheInboxes(obs);
} while (obs.enumerateAgain && current(obs));
} finally {
obs.enumerating = false;
}
}
/**
* Start observing the connected identity's inboxes. Called at the end of the connection
* work (`connect.connectedUser`), and idempotent: calling it again for the identity already
* being observed changes nothing.
*
* It resolves when the WATCHING is in place, not when the applying is done
* Connecting publishes one guarantee *the work it starts, it finishes* and "your
* inboxes are now being watched" is part of that work, so this is awaited rather than
* fired. What it does NOT wait for is the applying: subscribing pushes an initial `State`,
* which drains each inbox again, and making a person's sign-in wait on a second pass over
* a backlog `connectedUser` has just applied would buy nothing.
*
* It never rejects. Failing to WATCH is reported where failures go and denies nobody their
* session the same rule the drain loop above it follows, and for the same reason: what
* has not been applied stays in its queue for the next attempt.
*/
// @provenance startObservingInboxes kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream a connected verifier is handed each inbox message as it arrives and applies it; here the arrival signal is a document push, and the set of inboxes is the User branch's `AddInboxCap` records
export async function startObservingInboxes(): Promise<void> {
const holder = getCurrentUser();
if (holder === null) return;
if (observation !== null && observation.holder === holder) return;
stopObservingInboxes();
const obs: Observation = {
holder,
inboxes: new Map(),
register: null,
holdings: null,
enumerating: false,
enumerateAgain: false,
shortfallReported: null,
pending: new Set(),
};
observation = obs;
// The second "which inboxes exist" channel — see the module header. It is what makes a
// person's FIRST session watched at all: they connect holding nothing, and their account
// (with its inboxes) appears the moment the application creates something.
obs.holdings = getCaps().onChange(() => track(obs, enumerate(obs)));
const setup = enumerate(obs);
track(obs, setup);
await setup;
}
/**
* Stop observing on an identity change, on disconnection, and when the library is
* un-configured.
*
* Work already inside the drain queue is not cancelled (nothing can un-issue a write
* already sent); it is neutralised instead, because every step re-checks that its
* observation is still the current one and returns rather than filing for the wrong holder.
*/
// @provenance stopObservingInboxes kind=aligned level=1 ref=engine/verifier/src/verifier.rs:Verifier — a verifier's inbox processing lives and dies with its session; switching identity is another session, so nothing of the previous one keeps applying
export function stopObservingInboxes(): void {
const obs = observation;
if (obs === null) return;
observation = null;
for (const unsub of obs.inboxes.values()) {
try {
unsub();
} catch (error) {
console.error(accessLogPrefix() + " stopping an inbox observation failed:", error);
}
}
obs.inboxes.clear();
for (const unsub of [obs.register, obs.holdings]) {
if (!unsub) continue;
try {
unsub();
} catch (error) {
console.error(accessLogPrefix() + " stopping an inbox observation failed:", error);
}
}
obs.register = null;
obs.holdings = null;
}
/**
* Wait until the observation has nothing in flight the enumerations it is running and the
* drains they started.
*
* A seam for a suite that has to observe convergence without waiting for it, in the same
* spirit as `runScheduledInboxProcessingNow`: it names no inbox and starts no work, so it
* can only wait for what a push has already caused. Never exported from the package.
*/
// @provenance observationSettled kind=invention level=none ref=none — a test seam over the observation; upstream nothing exposes "has the verifier finished applying"
export async function observationSettled(): Promise<void> {
for (;;) {
const obs = observation;
const inFlight = obs ? [...obs.pending] : [];
if (inFlight.length === 0) break;
await Promise.all(inFlight);
}
await drainsSettled();
}
@@ -1,18 +1,26 @@
/** /**
* The inbox-processing SERVICE this deployment has not got emulated by a timer. * A stand-in for an inbox whose owner is NOT HERE emulated by a timer.
* *
* What is missing, and what stands in for it * What is missing, and what stands in for it
* Upstream a deposit does not wait for anybody: the broker routes the sealed message * Upstream a deposit does not wait for anybody: the broker routes the sealed message
* (`inboxes: PubKey → RepoId`, `engine/verifier/src/verifier.rs:105,1677`) and the * (`inboxes: PubKey → RepoId`, `engine/verifier/src/verifier.rs:105,1677`) and the
* recipient's verifier applies it when it processes the inbox * RECIPIENT's own verifier applies it as it arrives, for as long as that recipient has a
* (`engine/verifier/src/inbox_processor.rs`). A real NextGraph deployment will have * session (`LocalBrokerMessage::Inbox` `session.verifier.inbox(…)`,
* something running continuously that does this; today, in this library, the ONLY thing * `sdk/rust/src/local_broker.rs`). Two different things follow from that, and this module
* that drains an inbox is its owner connecting (`connect.connectedUser` * is only one of them:
* `inbox.processInbox`), which can be hours away. *
* - a connected identity's own inboxes are observed and applied continuously, which is
* `emulated-verifier/inbox-observer.ts` and needs no timer at all;
* - an identity that is NOT connected has nobody to apply anything for it. Upstream the
* broker queues the message until it comes back; here, on ONE shared wallet, the
* depositor's own page can stand in for it.
* *
* So: a successful deposit arms a ONE-SHOT timer that processes the TARGET's inbox, twenty * So: a successful deposit arms a ONE-SHOT timer that processes the TARGET's inbox, twenty
* seconds later. Alice deposits into Bob's inbox; twenty seconds later this package drains * seconds later. Alice deposits into Bob's inbox; twenty seconds later this package drains
* Bob's inbox, while the connected session is still Alice's. * Bob's inbox, while the connected session is still Alice's. When Bob IS connected in this
* page, the observation has already applied it long before the window closes and the timer
* finds nothing left to do harmless (draining is idempotent) and not worth a special
* case: the two answer different questions, and only this one answers the absent owner.
* *
* That is identity usurpation, and it is deliberate * That is identity usurpation, and it is deliberate
* It is only possible because one shared wallet holds every virtual identity, and it is * It is only possible because one shared wallet holds every virtual identity, and it is
@@ -32,7 +40,8 @@
* the real path, not a repair. * the real path, not a repair.
*/ */
import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log"; import { logStage, shortNuri } from "../shared-wallet/access-log";
import { drainInboxSerially, drainsSettled } from "./inbox-drain";
import type { Nuri } from "../model/types"; import type { Nuri } from "../model/types";
/** /**
@@ -62,13 +71,6 @@ interface Scheduled {
*/ */
const scheduled = new Map<Nuri, Scheduled>(); const scheduled = new Map<Nuri, Scheduled>();
/**
* Drains in flight, per inbox so a run that starts while another is still going CHAINS
* behind it instead of interleaving with it. Same reason `connect.connectedUser` drains its
* inboxes sequentially: two concurrent passes over one queue apply the same records twice.
*/
const running = new Map<Nuri, Promise<void>>();
/** /**
* Keep a pending drain from holding a runtime open. * Keep a pending drain from holding a runtime open.
* *
@@ -83,38 +85,14 @@ function releaseFromTheEventLoop(handle: ReturnType<typeof setTimeout>): void {
} }
/** /**
* Where a deferred drain's failure goes. * Run the drain of `inbox`, behind any run of the same inbox still in flight including a
* * run the CONTINUOUS observation started, which is why the queue lives in
* It cannot throw: nothing awaits it the application never asked for this work, and an * `emulated-verifier/inbox-drain.ts` rather than here. That module also owns where a
* unhandled rejection would take down whatever runtime it is in over a drain it did not * drain's failure goes; it cannot travel back to a caller, because a timer has none.
* request. But "nowhere" is not the alternative. A drain that swallows its failure is
* indistinguishable from one that succeeded, which is precisely the defect family removed
* from this package on 2026-08-13. So it goes to the package's own log stream the same
* `console.error` with the same `[<identity>][polyfill]` prefix that `startConnect` and
* `inbox.watch` use for their un-awaitable failures, and it is NOT gated by the access-log
* flag: a diagnostic may be opt-in, a failure may not.
*/ */
function reportFailure(inbox: Nuri, error: unknown): void {
console.error(
accessLogPrefix() + " deferred inbox processing failed for " + shortNuri(inbox) + ":",
error,
);
}
/** Run the drain of `inbox`, behind any run of the same inbox still in flight. */
function fire(inbox: Nuri, process: () => Promise<void>): void { function fire(inbox: Nuri, process: () => Promise<void>): void {
scheduled.delete(inbox); scheduled.delete(inbox);
const previous = running.get(inbox) ?? Promise.resolve(); void drainInboxSerially(inbox, process);
const next = previous
// A previous run that FAILED must not cancel this one: its failure was reported where
// failures go, and the deposits it did not apply are exactly what this run is for.
.catch(() => undefined)
.then(process)
.catch((error: unknown) => reportFailure(inbox, error))
.then(() => {
if (running.get(inbox) === next) running.delete(inbox);
});
running.set(inbox, next);
} }
/** /**
@@ -126,7 +104,7 @@ function fire(inbox: Nuri, process: () => Promise<void>): void {
* *
* Idempotent inside the window: a second deposit into the same inbox joins the pending run. * Idempotent inside the window: a second deposit into the same inbox joins the pending run.
*/ */
// @provenance scheduleInboxProcessing kind=divergent level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream the RECIPIENT's own verifier applies its inbox; here another identity's session drains it on a timer. Deliberate, unpublished, and only possible on one shared wallet // @provenance scheduleInboxProcessing kind=divergent level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream the RECIPIENT's own verifier applies its inbox and the broker queues for it while it is away; here another identity's session drains it on a timer, for a recipient that is not connected in this page (one that IS has its own observation). Deliberate, unpublished, and only possible on one shared wallet
export function scheduleInboxProcessing(inbox: Nuri, process: () => Promise<void>): void { export function scheduleInboxProcessing(inbox: Nuri, process: () => Promise<void>): void {
if (scheduled.has(inbox)) return; if (scheduled.has(inbox)) return;
const handle = setTimeout(() => fire(inbox, process), PROCESSING_DELAY_MS); const handle = setTimeout(() => fire(inbox, process), PROCESSING_DELAY_MS);
@@ -152,7 +130,7 @@ export async function runScheduledInboxProcessingNow(): Promise<void> {
clearTimeout(entry.handle); clearTimeout(entry.handle);
fire(inbox, entry.process); fire(inbox, entry.process);
} }
await Promise.all([...running.values()]); await drainsSettled();
} }
/** /**
@@ -74,7 +74,7 @@
import { mustNotAttempt } from "./reach"; import { mustNotAttempt } from "./reach";
import { fetchReadCap } from "./public-store"; import { fetchReadCap } from "./public-store";
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe"; import { resubscribeDocs, subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe";
import { logStage, shortNuri } from "../shared-wallet/access-log"; import { logStage, shortNuri } from "../shared-wallet/access-log";
import type { Nuri } from "../model/types"; import type { Nuri } from "../model/types";
@@ -137,6 +137,15 @@ export function resetOpenedRepos(): void {
syncState.clear(); syncState.clear();
boundSessionId = null; boundSessionId = null;
OPEN_TIMEOUT_MS = 8000; OPEN_TIMEOUT_MS = 8000;
// Whatever ELSE was still subscribed to a document — a `watchShape` following a scope,
// an inbox observation — belongs to the session that just went away too. One real
// subscription now serves all of them (`surface/subscribe.ts`), so dropping the bootstrap
// ones above no longer closes those channels; they are re-opened against the new session,
// with their barrier forgotten. Without this a bootstrap open would join a surviving
// fan-out, be handed the PREVIOUS session's `State`, and call a repo the new session has
// never synced "synced". After the held teardown, so a document nobody else follows is
// simply released.
resubscribeDocs();
} }
/** /**
@@ -25,6 +25,8 @@ import { setAccessLog } from "./access-log";
import { inspectOutbox } from "./outbox-log"; import { inspectOutbox } from "./outbox-log";
import { startConnect } from "../emulated-verifier/connect"; import { startConnect } from "../emulated-verifier/connect";
import { cancelScheduledInboxProcessing } from "../emulated-verifier/inbox-processor"; import { cancelScheduledInboxProcessing } from "../emulated-verifier/inbox-processor";
import { stopObservingInboxes } from "../emulated-verifier/inbox-observer";
import { resetDocSubscriptions } from "../surface/subscribe";
import { resetSharedWalletSession, sharedWalletSession } from "./session"; import { resetSharedWalletSession, sharedWalletSession } from "./session";
/** /**
@@ -191,6 +193,13 @@ let caps = new CapRegistry(capsHolder);
// @provenance configure kind=invention level=none ref=none — same — the whole call disappears at migration, when the consumer initialises the real SDK directly // @provenance configure kind=invention level=none ref=none — same — the whole call disappears at migration, when the consumer initialises the real SDK directly
export function configure(c: EventuallyConfig): void { export function configure(c: EventuallyConfig): void {
// Whatever the PREVIOUS SDK was driving stops here. A document subscription belongs to
// the `ng` that opened it and an inbox observation to the identity that was connected
// through it, so carrying either into a fresh configuration would leave listeners
// attached to a verifier nobody is talking to any more — and, on the observation, work
// that files for an identity this call is about to clear.
resetDocSubscriptions();
stopObservingInboxes();
cfg = c; cfg = c;
// Not taken from the config: an application never supplies its own identity — upstream // Not taken from the config: an application never supplies its own identity — upstream
// it comes FROM the wallet a person opened. Accepting one here would also let a caller // it comes FROM the wallet a person opened. Accepting one here would also let a caller
@@ -224,6 +233,12 @@ export function resetConfig(): void {
// no longer has an `ng` to read or write with, and report a failure about a session that // no longer has an `ng` to read or write with, and report a failure about a session that
// no longer exists. See `emulated-verifier/inbox-processor.ts`. // no longer exists. See `emulated-verifier/inbox-processor.ts`.
cancelScheduledInboxProcessing(); cancelScheduledInboxProcessing();
// …and so does the continuous observation, for the same reason and one more: it is a set
// of live document subscriptions, which the un-configured library has no `ng` to cancel
// through. Both go before the subscriptions themselves, so nothing is left holding a
// torn-down fan-out.
stopObservingInboxes();
resetDocSubscriptions();
// The hand-over goes with it, for the same reason: it is made of the config's injected // The hand-over goes with it, for the same reason: it is made of the config's injected
// `init`, so leaving it behind would let a revived barrier delegate to the PREVIOUS // `init`, so leaving it behind would let a revived barrier delegate to the PREVIOUS
// application's SDK. // application's SDK.
@@ -350,6 +365,13 @@ export function adoptCurrentUser(id: PrincipalId | null): boolean {
*/ */
export function setCurrentUser(id: PrincipalId | null): void { export function setCurrentUser(id: PrincipalId | null): void {
const changed = adoptCurrentUser(id); const changed = adoptCurrentUser(id);
// The PREVIOUS identity stops being watched the moment it stops being connected —
// including on `null`, which is a disconnection and not merely "no new work". An
// observation applies what arrives by resolving the current holder at each step, so one
// left running past a switch would read the old identity's registers under the new one
// and file the old one's capabilities into the new one's ring. Whoever connects next
// starts their own. See `emulated-verifier/inbox-observer.ts`.
if (changed) stopObservingInboxes();
// Connecting a user is what triggers inbox processing — the library's job, not // Connecting a user is what triggers inbox processing — the library's job, not
// the app's. Fire-and-forget: this setter is synchronous and every consumer calls // the app's. Fire-and-forget: this setter is synchronous and every consumer calls
// it from synchronous code, so the work announces itself through the cap // it from synchronous code, so the work announces itself through the cap
+32 -1
View File
@@ -669,16 +669,47 @@ export async function readSyncedForDocument(docLike: NuriLike): Promise<Deposit[
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a * Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as * second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
* {@link read} does Links are never surfaced. * {@link read} does Links are never surfaced.
*
* The holder is captured, not re-resolved after the read
* The ownership guard runs at entry (inside {@link readSynced}) and the FILING happens a
* broker round-trip later, so re-asking `getCurrentUser()` at that point asks a different
* question: not "whose inbox is this" but "who is holding the page NOW". A page that
* switched identity in that window filed CAROL's cap, deposited for ALICE, durably into
* BOB's ring — `documentsGivenTo("bob")` returns Carol's document, Alice gets nothing, and
* Bob's next sign-in restores it as a capability he holds. Not a near-miss: one user is
* given another's document and the real recipient is left with none.
*
* So the holder the guard authorised is the one the filing is checked against, and a switch
* ABANDONS the same answer {@link read} gives for its own half of this window, and the
* same one `connect.connectedUser` gives for a drain it started. Nothing is lost: an inbox
* is not consumed by being read, so the deposit is still there for its owner's next
* connection or its next push. Filing it FOR the absent owner instead would be a write on
* somebody else's store from a published call — which is the deferred processor's
* deliberate, unpublished privilege (`emulated-verifier/inbox-processor.ts`), not this one's.
*/ */
// @provenance inbox.processInbox kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — unseal-and-apply: Links are filed durably and never surfaced. Returning the remaining deposits is the divergent half it shares with `read` // @provenance inbox.processInbox kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — unseal-and-apply: Links are filed durably and never surfaced. Returning the remaining deposits is the divergent half it shares with `read`
export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]> { export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.processInbox"); const targetInbox = toNuri(targetInboxLike, "inbox.processInbox");
// WHO this processing is for, captured before the read that authorises it — see above.
const holder = getCurrentUser();
const deposits = await readSynced(targetInbox); const deposits = await readSynced(targetInbox);
// `readSynced` already put every Link in memory for this session; now make // `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 // them durable. Reading the raw deposits again would mean re-parsing, so the caps
// are taken from what the read just observed. // are taken from what the read just observed.
for (const cap of capsSeenIn(targetInbox)) await addLink(cap); const seen = capsSeenIn(targetInbox);
seenByInbox.delete(targetInbox); seenByInbox.delete(targetInbox);
for (const cap of seen) {
// Re-checked per cap, not once: `addLink` reads and writes, so the identity can move
// between two of them just as easily as during the read.
if (getCurrentUser() !== holder) {
logStage(
"ABANDONED " + shortNuri(targetInbox) + " — the identity changed while it was being " +
"processed; its deposits stay for their owner",
);
return deposits;
}
await addLink(cap, holder ?? undefined);
}
return deposits; return deposits;
} }
+459 -36
View File
@@ -31,6 +31,49 @@
* resolves the ~75s hang. `doc_subscribe` is per-branch/per-doc and has no * 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} * 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. * builds a set of these with per-doc error isolation to preserve that property.
*
* ONE real subscription per document, fanned out here
* A branch has room for exactly ONE subscriber upstream, and a second `doc_subscribe`
* does not join it it EVICTS the first. `branch_subscriptions: HashMap<BranchId,
* Sender<AppResponse>>` holds one sender per branch, and `create_branch_subscription`
* inserts into it and closes whatever it displaced
* (`engine/verifier/src/verifier.rs:create_branch_subscription`), for the document's own
* branch AND for its Header branch.
*
* Verified against the real broker on 2026-08-17: with two `subscribeDoc` calls on one
* document, a write fired the SECOND callback (`Patch`) and the first stopped firing
* entirely after having fired on a write moments earlier. Silently: nothing rejects,
* nothing logs, the stale unsubscribe still "works".
*
* That made every internal subscriber a hazard to every other one. `ensureRepoOpen`
* holds a bootstrap subscription per document for the whole session, `watchShape`
* subscribes to the documents of a scope, `inbox.watch` to an inbox so opening a
* repo killed the watch on it, and the app-visible result was a view that never
* re-read and an inbox that never notified. Both were reported as "the layer does not
* notify of its own actions", which is NOT what happens: a same-session write DOES
* push (verified the same day `Patch@69ms` on the writer's own `sparqlUpdate`,
* `e2e/reactivity-doc-subscribe.ts`). The push arrived; there was no longer anybody
* on the other end.
*
* So this module keeps at most one real subscription per NURI and fans its pushes out
* to every local listener. A listener that joins after the initial `State` is replayed
* the remembered one, because that is what its own `doc_subscribe` would have handed
* it without the replay, joining a document somebody else already opened would never
* fire, and `inbox.watch`'s "fires once immediately" would silently stop being true.
* The real subscription is torn down when the LAST listener leaves.
*
* What SHARING one subscription must not cost a caller
* Three things a per-caller `doc_subscribe` gave for free, and that the fan-out has to give
* back deliberately each of them was lost when it was introduced on 2026-08-17:
*
* - a caller is ITSELF, not its handler. One record per CALL, so passing the same function
* twice is two subscriptions and the first unsubscribe does not silence the second;
* - a caller that has LEFT hears nothing more, including from the push during which it
* left a handler may tear another one down, and the pushes are re-checked against the
* live set rather than a copy taken before the first handler ran;
* - a setup that FAILED is not the end of the document. The attempt is forgotten so the
* next joiner makes its own (upstream, each caller's own `doc_subscribe` retried), and
* it is reported rather than left as silence see {@link reportSetupFailure}.
*/ */
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
@@ -87,6 +130,358 @@ async function sessionId(): Promise<string | number> {
return (await getStoreRegistryDeps().getSession()).sessionId; return (await getStoreRegistryDeps().getSession()).sessionId;
} }
// --- one real subscription per document -----------------------------------
/** What a listener is handed on every push. */
type Listener = (r: DocChange, type: DocChangeType) => void;
/**
* ONE call to {@link subscribeDocUnguarded}, and what that caller asked for.
*
* Identified by this record and never by the `onChange` function: two callers may
* legitimately pass the SAME function a module-level handler, a bound method, an arrow
* that closes over nothing and they are two subscriptions with two independent lifetimes.
* Keyed by the function, the second call was swallowed by the set and the FIRST caller's
* unsubscribe silenced the second, which had never asked to leave.
*/
interface DocListener {
/** What this caller is handed on every push. */
onChange: Listener;
/**
* Told when the one real `doc_subscribe` behind this listener could not be OPENED.
*
* Upstream `doc_subscribe` is async and REJECTS on a setup failure, so its caller learns.
* This wrapper returns synchronously, so without this channel a caller cannot tell "this
* document is quiet" from "this document is not subscribed at all" the failure-as-absence
* this package keeps closing. `null` for a caller that did not ask (the published
* {@link subscribeDoc}, whose contract has no such argument); the failure is logged either
* way.
*/
onSetupFailed: ((error: unknown) => void) | null;
}
/**
* The single real `doc_subscribe` behind every local listener on one document.
* See the module header for why there can only be one.
*/
interface DocFanOut {
/** Every local listener on this document. The last one to leave tears it down. */
listeners: Set<DocListener>;
/** The platform's unsubscribe, once the async setup resolved. */
realUnsub: (() => void) | null;
/**
* True from the moment a setup is kicked off a later joiner must not kick off a second,
* because a second `doc_subscribe` on a branch EVICTS the first (see the module header).
* It therefore stays true once the setup has SUCCEEDED: the attempt still stands.
*
* Cleared on the two ways the attempt stops standing: the last listener leaving
* ({@link releaseFanOut}), and the CURRENT setup FAILING ({@link reportSetupFailure}). Left
* true on failure it stopped meaning "one is already running" and started meaning "this NURI
* is finished" no later joiner ever attempted it again, for the whole session, over one
* transient rejection.
*/
establishing: boolean;
/**
* WHICH establish this fan-out is waiting on bumped by {@link beginEstablish} every time
* one is kicked off, and captured by that call for the whole of its life.
*
* Two establishes can be in flight over the SAME entry: {@link resubscribeDocs} re-opens the
* channel of a fan-out whose first attempt has not settled yet, and it re-opens it on the
* entry rather than on a fresh one, precisely so the listeners are kept. So `entry` identity
* the only currency this module had answers "is this fan-out still the one for this
* NURI", and cannot answer "is this attempt still the one this fan-out is waiting on". A
* counter can, and that second question is the one a LATE outcome has to ask: the first
* attempt rejecting long after the second SUCCEEDED is not this document failing, it is one
* superseded call finally answering.
*/
attempt: number;
/**
* The most recent `State` push, replayed to a listener that joins later.
* Its own `doc_subscribe` would have pushed one; the fan-out owes it the same.
*/
lastState: { resp: DocChange; type: DocChangeType } | null;
}
/**
* Every document with at least one local listener.
*
* An entry in this map ALWAYS has listeners that is the invariant the rest of the module
* reads "is this fan-out still standing" off: a listener is added synchronously with the
* entry that holds it ({@link subscribeDocUnguarded}), and the last one out takes the entry
* with it ({@link releaseFanOut}, {@link resetDocSubscriptions}). So no caller asks after the
* listener count separately; `fanOuts.get(nuri) === entry` already answers it.
*/
const fanOuts = new Map<Nuri, DocFanOut>();
/** Hand one push to one listener, isolating a throwing handler from the others. */
function deliver(nuri: Nuri, listener: DocListener, resp: DocChange, type: DocChangeType): void {
try {
listener.onChange(resp, type);
} catch (error) {
console.error("[subscribe] onChange handler threw for", nuri, error);
}
}
/**
* Hand one push to every listener of `entry` the fan-out itself.
*
* Over a COPY, because a handler may unsubscribe itself or another from inside the push; and
* re-checking each listener against the live set, because a copy alone only stops the walk
* from breaking it still delivers to whoever left DURING it. That contradicted what
* {@link subscribeDoc} publishes ("no further `onChange` fires after unsubscribe"), on the
* one ordering an application cannot control: whether its handler runs before or after the
* one that tore it down.
*/
function fanOut(nuri: Nuri, entry: DocFanOut, resp: DocChange, type: DocChangeType): void {
for (const listener of [...entry.listeners]) {
if (!entry.listeners.has(listener)) continue;
deliver(nuri, listener, resp, type);
}
}
/**
* Is `attempt` still the establish this fan-out is waiting on?
*
* Two independent ways to stop being it, and each is the only one that catches its own case:
* the fan-out was RELEASED (last listener left, or `resetDocSubscriptions`), which drops it
* from the map and leaves the counter untouched so only the map half sees it; or it was
* SUPERSEDED in place by {@link resubscribeDocs}, which keeps the very same entry in the map
* and bumps the counter so only the counter half sees it.
*/
function isCurrentAttempt(nuri: Nuri, entry: DocFanOut, attempt: number): boolean {
return fanOuts.get(nuri) === entry && entry.attempt === attempt;
}
/**
* Kick off the one real subscription for `entry` the ONLY way an establish is started.
*
* It stamps the attempt (see {@link DocFanOut.attempt}) as it starts it, so an outcome that
* arrives late can tell whether it is still the one being waited on. Being the only door is
* what makes that true: a second call site that forgot to bump the counter would hand its
* establish the number of the one it just superseded.
*/
function beginEstablish(nuri: Nuri, entry: DocFanOut, ng: { doc_subscribe?: unknown }): void {
entry.establishing = true;
entry.attempt += 1;
void establish(nuri, entry, ng, entry.attempt);
}
/**
* The one real subscription could not be OPENED: forget the attempt, and say so.
*
* Two halves, and the shape of every "failure disguised as an absence" this package has
* closed. **Retryable** `establishing` goes back to false, so the next `subscribeDoc` on
* this NURI attempts it again instead of joining a fan-out that will never push. Left true,
* one transient rejection made the document silent for every later joiner in the session.
* **Visible** the log always, plus the callers who asked to be told, so a caller whose job
* depends on the subscription (the inbox observation) can report it and try again rather
* than sit on a dead entry.
*
* Only for the attempt still being WAITED ON
* Both halves act on the fan-out as it stands NOW, so a SUPERSEDED attempt must do neither.
* A rejection that arrives after {@link resubscribeDocs} has already re-opened the channel is
* not this document failing the channel is open and pushing. Told anyway, the inbox
* observation dropped its entry and released it, which closed the WORKING subscription,
* printed "this inbox could not be watched" about an inbox that was, and left the identity
* with a silent queue. Clearing `establishing` was the same mistake one level down: it says
* "nothing is running" while the second attempt is, so the next joiner opens a third and a
* second `doc_subscribe` on a branch EVICTS the one before it.
*
* Superseded, it is therefore LOGGED and nothing else: the call that replaced it owns the
* outcome, and reports its own failure if it has one.
*/
function reportSetupFailure(nuri: Nuri, entry: DocFanOut, error: unknown, attempt: number): void {
console.error("[subscribe] doc_subscribe failed for", nuri, error);
if (!isCurrentAttempt(nuri, entry, attempt)) return;
entry.establishing = false;
for (const listener of [...entry.listeners]) {
const tell = listener.onSetupFailed;
if (tell === null || !entry.listeners.has(listener)) continue;
try {
tell(error);
} catch (thrown) {
console.error("[subscribe] onSetupFailed handler threw for", nuri, thrown);
}
}
}
/** Drop a fan-out: forget the remembered state and release the real subscription. */
function releaseFanOut(nuri: Nuri, entry: DocFanOut): void {
if (fanOuts.get(nuri) === entry) fanOuts.delete(nuri);
entry.lastState = null;
entry.establishing = false;
const unsub = entry.realUnsub;
entry.realUnsub = null;
if (!unsub) return;
try {
unsub();
} catch (error) {
console.error("[subscribe] unsubscribe failed for", nuri, error);
}
}
/**
* Open the one real subscription for `entry` and route its pushes to every listener.
* Errors are isolated to this document (they never reject a shared batch see
* {@link subscribeDocs}); a failed setup simply leaves the document silent.
*
* Everything here is gated on the ATTEMPT, never on the entry alone
* A superseded establish must not deliver, must not re-seed the barrier, and must not
* install its unsubscribe: {@link resubscribeDocs} re-opens the channel ON THE SAME entry,
* so entry identity says "this fan-out still stands" and says nothing about which call it is
* waiting on. Gated on identity only, a first attempt that SUCCEEDS after a second one has
* replaced it fans the PREVIOUS session's `State` out to every listener, re-seeds
* `lastState` with it the exact stale barrier {@link resubscribeDocs} cleared it to
* prevent and overwrites `realUnsub`, so the eventual teardown releases the dead channel
* and leaves the live one subscribed upstream.
*
* A superseded attempt is RELEASED, not dropped
* Dropping its unsubscribe would leak a live subscription. Upstream's `CancelFn` closes only
* its OWN `tx` and never touches `branch_subscriptions` (`engine/verifier/src/verifier.rs:479-484`),
* so releasing a loser cannot silence the winner and cross-session there IS no winner to
* silence: `resubscribeDocs` runs on a session change, the superseded call sits on the
* previous session's verifier, and nothing else will ever close it.
*
* Why the guard sits BEFORE the call, and what it does NOT settle
* What it settles is a fact: superseded while the session id was still resolving, this opens
* NO channel at all. That is the wider half of the window, and it closes it outright.
*
* What it leaves open is a BET, and it is written down here as one. For an attempt that DID
* place its call before being superseded, this code releases the channel that call opened
* which is only ever releasing the upstream LOSER if the broker SERVED the two calls in the
* order they were ISSUED. Issuing is all this module can order; service is the broker's, and
* nothing found upstream promises the two agree:
*
* - `LOCAL_BROKER` is behind an `async_std::sync::RwLock` held for the whole call
* (`sdk/rust/src/local_broker.rs:3057`), and that type IS `async_lock::RwLock`
* (async-std 1.13.2 `src/sync/mod.rs:181`, a re-export);
* - `RwLock::write()` takes the lock's internal `async_lock::Mutex` first (async-lock 3.4.1
* `src/rwlock/raw.rs:163-168`), and that mutex is documented as "eventual fairness"
* fair ON AVERAGE, not FIFO (`src/mutex.rs:22-24`). Its hot loop re-runs a bare
* `compare_exchange` on every poll, so a call arriving later can take the lock ahead of
* one already waiting;
* - and the anti-starvation fallback that would eventually force fairness is
* `#[cfg(all(feature = "std", not(target_family = "wasm")))]` (`src/mutex.rs:578-581`)
* compiled out on wasm, which is where this SDK runs.
*
* So the lock SERIALISES the calls; it does not ORDER them. Two attempts on the same branch
* of the same verifier is a reachable state the last listener leaving while an establish is
* in flight releases the fan-out, and the next joiner opens a fresh one against the same
* session so this is not a cross-session-only concern.
*
* What breaks the bet is therefore a service order inverted relative to the issue order, and
* this is what it costs when it happens: the superseded call inserts LAST, so it wins the
* branch and closes the current attempt's sender (`verifier.rs:361`); this code then releases
* its own channel, leaving the branch holding a closed sender that `push_app_response` drops
* at the first push (`verifier.rs:258-261`). Nothing rejects and nothing logs. The fan-out
* holds a `realUnsub` and believes itself subscribed, `establishing` stays true so no later
* joiner re-opens it, and the document is silently dead for the rest of the session
* `test/subscribe.test.ts` drives exactly that and pins it.
*
* Releasing is still what to do: DROPPING the superseded unsubscribe instead leaks a live
* subscription in the ordinary case, and buys nothing in the inverted one the winner's own
* callback is gated on an attempt that is no longer current, so it delivers to nobody either
* way. The bet is on which failure mode is reachable, never on releasing being free.
*/
async function establish(
nuri: Nuri,
entry: DocFanOut,
ng: { doc_subscribe?: unknown },
attempt: number,
): Promise<void> {
// No reactive primitive on the injected `ng` (the fake in the unit suite): there is
// nothing to call, so this document simply never pushes — the same documented no-op
// `openRepoUnguarded` takes for the same injection, and not a failure to report. A real
// `@ng-org/web` always exposes it.
if (typeof ng.doc_subscribe !== "function") return;
const doc_subscribe = ng.doc_subscribe as (...a: unknown[]) => unknown;
try {
const sid = await sessionId();
// Superseded (or torn down) while the session id was resolving: open NOTHING. Cheaper
// than opening a channel to close it, and it is what keeps a superseded attempt from
// ever reaching the broker AFTER the attempt that replaced it.
if (!isCurrentAttempt(nuri, entry, attempt)) return;
const unsub = (await doc_subscribe(nuri, sid, (resp: DocChange): void => {
// A push that belongs to a torn-down fan-out, or to an establish that has been
// superseded — either way the real unsubscribe has not taken effect yet, and neither
// this fan-out's listeners nor its barrier are this call's to touch any more.
if (!isCurrentAttempt(nuri, entry, attempt)) return;
const type = docChangeType(resp);
// Remember the barrier for whoever joins next; a later `State` replaces it.
if (type === "State") entry.lastState = { resp, type };
fanOut(nuri, entry, resp, type);
})) as (() => void) | undefined;
if (!isCurrentAttempt(nuri, entry, attempt)) {
// Everyone left, the fan-out was reset, or a later establish replaced this one —
// release the channel this call just opened, because nothing else holds it. (A
// released fan-out is out of the map, which is why the listener count is not asked
// about separately: the last one out already took the entry with it.)
if (typeof unsub === "function") unsub();
return;
}
entry.realUnsub = typeof unsub === "function" ? unsub : null;
} catch (error) {
reportSetupFailure(nuri, entry, error, attempt);
}
}
/**
* Drop every fan-out without notice what a page that re-`configure()`s does.
*
* A subscription belongs to the injected SDK that opened it, so when that is replaced
* (or removed) its live subscriptions are void: their pushes would come from a verifier
* nobody is talking to any more. Called by `configure`/`resetConfig`, and by nothing on
* the reactive path a listener is never dropped while its SDK is still there.
*/
// @provenance resetDocSubscriptions kind=invention level=none ref=none — per-session bookkeeping reset, like `resetOpenedRepos`; upstream a verifier owns its own subscriptions and nothing resets them from outside
export function resetDocSubscriptions(): void {
for (const [nuri, entry] of [...fanOuts]) {
entry.listeners.clear();
releaseFanOut(nuri, entry);
}
fanOuts.clear();
}
/**
* Re-open every live subscription against the CURRENT session, keeping its listeners.
*
* Called when the session id changes under the page (`open-repo.resetOpenedRepos`), and
* this is the one thing the fan-out owes that a per-caller subscription got for free. A new
* session is a new verifier with an empty `self.repos`, so the channel behind a fan-out is
* dead and its remembered `State` is the old verifier's, which would let a bootstrap open
* JOIN it, be handed that stale barrier at once, and read a repo the new session has never
* synced. Zero rows, "synced", no error: the silent staleness this module exists to remove.
*
* So the barrier is forgotten and the channel re-opened, rather than the listeners dropped:
* a `watchShape` that was following a document keeps following it across the change.
*/
// @provenance resubscribeDocs kind=invention level=none ref=none — per-session bookkeeping, like `resetOpenedRepos`; upstream a session's subscriptions die with it and nothing carries them over
export function resubscribeDocs(): void {
// Un-configured (a torn-down suite): there is no SDK to re-open anything against, and
// `resetDocSubscriptions` has already emptied this map on that path.
let ng: { doc_subscribe?: unknown };
try {
ng = getConfig().ng as { doc_subscribe?: unknown };
} catch {
return;
}
for (const [nuri, entry] of [...fanOuts]) {
const stale = entry.realUnsub;
entry.realUnsub = null;
// The barrier belonged to the session that is gone. Whoever joins next waits for a
// real one, exactly as they would have on a fresh page.
entry.lastState = null;
if (stale) {
try {
stale();
} catch (error) {
console.error("[subscribe] unsubscribe failed for", nuri, error);
}
}
beginEstablish(nuri, entry, ng);
}
}
/** /**
* Subscribe to ONE document. `onChange` fires on the initial state push and on * 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 * every subsequent change to that doc (local write OR broker-synced remote
@@ -106,7 +501,7 @@ async function sessionId(): Promise<string | number> {
* *
* Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`). * Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`).
*/ */
// @provenance subscribeDoc kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — two deliberate deltas: the unsubscribe is returned synchronously, and the callback gets a pre-extracted `type` // @provenance subscribeDoc kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — three deliberate deltas: the unsubscribe is returned synchronously, the callback gets a pre-extracted `type`, and N callers of ONE document share ONE upstream subscription because a branch holds a single sender and a second subscribe evicts the first
export function subscribeDoc( export function subscribeDoc(
nuriLike: NuriLike, nuriLike: NuriLike,
onChange: (r: DocChange, type: DocChangeType) => void, onChange: (r: DocChange, type: DocChangeType) => void,
@@ -118,58 +513,86 @@ export function subscribeDoc(
return subscribeDocUnguarded(nuri, onChange); return subscribeDocUnguarded(nuri, onChange);
} }
/**
* {@link subscribeDoc}, for a caller that must be TOLD when the subscription could not be
* opened. Same guard, same fan-out; the only difference is that a setup failure reaches
* `onSetupFailed` instead of only the log.
*
* Internal, and deliberately not the published shape: upstream `doc_subscribe` is async and
* rejects, so a failure has a caller to reach; ours returns synchronously, and the published
* signature has no room for it (`.project/concepts/app-contract/polyfill-surface/`). An
* application uses a subscription as a change SIGNAL and has nothing to do with the answer,
* so it keeps the two-argument call. The inbox observation does have something to do with it
* an inbox it believes it is watching and is not leaves every deposit unapplied for the
* session so it asks.
*/
// @provenance subscribeDocReportingSetupFailure kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — restores to an internal caller what upstream's async `doc_subscribe` gives every caller: the setup failure. One delta remains, the synchronous unsubscribe
export function subscribeDocReportingSetupFailure(
nuriLike: NuriLike,
onChange: (r: DocChange, type: DocChangeType) => void,
onSetupFailed: (error: unknown) => void,
): Unsubscribe {
const nuri = toNuri(nuriLike, "subscribeDoc");
assertMayReach(nuri, "subscribeDoc");
return subscribeDocUnguarded(nuri, onChange, onSetupFailed);
}
/** /**
* The unguarded core. Exported for ONE importer `shared-wallet/physical.ts`, which * 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 * 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. * re-exported by either entry point; the `Unguarded` suffix is the warning.
*/ */
// @provenance subscribeDocUnguarded kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — the same call without the reach guard; internal, never published // @provenance subscribeDocUnguarded kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — the same call without the reach guard, and the place the one-subscription-per-document fan-out is kept; internal, never published
export function subscribeDocUnguarded( export function subscribeDocUnguarded(
nuri: Nuri, nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void, onChange: (r: DocChange, type: DocChangeType) => void,
onSetupFailed?: (error: unknown) => void,
): Unsubscribe { ): Unsubscribe {
// Resolved here, synchronously, so calling this before `configure()` still throws at
// the call rather than inside a background task nobody awaits.
const { ng } = getConfig(); const { ng } = getConfig();
let stopped = false;
let realUnsub: (() => void) | null = null;
const cb = (resp: DocChange): void => { let entry = fanOuts.get(nuri);
if (stopped) return; if (!entry) {
try { entry = {
onChange(resp, docChangeType(resp)); listeners: new Set(),
} catch (error) { realUnsub: null,
console.error("[subscribe] onChange handler threw for", nuri, error); establishing: false,
} attempt: 0,
}; lastState: null,
};
fanOuts.set(nuri, entry);
}
const joined = entry;
// THIS call's subscription — see {@link DocListener} for why it is not the function.
const listener: DocListener = { onChange, onSetupFailed: onSetupFailed ?? null };
joined.listeners.add(listener);
// Kick off the async subscription. Errors are isolated to this doc (they never // Joining a document somebody else already opened: hand this listener the `State` its
// reject a shared batch — see subscribeDocs). If setup fails, this doc simply // own `doc_subscribe` would have pushed it. Asynchronously, like the real push, so a
// never fires; the caller's unsubscribe stays a safe no-op. // caller that sets itself up after this call still sees it.
void (async () => { if (joined.lastState) {
try { const { resp, type } = joined.lastState;
const sid = await sessionId(); queueMicrotask(() => {
const unsub = (await ng.doc_subscribe(nuri, sid, cb)) as (() => void) | undefined; if (fanOuts.get(nuri) === joined && joined.listeners.has(listener)) {
if (stopped) { deliver(nuri, listener, resp, type);
// 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);
}
})();
if (!joined.establishing) {
beginEstablish(nuri, joined, ng as { doc_subscribe?: unknown });
}
let stopped = false;
return () => { return () => {
if (stopped) return; if (stopped) return;
stopped = true; stopped = true;
if (realUnsub) { joined.listeners.delete(listener);
try { // The LAST one out releases the real subscription — while anybody is still
realUnsub(); // listening, tearing it down would silence them (and re-opening it later is not
} catch (error) { // free: a second `doc_subscribe` evicts whoever else has the branch by then).
console.error("[subscribe] unsubscribe failed for", nuri, error); if (joined.listeners.size === 0) releaseFanOut(nuri, joined);
}
realUnsub = null;
}
}; };
} }
@@ -0,0 +1,724 @@
/**
* While I am connected, what lands in my inboxes is applied no reload, nobody asked.
*
* The regime, and the one this replaces
* Upstream, applying an inbox is what a SESSION does: a sealed message reaches the
* recipient's own verifier as it arrives and is applied inline, and only the backlog handed
* over at connection is marked apart (`from_queue`). This package had emulated the backlog
* and nothing else `inbox.processInbox` was called from exactly one place, at connection
* so a share deposited while its recipient sat connected in front of the application
* converged when that person next RELOADED the page. Its stand-in was a twenty-second timer
* in the DEPOSITOR's session, which does nothing if that tab closes and tells the connected
* owner nothing either way.
*
* How a deposit gets here without a reconnection
* Bob makes his deposit through the published surface, under his own identity, naming a
* PERSON (`inbox.share(doc, "alice")`) he is handed no address, and the test hands him
* none. What the test then does is what a broker does: it HOLDS what he wrote and delivers
* it to this page later, once Alice is the one connected (`wallet-fake._deliver`). The
* quads delivered are the ones the library itself produced under Bob; nothing is composed
* by hand. That is the cross-session case verified against the real broker
* (`e2e/reactivity-doc-subscribe.ts`): a second session's write reaches the first session's
* subscription as a `Patch`.
*
* A deposit by the owner is a REAL case, not a shortcut
* Two tests below have Alice deposit into her own document's inbox while she is connected.
* That is not a stand-in for a stranger: it is the case a consuming application reported,
* and a same-session write is pushed to that session's own subscription verified against
* the real broker the same day (`Patch@69ms` on the writer's own `sparqlUpdate`).
*/
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
import { inbox as inboxSurface, storeRegistry } from "../src/index";
import { getCaps, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetRegistryCache, resolveAccount, userInbox } from "../src/shared-wallet/account-registry";
import { observationSettled } from "../src/emulated-verifier/inbox-observer";
import { cancelScheduledInboxProcessing } from "../src/emulated-verifier/inbox-processor";
import { connectedUser } from "../src/emulated-verifier/connect";
import { enumerateMyInboxes, myInboxes } from "../src/emulated-verifier/branch-registers";
import { setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
import { bootPage, forgetEverything, signIn, type FakeWallet, type Quad } from "./wallet-fake";
import type { Nuri } from "../src/model/types";
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** The reactive fake: a repo answers nothing until subscribed, and a commit pushes. */
const COLD = { unsyncedUntilSubscribed: true } as const;
let quads: Quad[];
let fake: FakeWallet;
function boot(): void {
quads = [];
fake = bootPage(quads, COLD);
}
/**
* Let the page's pushes land, and everything they set off finish.
*
* Not a sleep with a number on it: each round YIELDS so the fake can deliver the push it
* queued (on a macrotask, as the real RPC does), then WAITS on the work that push actually
* started (`observationSettled` the enumerations in flight and the drains behind them).
* Several rounds because applying one push can queue the next: a Link filed on the private
* store pushes to the register subscription, which re-enumerates.
*/
async function converge(): Promise<void> {
for (let round = 0; round < 5; round += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
await observationSettled();
}
}
/**
* Take what has been written into `graph` OUT of this page's wallet and hand it back the
* broker holding a commit it has not delivered yet. Delivering it later
* (`fake._deliver`) is the only way a deposit made in another session can arrive here
* while Alice, and not its author, is the connected identity.
*/
function heldByTheBroker(graph: Nuri): Quad[] {
const held: Quad[] = [];
for (let i = quads.length - 1; i >= 0; i -= 1) {
if (quads[i]!.g === graph) held.unshift(...quads.splice(i, 1));
}
return held;
}
/**
* The documents a user has durably been GIVEN the emulated `AddLink` records on their
* User branch, named by the document each one opens. The record holds a `ReadCap` (the
* reference plus its secret); the tests are about WHICH document arrived, so the secret is
* dropped here rather than pinned to the stand-in value the emulation currently mints.
*/
async function documentsGivenTo(id: string): Promise<string[]> {
const store = (await resolveAccount(id))?.docPrivate;
if (!store) return [];
return quads
.filter((q) => q.g === store && q.p === `${SHIM}:link`)
.map((q) => q.o.split(":r:")[0]!);
}
/** How many times this inbox's deposits have been read — i.e. how often it was processed. */
function depositReadsOf(inbox: Nuri): number {
return fake.sparql_query.mock.calls.filter(
(c) => c[3] === inbox && String(c[1]).includes(`${INBOX}:payload`),
).length;
}
/**
* The doc-shim, read off the WALLET the document the account records live in, and the one
* `userInbox` asks "which inbox does this user own". Found by the record it carries rather
* than by a minting order, so it is the shim because of what is written in it.
*/
function theDocShim(): Nuri {
const record = quads.find((q) => q.p === `${SHIM}:id`);
if (!record) throw new Error("no account record was written, so there is no doc-shim to name");
return record.g as Nuri;
}
/** The inbox recorded for a note, read off the WALLET — no application can ask the package. */
function inboxOnTheNote(note: Nuri): Nuri {
const record = quads.find((q) => q.p === `${SHIM}:inboxCap` && q.o.startsWith(note + " "));
if (!record) throw new Error("no AddInboxCap record was written for the note");
return record.o.split(" ")[1] as Nuri;
}
/** Capture what `console.error` is told while `body` runs. */
async function whileWatchingTheLog(body: () => Promise<void>): Promise<string[]> {
const lines: string[] = [];
const real = console.error;
console.error = ((...args: unknown[]) => {
lines.push(args.map((a) => String(a)).join(" "));
}) as typeof console.error;
try {
await body();
} finally {
console.error = real;
}
return lines;
}
/**
* Bob makes a document and shares it with Alice by NAME, then the broker holds his deposit
* back. Returns the document he shared and the quads still in transit.
*
* Alice has to have been here before, and to have DONE something: `inbox.share` refuses a
* recipient nobody has ever been, and connecting does not provision an identity acquires
* its account the first time it creates anything. That is the model and not a fixture
* detail: upstream a deposit is sealed to an inbox key somebody had to hand you, so you
* cannot address a name you invented. Her first visit here is not the one under test; every
* test below connects her again afterwards, and the deposit arrives strictly after that.
*/
async function bobSharesWithAlice(): Promise<{ doc: Nuri; inTransit: Quad[] }> {
await signIn("alice");
await storeRegistry.createEntityDoc("protected"); // her first visit — now she exists
await signIn("bob");
const doc = await storeRegistry.createEntityDoc("protected");
await inboxSurface.share(doc, "alice");
// Test-side inspection only — the address is read off the wallet to say WHICH document
// is in transit, and is never handed to an actor.
const aliceInbox = await userInbox("alice", "protected");
return { doc, inTransit: heldByTheBroker(aliceInbox) };
}
beforeEach(() => {
forgetEverything();
boot();
});
afterAll(() => {
cancelScheduledInboxProcessing();
forgetEverything();
});
describe("a deposit that arrives while its recipient is connected", () => {
test("is applied, without anyone reconnecting", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
await signIn("alice");
// The honest baseline: as far as this page is concerned Alice's inbox is empty, so
// connecting applied nothing. Whatever the next lines prove, they do not prove it twice.
expect(getCaps().capForHolder("alice", doc)).toBeUndefined();
fake._deliver(inTransit);
await converge();
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
expect(await documentsGivenTo("alice")).toContain(doc);
});
test("is applied DURABLY — the same as if she had reconnected to find it", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
await signIn("alice");
fake._deliver(inTransit);
await converge();
// A cap held only in memory is a cap lost at the next reload, and the whole point of
// applying rather than merely reading is that it survives.
expect(await documentsGivenTo("alice")).toEqual([doc]);
});
test("does not need the depositor's tab to stay open — no timer is involved", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
await signIn("alice");
// Whatever the deposit armed in Bob's session is dropped here, exactly as a closed tab
// drops it. What follows is the connected owner's own doing, or it does not happen.
cancelScheduledInboxProcessing();
fake._deliver(inTransit);
await converge();
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
});
});
describe("an inbox opened in the middle of a session", () => {
test("is watched too — what lands in it is processed without reconnecting", async () => {
await signIn("alice");
// The note and its inbox come into existence AFTER connecting, so nothing the
// connection enumerated could have included them.
const note = await storeRegistry.createEntityDoc("public");
await storeRegistry.openDocumentInbox(note);
await converge();
const inbox = inboxOnTheNote(note);
const readsBefore = depositReadsOf(inbox);
// A message is left on the NOTE — the depositor names the document, never an address.
await inboxSurface.postToDocument(note, { payload: { text: "j'apporte le café" }, from: null, ts: 1 });
await converge();
// Processing an inbox IS reading its queue and applying what is this library's to
// apply; for a document inbox nothing is (a Link only ever reaches a person's inbox),
// so the read is the whole of the consequence — and it can come from nowhere else:
// depositing reads the shim, not the queue, and the deferred window has not closed.
expect(depositReadsOf(inbox)).toBeGreaterThan(readsBefore);
});
test("the messages left on it are readable, on the document its owner named", async () => {
await signIn("alice");
const note = await storeRegistry.createEntityDoc("public");
await storeRegistry.openDocumentInbox(note);
await converge();
await inboxSurface.postToDocument(note, { payload: { text: "à demain" }, from: null, ts: 2 });
await converge();
const left = await inboxSurface.readForDocument(note);
expect(left.map((d) => (d.payload as { text: string }).text)).toEqual(["à demain"]);
});
});
describe("switching identity", () => {
test("stops the observation — the previous identity's inbox is no longer applied", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
await signIn("alice");
// Alice steps away and Bob takes the page. A session belongs to one person.
await signIn("bob");
fake._deliver(inTransit);
await converge();
// Nothing was applied for Alice — she is not connected, and her queue keeps its
// deposit for the next time she is.
expect(getCaps().capForHolder("alice", doc)).toBeUndefined();
expect(await documentsGivenTo("alice")).toEqual([]);
// …and emphatically nothing was filed for Bob either: work started for one holder must
// never file for another. (Bob's own cap on the document is not evidence of that — he
// made it. What would be evidence is a Link, and there is none.)
expect(await documentsGivenTo("bob")).toEqual([]);
});
test("and disconnecting stops it too", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
await signIn("alice");
setCurrentUser(null); // no identity is acting — anonymous holds nothing and owns no inbox
fake._deliver(inTransit);
await converge();
expect(await documentsGivenTo("alice")).toEqual([]);
});
test("MID-DRAIN files nothing for the identity that arrives", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
// Test-side inspection only: the address is used to recognise the read in flight, and
// is never handed to an actor.
const aliceInbox = await userInbox("alice", "protected");
await signIn("alice");
// The broker takes its time over the deposits read, and the page switches user INSIDE
// that window — a person clicking "sign in as Bob" while a push is being applied. The
// ownership guard has already passed by then; it ran at the start of the read.
const answering = fake.sparql_query.getMockImplementation()!;
let switched = false;
fake.sparql_query.mockImplementation(async (...args: unknown[]) => {
const answer = await answering(...args);
if (!switched && args[3] === aliceInbox && String(args[1]).includes(`${INBOX}:payload`)) {
switched = true;
setCurrentUser("bob");
}
return answer;
});
fake._deliver(inTransit);
await converge();
// Bob was GIVEN nothing. (His own cap on the document is not evidence either way — he
// made it; what would be evidence is a Link, and there must be none.) Filed here, the
// cap addressed to Alice becomes a capability Bob holds at his next sign-in, and its
// real recipient is left with nothing at all.
expect(await documentsGivenTo("bob")).toEqual([]);
// …and Alice has lost nothing: an inbox is not consumed by a drain that abandoned it,
// so what was deposited for her is still there when she is the one connected.
await signIn("alice");
await converge();
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
});
test("and Alice coming back finds the deposit still there to apply", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
await signIn("alice");
await signIn("bob");
fake._deliver(inTransit);
await converge();
// An inbox is not consumed by being ignored: connecting again drains what was left.
await signIn("alice");
await converge();
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
});
});
describe("a deposit that cannot be applied", () => {
test("is reported, and stops neither the observation nor the next deposit", async () => {
const first = await bobSharesWithAlice();
const second = await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
await signIn("alice");
// The broker cannot answer for Alice's inbox — the deposit lands, applying it does not.
fake._failReadsOn.add(aliceInbox);
const reported = await whileWatchingTheLog(async () => {
fake._deliver(first.inTransit);
await converge();
});
expect(reported.filter((l) => /could not apply what is in this inbox/.test(l)).length)
.toBeGreaterThan(0);
// Prefixed by the connected identity, like every other polyfill-layer line.
expect(reported.find((l) => /could not apply what is in this inbox/.test(l)))
.toContain("[alice][polyfill]");
expect(getCaps().capForHolder("alice", first.doc)).toBeUndefined();
// The broker recovers. The observation is still running — one unapplicable item denies
// nothing — and the deposit that failed was never consumed, so both land now.
fake._failReadsOn.delete(aliceInbox);
fake._deliver(second.inTransit);
await converge();
expect(getCaps().capForHolder("alice", second.doc)).toBeDefined();
expect(getCaps().capForHolder("alice", first.doc)).toBeDefined();
});
test("because its inbox could not be WATCHED is reported, and the next signal re-attempts it", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
// A repo that never pushes its initial `State` is what a refused subscription looks like
// from the bootstrap open's side, and it waits out its bounded fallback before giving up.
// Eight seconds of it, twice, is the production wait and not a unit test's.
setOpenTimeoutForTests(20);
// The broker will not open a channel on Alice's inbox. Everything else about her session
// works — which is the point: the only symptom of a watch that was never established is
// that shares stop arriving.
const opening = fake.doc_subscribe!.getMockImplementation()!;
let refusing = true;
fake.doc_subscribe!.mockImplementation(async (...args: unknown[]) => {
if (refusing && args[0] === aliceInbox) throw new Error(`RepoNotFound: ${String(args[0])}`);
return opening(...args);
});
const reported = await whileWatchingTheLog(async () => {
await signIn("alice");
await converge();
});
// Said out loud, in this package's own words and under the connected identity — not left
// as the absence of a push.
expect(reported.filter((l) => /could not be watched/.test(l)).length).toBeGreaterThan(0);
expect(reported.find((l) => /could not be watched/.test(l))).toContain("[alice][polyfill]");
// Nothing is watching, so the deposit that lands now cannot be applied — and is not.
fake._deliver(inTransit);
await converge();
expect(getCaps().capForHolder("alice", doc)).toBeUndefined();
// The broker recovers and Alice does something ordinary — which is a SIGNAL, not a
// coincidence: creating anything files caps, and the held-caps channel re-enters the
// enumeration. That is the whole of the repair, and it is deliberately the whole of it:
// a re-attempt fired from the rejection itself asks the broker that has just refused, in
// the same turn, with nothing having changed. The inbox that could not be opened was not
// written off for the session — the failed entry is forgotten, so this enumeration
// subscribes again as if it had never been attempted, and the initial push of that new
// subscription finds the deposit still waiting.
refusing = false;
await storeRegistry.createEntityDoc("protected");
await converge();
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
expect(await documentsGivenTo("alice")).toContain(doc);
});
test("never rejects into the application — nobody asked for this work", async () => {
const { inTransit } = await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
await signIn("alice");
fake._failReadsOn.add(aliceInbox);
const unhandled: unknown[] = [];
const onUnhandled = (e: unknown): void => {
unhandled.push(e);
};
process.on("unhandledRejection", onUnhandled);
try {
await whileWatchingTheLog(async () => {
fake._deliver(inTransit);
await converge();
});
} finally {
process.off("unhandledRejection", onUnhandled);
fake._failReadsOn.delete(aliceInbox);
}
expect(unhandled).toEqual([]);
});
});
describe("a connection whose own work FAILED", () => {
/**
* Aimed at the PRIVATE store, and that is the whole test.
*
* It used to fail reads on `docPublic`, which makes the restore reject and leaves the
* enumeration of the inboxes untouched so it proved that watching survives a failure that
* was never going to threaten it. The private store is the one the connection restores from
* AND the register that says which inboxes exist, so failing it is the case that actually
* decides: listing the inboxes reads it, and one throw used to discard the two user inboxes
* that had ALREADY been listed before it. The identity was then connected with nothing
* watched at all, and a person who only reads who never creates anything, so never fires
* a signal had no way back for the rest of the session.
*/
test("still leaves the identity watched — including when the failing store is the register", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
// The broker cannot answer for Alice's private store, so the RESTORE fails and the
// connection rejects. She is connected regardless: `setCurrentUser` is synchronous and
// took effect before any of this ran, and nothing signs her back out.
const store = (await resolveAccount("alice"))?.docPrivate;
if (store === undefined) throw new Error("the fixture did not give Alice a private store");
fake._failReadsOn.add(store);
setCurrentUser("alice");
let rejected = false;
const reported = await whileWatchingTheLog(async () => {
try {
await connectedUser();
} catch {
rejected = true;
}
await converge();
});
// The caller is still TOLD, and that rule is not what changes here: failing to reach the
// registers rejects, exactly as before.
expect(rejected).toBe(true);
// …and so is the log, about the half of the list that could not be read. A short list
// that says nothing is a failure wearing the face of an absence, which is the one thing
// this package will not do — the inboxes it names are watched, the ones it does not are
// owed a next enumeration, and both facts have to be legible.
expect(reported.filter((l) => /could not all be listed/.test(l)).length).toBeGreaterThan(0);
expect(reported.find((l) => /could not all be listed/.test(l))).toContain("[alice][polyfill]");
// The hiccup passes. Alice never touched the page — no sign-in, no document created,
// nothing that could stand in for the watching she is owed.
fake._failReadsOn.delete(store);
fake._deliver(inTransit);
await converge();
// What she is owed is not the restore she lost — it is that a deposit made while she sits
// there converges. Her own two inboxes are where a share addressed to her by NAME lands,
// and they were listed before the register threw; watching them is what makes this
// session behave like every other one.
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
expect(await documentsGivenTo("alice")).toContain(doc);
});
});
/**
* The list the observation works from, asked directly.
*
* It is built from two independent registers the account record, which names the user's own
* two store inboxes, and the User branch, which names one per document it opened an inbox on
* and the two fail independently. What a caller may do with a half-read list depends
* entirely on being TOLD it is half-read, so both halves of that answer are pinned here
* rather than only through the behaviour above.
*/
describe("listing the inboxes when one of the two registers cannot be read", () => {
test("comes back as what WAS listed plus the failure — never as a short list", async () => {
await signIn("alice");
// A document inbox: a record on the User branch of the private store, which is the
// register the broker is about to stop answering for.
const note = await storeRegistry.createEntityDoc("public");
await storeRegistry.openDocumentInbox(note);
await converge();
const store = (await resolveAccount("alice"))?.docPrivate;
if (store === undefined) throw new Error("the fixture did not give Alice a private store");
const whole = await enumerateMyInboxes();
expect(whole.incomplete).toBeNull();
expect(whole.inboxes).toContain(inboxOnTheNote(note));
fake._failReadsOn.add(store);
const partial = await enumerateMyInboxes();
fake._failReadsOn.delete(store);
// Her own two inboxes were in hand before the second register threw. Discarding them
// with it is what left an identity connected with ZERO inboxes watched.
expect(partial.inboxes).toEqual([
await userInbox("alice", "public"),
await userInbox("alice", "protected"),
]);
// …and the shortfall travels WITH them: an answer that came back short while looking
// complete is a failure disguised as an absence, which is the fault this package keeps
// closing. The document inbox is missing from the list and that fact is legible.
expect(partial.incomplete).not.toBeNull();
expect(String(partial.incomplete?.error)).toContain("RepoNotFound");
expect(partial.inboxes).not.toContain(inboxOnTheNote(note));
});
/**
* The two shortfalls are not degrees of one condition they are two, and the worse one is
* the one this suite exists for. The account record names the user's OWN two inboxes, which
* is where a share addressed to a PERSON lands, and it is also what the other half reads
* THROUGH; losing it means nothing at all is watched. Losing the User branch means those two
* are watched and only the per-document inboxes are missing. A caller handed `error` alone
* cannot tell which it is holding.
*/
test("names WHICH register fell short, and the account record is the worse one", async () => {
await bobSharesWithAlice();
await converge(); // let the depositor's own session finish before the broker breaks
const shim = theDocShim();
// Another session, so nothing is answered from a warm cache. Her record is read first —
// that is what connecting does — and the broker goes away between that read and the one
// that names her inboxes. Both live in the doc-shim, so this is one hiccup, mid-list.
resetRegistryCache();
setCurrentUser("alice");
expect(await resolveAccount("alice")).not.toBeNull();
fake._failReadsOn.add(shim);
const listed = await enumerateMyInboxes();
fake._failReadsOn.delete(shim);
expect(listed.incomplete?.register).toBe("account-record");
expect(String(listed.incomplete?.error)).toContain("RepoNotFound");
// Nothing came back: not one inbox of hers can be named, so not one can be watched. That
// is what makes this shortfall a different report from the other one and not a louder
// copy of it — there, her own two are in the list.
expect(listed.inboxes).toEqual([]);
await converge();
});
test("still REJECTS for the caller that cannot use a partial list", async () => {
await signIn("alice");
await storeRegistry.openDocumentInbox(await storeRegistry.createEntityDoc("public"));
await converge();
const store = (await resolveAccount("alice"))?.docPrivate;
if (store === undefined) throw new Error("the fixture did not give Alice a private store");
fake._failReadsOn.add(store);
try {
// `connect.connectedUser` drains this list, and a queue missing from it is a delivered
// share silently never applied. Not knowing which queues exist is the session failing
// to establish, and that contract is not what the partial answer above relaxes.
await expect(myInboxes()).rejects.toThrow(/RepoNotFound/);
} finally {
fake._failReadsOn.delete(store);
}
});
});
/**
* Bob shares TWO documents with Alice, each held back separately, so a test has two
* independent arrivals to drive the observation with rather than one.
*/
async function bobSharesTwiceWithAlice(): Promise<Quad[][]> {
await signIn("alice");
await storeRegistry.createEntityDoc("protected"); // her first visit — now she exists
await signIn("bob");
const aliceInbox = await userInbox("alice", "protected");
const batches: Quad[][] = [];
for (let round = 0; round < 2; round += 1) {
const doc = await storeRegistry.createEntityDoc("protected");
await inboxSurface.share(doc, "alice");
batches.push(heldByTheBroker(aliceInbox));
}
return batches;
}
describe("a register that stays unreadable", () => {
/**
* The retry that would repair the shortfall is also what re-reads it.
*
* Every signal the observation listens to runs a full cycle, and each cycle re-discovers a
* condition that never stopped holding so ONE deposit put three copies of the same line
* in the log. A report that repeats on its own is not more information; it is what buries
* the report that means something, and it invites reading a persistent fault as a
* recurring one.
*/
test("is reported ONCE, not once per enumeration", async () => {
const { inTransit } = await bobSharesWithAlice();
const store = (await resolveAccount("alice"))?.docPrivate;
if (store === undefined) throw new Error("the fixture did not give Alice a private store");
fake._failReadsOn.add(store);
const reported = await whileWatchingTheLog(async () => {
try {
await signIn("alice");
} catch {
// The restore rejects on the unreadable store; she is connected regardless.
}
await converge();
fake._deliver(inTransit);
await converge();
await converge();
});
fake._failReadsOn.delete(store);
expect(reported.filter((l) => /could not all be listed/.test(l))).toHaveLength(1);
});
/**
* A shortfall is deduplicated per REGISTER, because there are two of them and they are not
* the same news.
*
* The account record failing leaves this identity with NOTHING watched its own two
* inboxes included, which is where a share addressed to a PERSON lands. The User branch
* failing leaves those two watched and only the per-document inboxes missing. Remembering
* merely that "something was already said" made the second condition arrive in silence: the
* log went on describing a state that had stopped being the one the identity was in.
*/
test("a shortfall on the OTHER register is reported, not swallowed as already said", async () => {
await bobSharesWithAlice();
await converge();
const shim = theDocShim();
const store = (await resolveAccount("alice"))?.docPrivate;
if (store === undefined) throw new Error("the fixture did not give Alice a private store");
const reported = await whileWatchingTheLog(async () => {
// Another session. Connecting reads her account record first, and the doc-shim goes
// away right after — so her own two inboxes cannot even be NAMED, and the document
// half is never reached. `signIn` taken in its two steps, which is all it is, so the
// hiccup can land where a hiccup lands: in the middle.
resetRegistryCache();
setCurrentUser("alice");
expect(await resolveAccount("alice")).not.toBeNull();
fake._failReadsOn.add(shim);
try {
await connectedUser();
} catch {
// The restore rejects on the unreadable shim; she is connected regardless.
}
await converge();
// The doc-shim answers again and the private store stops instead: her own two inboxes
// are watched now, and only the ones opened on documents are missing. A milder
// condition, a different one — and the application creating something is what makes
// the observation look again (`CapRegistry.onChange`).
fake._failReadsOn.delete(shim);
fake._failReadsOn.add(store);
await storeRegistry.createEntityDoc("protected");
await converge();
});
fake._failReadsOn.delete(store);
const shortfalls = reported.filter((l) => /could not all be listed/.test(l));
expect(shortfalls).toHaveLength(2);
expect(shortfalls.filter((l) => /the account record/.test(l))).toHaveLength(1);
expect(shortfalls.filter((l) => /inboxes opened on documents/.test(l))).toHaveLength(1);
});
/**
* Once per OCCURRENCE, and a second occurrence is a real one.
*
* Silencing the repeat by remembering "already said" and never forgetting it would trade a
* noisy log for a mute one: the register breaking again, after a spell of working, is news
* and it is exactly the case the retry exists for.
*/
test("is reported AGAIN once the list has come back WHOLE in between", async () => {
const [first, second] = await bobSharesTwiceWithAlice();
const store = (await resolveAccount("alice"))?.docPrivate;
if (store === undefined) throw new Error("the fixture did not give Alice a private store");
fake._failReadsOn.add(store);
const reported = await whileWatchingTheLog(async () => {
try {
await signIn("alice");
} catch {
// As above: the restore rejects, the connection stands.
}
await converge(); // the shortfall, first occurrence
fake._failReadsOn.delete(store); // the register is readable again…
fake._deliver(first ?? []); // …and an arrival makes the observation re-enumerate
await converge(); // the list comes back WHOLE
fake._failReadsOn.add(store); // and then it breaks a second time
fake._deliver(second ?? []);
await converge(); // the shortfall, second occurrence
});
fake._failReadsOn.delete(store);
expect(reported.filter((l) => /could not all be listed/.test(l))).toHaveLength(2);
});
});
@@ -434,7 +434,10 @@ test("a drain that fails says so in the package's log, and rejects into nobody",
console.error = realError; console.error = realError;
} }
const reported = errors.filter((line) => /deferred inbox processing failed/.test(line)); // The wording is the SHARED reporter's since 2026-08-17 (`emulated-verifier/inbox-drain.ts`):
// the timer is no longer the only thing that drains an inbox — the continuous observation
// does too, through the same queue — so the line names the act and not the schedule.
const reported = errors.filter((line) => /could not apply what is in this inbox/.test(line));
expect(reported.length).toBe(1); expect(reported.length).toBe(1);
// Prefixed by the CONNECTED identity, like every other polyfill-layer line — which is // Prefixed by the CONNECTED identity, like every other polyfill-layer line — which is
// what makes a drain running under someone else's session legible in a live trace. // what makes a drain running under someone else's session legible in a live trace.
+13 -4
View File
@@ -145,21 +145,30 @@ test("a public store serves every asker, not only the first", async () => {
test("asked once per document: the outcome is memoised, in both directions", async () => { test("asked once per document: the outcome is memoised, in both directions", async () => {
const { sparql_query } = inject(); const { sparql_query } = inject();
// Counted PER DOCUMENT (the read is anchored on the one being asked about), not over
// every read the process makes. `setCurrentUser` fires the connection work, which reads
// on its own account in the background — none of it about this document — so a total
// makes the memo's arithmetic depend on whatever else happens to be in flight. This is
// the same assertion, about the read it is actually about.
const asks = (nuri: string): number =>
sparql_query.mock.calls.filter((c) => c[3] === nuri).length;
setCurrentUser("alice"); setCurrentUser("alice");
await aliceExposesHerNote(); await aliceExposesHerNote();
armEmulation(); armEmulation();
setCurrentUser("bob"); setCurrentUser("bob");
await fetchReadCap(PUB); await fetchReadCap(PUB);
const afterHit = sparql_query.mock.calls.length; const afterHit = asks(PUB);
await fetchReadCap(PUB); // held now → not even the memo is consulted await fetchReadCap(PUB); // held now → not even the memo is consulted
expect(sparql_query.mock.calls.length).toBe(afterHit); expect(asks(PUB)).toBe(afterHit);
const absent = "did:ng:o:nothing-here" as Nuri; const absent = "did:ng:o:nothing-here" as Nuri;
await fetchReadCap(absent); await fetchReadCap(absent);
const afterMiss = sparql_query.mock.calls.length; const afterMiss = asks(absent);
expect(afterMiss).toBe(1); // it WAS asked once — a memo over nothing proves nothing
await fetchReadCap(absent); // a miss is remembered too await fetchReadCap(absent); // a miss is remembered too
expect(sparql_query.mock.calls.length).toBe(afterMiss); expect(asks(absent)).toBe(afterMiss);
}); });
test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => { test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => {
+590 -21
View File
@@ -1,5 +1,12 @@
import { test, expect, mock, afterAll } from "bun:test"; import { test, expect, mock, afterAll } from "bun:test";
import { subscribeDoc, subscribeDocs } from "../src/surface/subscribe"; import {
docChangeType,
resubscribeDocs,
subscribeDoc,
subscribeDocReportingSetupFailure,
subscribeDocs,
type Unsubscribe,
} from "../src/surface/subscribe";
import { configure } from "../src/index"; import { configure } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap"; import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap"; import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
@@ -17,34 +24,154 @@ afterAll(() => {
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" }; const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
/** /**
* A fake reactive `ng`: `doc_subscribe(nuri, sid, cb)` registers `cb` for `nuri`, * A fake reactive `ng`: `doc_subscribe(nuri, sid, cb)` takes over `nuri`, fires `cb` once
* fires it once (initial State push), and returns an unsubscribe. `push(nuri)` * (initial State push), and returns an unsubscribe. `push(nuri)` drives a later change to
* drives a later change to that doc's subscribers. A per-doc `failFor` set makes * that doc's subscriber. A per-doc `failFor` set makes `doc_subscribe` reject for chosen
* `doc_subscribe` reject for chosen NURIs (a not-yet-synced doc). * NURIs (a not-yet-synced doc).
*
* ONE subscriber per document, and a second one EVICTS it
* Not a simplification it is what the broker does. A branch holds a single sender
* (`branch_subscriptions: HashMap<BranchId, Sender<AppResponse>>`) and
* `create_branch_subscription` closes whatever it displaces, silently: the evicted
* unsubscribe still returns cleanly and nothing anywhere errors. Confirmed against the real
* broker on 2026-08-17 with two subscriptions on one document, a write fired the second
* callback and the first, which had been firing moments earlier, went quiet for good.
*
* A Set of callbacks here would model a world where every subscriber coexists. It is
* exactly the assumption that cost this package a view that never re-read and an inbox that
* never notified, and a fake that holds it cannot fail on either.
*/ */
function makeFakeNg(failFor: Set<string> = new Set()) { function makeFakeNg(
const subs = new Map<string, Set<(r: unknown) => void>>(); failFor: Set<string> = new Set(),
hangFor: Set<string> = new Set(),
queueFor: Set<string> = new Set(),
) {
const subs = new Map<string, (r: unknown) => void>();
// A call the broker has neither answered nor refused yet, so a LATER call can overtake it
// and this one can settle afterwards. Held on an object rather than in a `let` so its
// type survives being written from one closure and read from another.
const hung: {
reject: ((error: unknown) => void) | null;
resolve: ((unsub: () => void) => void) | null;
cb: ((r: unknown) => void) | null;
live: boolean;
} = { reject: null, resolve: null, cb: null, live: false };
/**
* The broker's side of ONE `doc_subscribe`, at the moment it is SERVED taking the branch
* over for `cb` and handing back the cancel.
*
* Apart from the call because ACCEPTING a call and SERVING it are two moments, and the
* broker does not promise they happen in the same order (see {@link queued}). Everything
* that decides who holds the branch is here, so serving is one function call and a test can
* make it happen when it likes.
*/
const serveBranch = (nuri: string, cb: (r: unknown) => void): (() => void) => {
subs.set(nuri, cb); // whoever held this branch is dropped, without a word
// Initial State push, delivered async (as the real RPC does) — and only while this
// callback still holds the branch.
queueMicrotask(() => {
if (subs.get(nuri) === cb) cb({ V0: { State: { doc: nuri } } });
});
return () => {
if (subs.get(nuri) === cb) subs.delete(nuri);
};
};
/**
* Calls the broker has ACCEPTED and not yet SERVED, in the order they were issued.
*
* `subs.set` at call time would hardwire "served in the order issued", which is not
* something the platform offers: the whole call is serialised under one
* `async_std::sync::RwLock` (`sdk/rust/src/local_broker.rs:3057`) but that IS
* `async_lock::RwLock` (async-std 1.13.2 `src/sync/mod.rs:181`), whose internal mutex is
* documented as "eventual fairness", explicitly not FIFO (async-lock 3.4.1
* `src/mutex.rs:22-24`), and whose anti-starvation fallback is compiled out on wasm
* (`src/mutex.rs:578-581`) which is where this SDK runs. A fake that can only serve in
* issue order cannot fail on the state that assumption is wrong about.
*/
const queued: Array<() => void> = [];
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => { const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
if (failFor.has(nuri)) throw new Error(`RepoNotFound: ${nuri}`); if (failFor.has(nuri)) throw new Error(`RepoNotFound: ${nuri}`);
let set = subs.get(nuri); if (hangFor.has(nuri)) {
if (!set) { return await new Promise((resolve, reject) => {
set = new Set(); hung.reject = reject;
subs.set(nuri, set); hung.resolve = resolve as (unsub: () => void) => void;
hung.cb = cb;
});
} }
set.add(cb); if (queueFor.has(nuri)) {
// Initial State push, delivered async (as the real RPC does). return await new Promise<() => void>((resolve) => {
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } })); queued.push(() => resolve(serveBranch(nuri, cb)));
return () => set!.delete(cb); });
}
return serveBranch(nuri, cb);
}); });
const push = (nuri: string): void => { /**
for (const cb of subs.get(nuri) ?? []) cb({ V0: { Patch: { doc: nuri } } }); * Serve the accepted calls, in the order given `serveQueued(1, 0)` serves the SECOND
* call first, which is the inversion the module's release-the-loser reasoning bets against.
*/
const serveQueued = (...order: number[]): void => {
for (const i of order) {
const serve = queued[i];
if (!serve) throw new Error(`no call number ${i} was accepted (${queued.length} were)`);
serve();
}
};
const push = (nuri: string): void => {
subs.get(nuri)?.({ V0: { Patch: { doc: nuri } } });
};
const isSubscribed = (nuri: string): boolean => subs.has(nuri);
/** The call that was left hanging finally answers — with a refusal. */
const rejectHung = (): void => {
const reject = hung.reject;
hung.reject = null;
reject?.(new Error("RepoNotFound: late"));
};
/**
* The call that was left hanging finally answers SUCCESSFULLY, and from the session it
* was placed against, which by now is the PREVIOUS one.
*
* Modelled apart from `subs` on purpose: a session is a verifier of its own, with its own
* `branch_subscriptions`, so this channel neither evicted nor was evicted by the one the
* new session opened (`engine/verifier/src/verifier.rs:361` inserts into `self`). It is
* genuinely live and genuinely pushing, and nothing but its own unsubscribe closes it
* which is the whole reason a superseded attempt has to be released rather than dropped.
*/
const resolveHung = (): void => {
const resolve = hung.resolve;
hung.resolve = null;
hung.reject = null;
hung.live = true;
resolve?.(() => {
hung.live = false;
});
};
/**
* The PREVIOUS session pushes over the channel it just handed back.
*
* Unconditionally, even once that channel has been released: a cancel does not reach back
* and un-send what the broker already dispatched, which is precisely why the callback
* carries a guard of its own instead of trusting the unsubscribe to have taken effect.
*/
const pushHung = (payload: unknown): void => {
hung.cb?.(payload);
};
/** Is the previous session's channel still open upstream? */
const hungIsLive = (): boolean => hung.live;
return {
doc_subscribe,
push,
isSubscribed,
rejectHung,
resolveHung,
pushHung,
hungIsLive,
serveQueued,
_subs: subs,
}; };
const isSubscribed = (nuri: string): boolean => (subs.get(nuri)?.size ?? 0) > 0;
return { doc_subscribe, push, isSubscribed, _subs: subs };
} }
function inject(failFor?: Set<string>) { function inject(failFor?: Set<string>, hangFor?: Set<string>, queueFor?: Set<string>) {
const ng = makeFakeNg(failFor); const ng = makeFakeNg(failFor, hangFor, queueFor);
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff. // Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION }); configureStoreRegistry({ getSession: async () => SESSION });
@@ -143,3 +270,445 @@ test("subscribeDocs deduplicates repeated NURIs", async () => {
await tick(); await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1); expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
}); });
// --- two subscribers on ONE document ---------------------------------------
//
// A branch has room for exactly one subscriber and a second `doc_subscribe` evicts the
// first (see `makeFakeNg`). Everything inside this package subscribes — `ensureRepoOpen`
// holds a bootstrap subscription per document for the session, `watchShape` follows the
// documents of a scope, `inbox.watch` follows an inbox, and the inbox observation follows
// every inbox — so any two of them on the same document used to silence one another.
// Nothing rejected and nothing logged; the view simply stopped re-reading.
//
// So the package opens ONE real subscription per document and fans it out. These are the
// tests that say so.
test("two subscribers on one document BOTH keep firing", async () => {
const ng = inject();
const first: unknown[] = [];
const second: unknown[] = [];
subscribeDoc(A, (r) => first.push(r));
await tick();
expect(first).toHaveLength(1); // its initial State
subscribeDoc(A, (r) => second.push(r));
await tick();
ng.push(A);
// The one that was there first is not silenced by the one that came second.
expect(first).toHaveLength(2);
expect(second.length).toBeGreaterThanOrEqual(1);
});
test("one real doc_subscribe serves every subscriber of a document", async () => {
const ng = inject();
subscribeDoc(A, () => {});
subscribeDoc(A, () => {});
subscribeDoc(A, () => {});
await tick();
// Three callers, one branch taken. A second call would have evicted the first caller.
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
test("a subscriber that joins LATE still gets its initial State", async () => {
inject();
subscribeDoc(A, () => {});
await tick(); // the initial State has come and gone
const late: unknown[] = [];
subscribeDoc(A, (r) => late.push(r));
await tick();
// Its own `doc_subscribe` would have pushed it a State; joining an open one owes it the
// same, or "fires once immediately" quietly stops being true for whoever arrives second.
expect(late).toHaveLength(1);
expect(docChangeType(late[0])).toBe("State");
});
test("the real subscription is released only when the LAST subscriber leaves", async () => {
const ng = inject();
const seen: unknown[] = [];
const stopFirst = subscribeDoc(A, () => {});
const stopSecond = subscribeDoc(A, (r) => seen.push(r));
await tick();
stopFirst();
expect(ng.isSubscribed(A)).toBe(true); // somebody is still listening
ng.push(A);
expect(seen.length).toBeGreaterThanOrEqual(2); // and still hearing
stopSecond();
expect(ng.isSubscribed(A)).toBe(false); // now nobody is
});
// --- what sharing one subscription must not COST a caller --------------------
//
// Three things a per-caller `doc_subscribe` gave for free. The fan-out took each of them
// away when it was introduced, and each is invisible from the caller's side: nothing
// rejects, nothing logs, the document simply stops speaking to somebody.
test("two subscriptions with the SAME handler are two subscriptions", async () => {
const ng = inject();
const seen: unknown[] = [];
// One function, two callers. A module-level handler, a bound method or a shared arrow
// makes this ordinary rather than exotic — and keyed by the function, the second caller
// was never registered at all, so the first one's departure took it with it.
const handler = (r: unknown): void => {
seen.push(r);
};
const stopFirst = subscribeDoc(A, handler);
const stopSecond = subscribeDoc(A, handler);
await tick();
const afterInitial = seen.length;
stopFirst(); // only the FIRST caller has left
expect(ng.isSubscribed(A)).toBe(true);
ng.push(A);
expect(seen.length).toBeGreaterThan(afterInitial); // the second one is still listening
stopSecond();
expect(ng.isSubscribed(A)).toBe(false); // …and now the last one has gone
});
test("a subscriber torn down inside another's handler does not receive that push", async () => {
const ng = inject();
const seen: unknown[] = [];
let stopSecond: Unsubscribe | null = null;
let armed = false;
// The first handler tears the second one down mid-push. Which of the two runs first is an
// ordering no application controls, and `subscribeDoc` publishes that no `onChange` fires
// after unsubscribe — so the answer must not depend on it.
const stopFirst = subscribeDoc(A, () => {
if (armed) stopSecond?.();
});
stopSecond = subscribeDoc(A, (r) => seen.push(r));
await tick();
seen.length = 0;
armed = true;
ng.push(A);
expect(seen).toEqual([]);
stopFirst();
});
test("a setup that FAILED does not poison the document for the next subscriber", async () => {
const failFor = new Set([A]);
const ng = inject(failFor);
const stopFirst = subscribeDoc(A, () => {});
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1); // …and it rejected
// The document syncs; the broker can serve it now. A caller arriving after a transient
// rejection must get a real subscription, exactly as its own `doc_subscribe` would have.
failFor.delete(A);
const seen: unknown[] = [];
const stopSecond = subscribeDoc(A, (r) => seen.push(r));
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2); // attempted again, not joined to a corpse
expect(seen.length).toBeGreaterThan(0); // its initial State
ng.push(A);
expect(seen.length).toBeGreaterThan(1); // and the changes that follow
stopFirst();
stopSecond();
});
test("a caller that asks to be told learns its subscription could not be opened", async () => {
inject(new Set([A]));
const failures: unknown[] = [];
const stop = subscribeDocReportingSetupFailure(
A,
() => {},
(error) => failures.push(error),
);
await tick();
// Upstream `doc_subscribe` is async and rejects, so its caller learns. This wrapper
// returns synchronously, and a caller whose job depends on the subscription (the inbox
// observation) cannot otherwise tell "quiet" from "never opened".
expect(failures).toHaveLength(1);
expect(String(failures[0])).toContain("RepoNotFound");
stop();
});
/**
* Two establishes over ONE fan-out, and the first one answering last.
*
* `resubscribeDocs` re-opens the channel of a fan-out whose first `doc_subscribe` has not
* settled yet that is the whole point of it, since the session it was opened against is
* gone and it re-opens it on the SAME entry so the listeners are kept. The two calls
* therefore race, and the broker is under no obligation to answer them in order.
*/
async function hangingThenReopened(): Promise<{
ng: ReturnType<typeof makeFakeNg>;
failures: unknown[];
seen: unknown[];
stop: Unsubscribe;
}> {
const hangFor = new Set([A]);
const ng = inject(new Set(), hangFor);
const failures: unknown[] = [];
const seen: unknown[] = [];
const stop = subscribeDocReportingSetupFailure(
A,
(r) => seen.push(r),
(error) => failures.push(error),
);
// Awaited before the broker is allowed to answer: `establish` resolves the session id
// first, so the call this has to leave hanging has not been placed yet.
await tick();
hangFor.delete(A);
return { ng, failures, seen, stop };
}
test("a SUPERSEDED setup rejecting late is not reported as this document failing", async () => {
const { ng, failures, seen, stop } = await hangingThenReopened();
expect(ng.isSubscribed(A)).toBe(false); // the first call has not answered
resubscribeDocs(); // the session rotated: a second establish, on the same fan-out
await tick();
expect(ng.isSubscribed(A)).toBe(true);
const before = seen.length;
expect(before).toBeGreaterThan(0); // …and it is pushing
ng.rejectHung(); // …and only now does the first call refuse
await tick();
// Nothing failed: the document is subscribed and pushing. Told otherwise, the caller whose
// job depends on this subscription (the inbox observation) releases the entry it holds —
// which closes the WORKING channel and reports an inbox that is watched as unwatchable.
expect(failures).toEqual([]);
expect(ng.isSubscribed(A)).toBe(true);
ng.push(A);
expect(seen.length).toBeGreaterThan(before);
stop();
});
test("a SUPERSEDED setup rejecting late does not let the next joiner evict the live channel", async () => {
const { ng, stop } = await hangingThenReopened();
resubscribeDocs();
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2);
ng.rejectHung();
await tick();
// The second establish still stands, so this joiner must join it. Counting the late
// rejection as "nothing is running any more" opens a THIRD `doc_subscribe` — and a second
// subscribe on a branch evicts the one before it, so the joiner's own call is what silences
// everybody already listening.
const late: unknown[] = [];
const stopLate = subscribeDoc(A, (r) => late.push(r));
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2);
expect(late.length).toBeGreaterThan(0); // replayed the barrier, as any late joiner is
ng.push(A);
expect(late.length).toBeGreaterThan(1);
stopLate();
stop();
});
/** A `State` that could only have come from the session `resubscribeDocs` walked away from. */
const OLD_SESSION_STATE = { V0: { State: { doc: A, from: "the previous session" } } };
test("a SUPERSEDED setup that SUCCEEDS pushes to nobody, re-seeds nothing, and is released", async () => {
const { ng, seen, stop } = await hangingThenReopened();
resubscribeDocs(); // the session rotated: a second establish, on the SAME fan-out
await tick();
expect(ng.isSubscribed(A)).toBe(true);
const afterReopen = seen.length;
expect(afterReopen).toBeGreaterThan(0); // the current session is pushing
// …and only now does the FIRST call answer — successfully, from the session that is gone.
ng.resolveHung();
await tick();
ng.pushHung(OLD_SESSION_STATE);
await tick();
// Its channel was released as it resolved: nothing else would ever close it, because it
// sits on the previous session's verifier where the new subscription never displaced it.
expect(ng.hungIsLive()).toBe(false);
// Its push reaches nobody. The listeners are following the establish that replaced it, and
// handing them a verifier nobody is talking to any more is the staleness, not the cure.
expect(seen).not.toContainEqual(OLD_SESSION_STATE);
expect(seen.length).toBe(afterReopen);
// Nor is the abandoned `State` left as the barrier: a joiner is replayed the CURRENT
// session's, which is what `resubscribeDocs` cleared `lastState` to guarantee. Replayed the
// other one, a bootstrap open calls a repo this session has never synced "synced".
const late: unknown[] = [];
const stopLate = subscribeDoc(A, (r) => late.push(r));
await tick();
expect(late.length).toBeGreaterThan(0);
expect(late).not.toContainEqual(OLD_SESSION_STATE);
// And `realUnsub` is still the live call's, so the last listener out leaves NOTHING
// subscribed upstream — neither session's channel.
stopLate();
stop();
await tick();
expect(ng.isSubscribed(A)).toBe(false);
expect(ng.hungIsLive()).toBe(false);
});
test("a SUPERSEDED setup that FAILS is logged, even though it is reported to nobody", async () => {
const { ng, failures, stop } = await hangingThenReopened();
resubscribeDocs();
await tick();
const logged: string[] = [];
const realError = console.error;
console.error = ((...a: unknown[]) => {
logged.push(a.map(String).join(" "));
}) as typeof console.error;
try {
ng.rejectHung();
await tick();
} finally {
console.error = realError;
}
// Told to nobody — the call that replaced it owns the outcome — but never passed over in
// silence: a rejection that reaches no caller AND no log is a failure that never happened,
// and this one is the only trace that the abandoned session refused at all.
expect(failures).toEqual([]);
expect(logged.some((l) => l.startsWith("[subscribe] doc_subscribe failed for"))).toBe(true);
stop();
});
test("an establish SUPERSEDED while the session id resolves never reaches the broker at all", async () => {
const ng = makeFakeNg();
configure({ ng: ng as unknown as Parameters<typeof configure>[0]["ng"], useShape: (() => {}) as never });
// A session lookup this test holds open, so the supersession lands while the first
// establish is between "started" and "placed its call" — the window `resubscribeDocs`
// actually opens, since it fires from a `resetOpenedRepos` that a session read preceded.
const gate: { release: (() => void) | null } = { release: null };
const held = new Promise<void>((r) => {
gate.release = r;
});
configureStoreRegistry({
getSession: async () => {
await held;
return SESSION;
},
});
const seen: unknown[] = [];
const stop = subscribeDoc(A, (r) => seen.push(r));
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(0); // still resolving the session
resubscribeDocs(); // superseded before it ever placed its call
gate.release?.();
await tick();
// ONE call, and it is the current attempt's. What that buys is exact and worth stating
// exactly: an establish superseded in THIS window opens nothing, so there is no channel of
// its own to release and no second call for the broker to order. It does not make releasing
// a superseded attempt safe in general — that rests on a bet about SERVICE order, which the
// two tests below take apart.
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(ng.isSubscribed(A)).toBe(true);
expect(seen.length).toBeGreaterThan(0);
stop();
expect(ng.isSubscribed(A)).toBe(false);
});
/**
* Two attempts on ONE branch, and the broker free to serve them in either order.
*
* `resubscribeDocs` re-opens a fan-out whose first establish has not settled, and the last
* listener leaving mid-establish then re-joining does the same against the SAME session so
* two calls contending for one branch is a state this module reaches. Which of them ends up
* holding the branch is the broker's to decide, not this module's: the call is serialised
* under one `async_std::sync::RwLock` (`sdk/rust/src/local_broker.rs:3057`), and serialised
* is not ordered. That lock IS `async_lock::RwLock` (async-std 1.13.2 `src/sync/mod.rs:181`),
* `write()` takes its internal `async_lock::Mutex` first (async-lock 3.4.1
* `src/rwlock/raw.rs:163-168`), and that mutex is documented as "eventual fairness" and
* explicitly not FIFO (`src/mutex.rs:22-24`) with the anti-starvation fallback that would
* eventually force fairness compiled out on wasm (`src/mutex.rs:578-581`), which is where
* this SDK runs.
*
* Both orders are therefore real, and they do not end the same way. The fake serves on
* demand (`serveQueued`) rather than at call time precisely so both can be exercised: a fake
* that inserts in call order asserts the happy one into existence.
*/
async function twoAttemptsOnOneBranch(): Promise<{
ng: ReturnType<typeof makeFakeNg>;
seen: unknown[];
failures: unknown[];
stop: Unsubscribe;
}> {
const ng = inject(undefined, undefined, new Set([A]));
const seen: unknown[] = [];
const failures: unknown[] = [];
const stop = subscribeDocReportingSetupFailure(
A,
(r) => seen.push(r),
(e) => failures.push(e),
);
await tick(); // the first attempt has PLACED its call — the broker has it, unserved
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
resubscribeDocs(); // …and is superseded, after the call, not before it
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2);
return { ng, seen, failures, stop };
}
test("served in the order issued, the superseded attempt is the one the broker evicted", async () => {
const { ng, seen, failures, stop } = await twoAttemptsOnOneBranch();
ng.serveQueued(0, 1); // the first call first — the order the module bets on
await tick();
// The current attempt inserted last, so it holds the branch; the superseded one released a
// channel that had already been displaced. The document is live and pushing.
expect(ng.isSubscribed(A)).toBe(true);
const before = seen.length;
ng.push(A);
expect(seen.length).toBe(before + 1);
expect(failures).toEqual([]);
stop();
expect(ng.isSubscribed(A)).toBe(false);
});
test("served in the INVERTED order, the document goes silently dead — nothing rejects, nothing logs", async () => {
const { ng, seen, failures, stop } = await twoAttemptsOnOneBranch();
const logged: string[] = [];
const realError = console.error;
console.error = ((...a: unknown[]) => {
logged.push(a.map(String).join(" "));
}) as typeof console.error;
try {
ng.serveQueued(1, 0); // the SECOND call served first — the bet, broken
await tick();
} finally {
console.error = realError;
}
// The superseded call inserted LAST, so it took the branch and closed the current
// attempt's sender (`engine/verifier/src/verifier.rs:361`); this module then released the
// channel that call had opened, because the attempt behind it is no longer current. What
// upstream is left holding is a closed sender, which `push_app_response` drops at the first
// push (`verifier.rs:258-261`) — here, a branch with nobody on it.
expect(ng.isSubscribed(A)).toBe(false);
const before = seen.length;
ng.push(A);
expect(seen.length).toBe(before); // the listener is subscribed and hears nothing
// And nothing anywhere says so. No rejection reaches the caller that ASKED to be told, and
// the log is clean: every call resolved, so there was no failure to report.
expect(failures).toEqual([]);
expect(logged).toEqual([]);
// The fan-out believes itself subscribed, which is what makes this last the session: it
// holds a `realUnsub` and `establishing` never went back to false, so the next joiner is
// handed the dead entry instead of opening a channel of its own.
const alsoStop = subscribeDoc(A, () => {});
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2); // no third call — nobody re-opens it
alsoStop();
stop();
});
+79 -6
View File
@@ -125,6 +125,23 @@ export interface FakeWallet {
/** Present only under {@link WalletOptions.unsyncedUntilSubscribed}. */ /** Present only under {@link WalletOptions.unsyncedUntilSubscribed}. */
doc_subscribe?: ReturnType<typeof mock>; doc_subscribe?: ReturnType<typeof mock>;
_quads: Quad[]; _quads: Quad[];
/**
* A commit made in ANOTHER session, reaching this page now the broker delivering what
* it was holding. The quads land in the wallet and each document they touch pushes to
* its subscriber, which is what a remote write does here: verified against the real
* broker, a second session's write reached the first session's subscription as a `Patch`
* 12ms after it landed (`e2e/reactivity-doc-subscribe.ts`, CROSS).
*
* It delivers; it does not INVENT. A caller hands it quads the library itself produced
* under the other actor's identity never a shape a test wrote by hand.
*/
_deliver: (arriving: Quad[]) => void;
/**
* Anchors whose anchored READ throws, as an unreachable repo does. Mutable after boot,
* so a suite builds a healthy world first and breaks only the one call it is about
* the fault is the broker's, never a reach into the library to make it reject.
*/
_failReadsOn: Set<string>;
} }
export interface WalletOptions { export interface WalletOptions {
@@ -168,6 +185,38 @@ export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWall
const synced = new Set<string>(); const synced = new Set<string>();
const cold = options.unsyncedUntilSubscribed === true; const cold = options.unsyncedUntilSubscribed === true;
/**
* The ONE subscriber a document can have.
*
* Not a convenience it is what the broker does. A branch holds exactly one sender
* (`branch_subscriptions: HashMap<BranchId, Sender<AppResponse>>`) and
* `create_branch_subscription` closes whatever it displaces, so a second
* `doc_subscribe` on a document does not join the first, it EVICTS it silently, with
* the evicted unsubscribe still callable and no error anywhere. Confirmed against the
* real broker on 2026-08-17: with two subscriptions on one document, a write fired the
* second callback and the first, which had been firing moments before, went quiet.
*
* A Set here would fabricate a world where every subscriber coexists precisely the
* assumption whose falseness cost this package a view that never re-read and an inbox
* that never notified.
*/
const subscriber = new Map<string, (r: unknown) => void>();
/** See {@link FakeWallet._failReadsOn}. */
const failReadsOn = new Set<string>();
/** A commit on `g` pushes a `Patch` to that document's subscriber the SESSION THAT
* WROTE IT INCLUDED. Verified against the real broker the same day: a session's own
* `sparqlUpdate` to a document it subscribes to pushed `Patch@69ms`. The engine keys
* its senders by branch and knows nothing about who issued the write. */
const commit = (g: string): void => {
const cb = subscriber.get(g);
if (!cb) return;
setTimeout(() => {
if (subscriber.get(g) === cb) cb({ V0: { Patch: {} } });
}, 0);
};
const doc_create = mock(async () => { const doc_create = mock(async () => {
const nuri = `did:ng:o:doc${++minted}`; const nuri = `did:ng:o:doc${++minted}`;
// Created here: nothing remote to wait for. This is why the session that wrote the // Created here: nothing remote to wait for. This is why the session that wrote the
@@ -180,11 +229,19 @@ export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWall
const nuri = a[0] as string; const nuri = a[0] as string;
const onChange = a[2] as (r: unknown) => void; const onChange = a[2] as (r: unknown) => void;
synced.add(nuri); synced.add(nuri);
subscriber.set(nuri, onChange);
// `TabInfo` first, then the initial `State` — the platform's own order, so a waiter // `TabInfo` first, then the initial `State` — the platform's own order, so a waiter
// that resolved on "the first push of any kind" would return BEFORE the barrier. // that resolved on "the first push of any kind" would return BEFORE the barrier.
setTimeout(() => onChange({ V0: { TabInfo: {} } }), 0); // Only while this callback still holds the branch: an evicted subscriber hears nothing.
setTimeout(() => onChange({ V0: { State: {} } }), 0); setTimeout(() => {
return () => {}; if (subscriber.get(nuri) === onChange) onChange({ V0: { TabInfo: {} } });
}, 0);
setTimeout(() => {
if (subscriber.get(nuri) === onChange) onChange({ V0: { State: {} } });
}, 0);
return () => {
if (subscriber.get(nuri) === onChange) subscriber.delete(nuri);
};
}); });
const sparql_update = mock(async (...a: unknown[]) => { const sparql_update = mock(async (...a: unknown[]) => {
@@ -199,6 +256,7 @@ export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWall
const q = quads[i]!; const q = quads[i]!;
if (q.g === anchor && q.s === pattern[1] && q.p === pattern[2]) quads.splice(i, 1); if (q.g === anchor && q.s === pattern[1] && q.p === pattern[2]) quads.splice(i, 1);
} }
commit(anchor);
} }
return undefined; return undefined;
} }
@@ -210,6 +268,7 @@ export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWall
? wrapped[2]! ? wrapped[2]!
: query.replace(/^[\s\S]*?INSERT\s+DATA\s*\{/i, "").replace(/\}\s*$/, ""); : query.replace(/^[\s\S]*?INSERT\s+DATA\s*\{/i, "").replace(/\}\s*$/, "");
for (const t of parseTriples(body)) quads.push({ g, ...t }); for (const t of parseTriples(body)) quads.push({ g, ...t });
commit(g);
return undefined; return undefined;
}); });
@@ -221,6 +280,12 @@ export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWall
// The repo the verifier resolves the read against — the anchor when there is one, // The repo the verifier resolves the read against — the anchor when there is one,
// otherwise the graph named in the query. // otherwise the graph named in the query.
const target = anchor ?? g; const target = anchor ?? g;
// The repo this broker cannot answer for. Rejects, as `resolve_target_for_sparql`
// does on a repo the verifier does not have — never 0 rows, which would be the
// altogether different (and silent) cold-start state modelled below.
if (target !== undefined && failReadsOn.has(target)) {
throw new Error(`RepoNotFound: ${target}`);
}
// COLD: present but unsynced. No error, no rows — which is exactly why it is dangerous. // COLD: present but unsynced. No error, no rows — which is exactly why it is dangerous.
if (cold && target !== undefined && !synced.has(target)) return { results: { bindings: [] } }; if (cold && target !== undefined && !synced.has(target)) return { results: { bindings: [] } };
const inGraph = quads.filter((q) => q.g === g); const inGraph = quads.filter((q) => q.g === g);
@@ -299,9 +364,17 @@ export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWall
return { results: { bindings: [] } }; return { results: { bindings: [] } };
}); });
return cold const _deliver = (arriving: Quad[]): void => {
? { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads } const touched = new Set<string>();
: { doc_create, sparql_update, sparql_query, _quads: quads }; for (const q of arriving) {
quads.push(q);
touched.add(q.g);
}
for (const g of touched) commit(g);
};
const common = { doc_create, sparql_update, sparql_query, _quads: quads, _deliver, _failReadsOn: failReadsOn };
return cold ? { ...common, doc_subscribe } : common;
} }
/** Wire the library onto `quads` — what a page load does. */ /** Wire the library onto `quads` — what a page load does. */