fix: quatre écarts entre la surface publiée et ce que NextGraph déclare

Un audit de la surface contre la source amont en a trouvé cinq ; voici les
quatre mécaniques. La cinquième — l'adresse d'inbox, qui traverse sept symboles
— relève du dessin et reste ouverte.

L'identifiant de session bloquait. Amont le déclare string | number
(sdk/js/web/src/index.ts:16) et le binding désérialise un u64 ; nous exigions
une chaîne. Une application ne pouvait donc pas passer la valeur que le SDK
venait de lui remettre. Élargi à ce qu'amont déclare, sur toute la chaîne, et
jamais converti : une chaîne échoue pour de vrai (Deserialization error of
session_id JsValue("1"), observé).

sparqlUpdate annonçait Promise<void> alors qu'il relayait DÉJÀ les commits.
C'était donc un mensonge de typage, pas un comportement — et la doublure de test
qui rendait undefined, un état que le vrai broker ne produit jamais, est ce qui
l'a laissé sans contradicteur.

ng était publié en Record<string, any>, ce qui perdait les 88 membres typés
d'amont — 88, pas 77 : le chiffre de notre propre documentation était faux.

Et materialize, second nom publié de read, sans appelant ni contrepartie amont,
est retiré.

docs/api-contract.md qualifiait docs.* de passthrough « 1:1 ». C'était faux sur
les deux premiers points. Corrigé, pas complété : un document qui se déclare
vérifié et qui ment est pire qu'un document absent, parce qu'on cesse d'aller
voir.

Une déviation assumée : amont type le retour en any, interdit ici ; on rend
unknown, comme sparqlQuery le fait déjà pour le même any amont.
This commit is contained in:
Sylvain Duchesne
2026-08-14 10:00:40 +02:00
parent e32b6d04fc
commit 12eba6eea6
17 changed files with 227 additions and 99 deletions
+13
View File
@@ -0,0 +1,13 @@
# Doc-debt — app-contract
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED packages/polyfill/src/surface/docs.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/surface/read-model.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/surface/inbox.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/surface/subscribe.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/surface/ng-proxy.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/index.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED docs/api-contract.md @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42)
@@ -61,9 +61,14 @@ export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: Doc
// ── low-level document / SPARQL primitives ─────────────────────────────── // ── low-level document / SPARQL primitives ───────────────────────────────
export const docs: { export const docs: {
docCreate(sessionId: string, crdt: string, cls: string, dest: string, store?: unknown): Promise<Nuri>; // `sessionId` is `string | number` — upstream's own declared type (`Session.session_id`).
sparqlQuery(sessionId: string, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>; // It is RELAYED, never converted: the wasm side deserializes a `u64`, and stringifying it
sparqlUpdate(sessionId: string, query: string, anchor?: NuriLike, label?: string): Promise<void>; // 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 ──────────────────────────────── // ── inbox: giving to read, and depositing ────────────────────────────────
@@ -76,12 +81,15 @@ export const inbox: {
readSynced(targetInbox: NuriLike): Promise<Deposit[]>; readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
processInbox(targetInbox: NuriLike): Promise<Deposit[]>; processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void; watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
materialize: typeof read; // `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 } export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
// ── the wrapped SDK objects ────────────────────────────────────────────── // ── the wrapped SDK objects ──────────────────────────────────────────────
export const ng: Record<string, any>; // call this instead of the `ng` passed to `configure` 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 init(...args: any[]): any; // likewise — not the `init` passed to `configure`
export function initNg(...args: any[]): any; export function initNg(...args: any[]): any;
``` ```
+2
View File
@@ -5,3 +5,5 @@
## Raw markers (consolidate into blocks, then delete) ## Raw markers (consolidate into blocks, then delete)
- TOUCHED packages/polyfill/src/shared-wallet/account-registry.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42) - TOUCHED packages/polyfill/src/shared-wallet/account-registry.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/shared-wallet/physical.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/shared-wallet/session.ts @2026-08-14 (session f93872b5-293a-4916-a353-181409a96d42)
+26 -19
View File
@@ -135,19 +135,19 @@ Why it lives here and not in the consumer application: the first consumer had ~3
### Today — `@ng-eventually/polyfill` ### Today — `@ng-eventually/polyfill`
```ts ```ts
// index.ts:55 // index.ts:152
export const ng: Record<string, any>; export const ng: NG;
// type re-export, index.ts:50 // type re-export, index.ts:86
export type { NG } from "@ng-org/web"; export type { NG } from "@ng-org/web";
``` ```
`ng` is a `Proxy` (`ng-proxy.ts:10`) forwarding every property to the injected real `ng`, overriding exactly two things: `login` / `session_start` (currently a passthrough with a TODO for shared-wallet credentials) and `sparql_update` (the emulated write-cap guard, rejecting a write when a write policy governs the anchored document and the current user lacks the cap). `ng` is a `Proxy` (`ng-proxy.ts:26`) forwarding every property to the injected real `ng`, overriding exactly one thing: `sparql_update` (the emulated write-cap guard, rejecting a write when a write policy governs the anchored document and the current user lacks the cap). `session_start` is intercepted but is a pure passthrough today, with a TODO for shared-wallet credentials. (`login` was named here until 2026-08-14 but has not existed since 2026-08-03: `@ng-org/web` exposes no such method, and the proxy was FABRICATING it.)
### Target ### Target
**PASSTHROUGH (level 2, VERIFIED).** `export declare const ng: NG` with `NG = typeof NGModule`, 77 exported members (`index.d.ts:136-231`). The surface is identical by construction — the proxy adds no member and removes none. **PASSTHROUGH (level 2, VERIFIED).** `export declare const ng: NG` with `NG = typeof NGModule`, **88** exported members (`index.d.ts:136-231`; the namespace body is `:140-231`). The surface is identical by construction — the proxy adds no member and removes none**and identical at the type level too since 2026-08-14**, when the published `ng` stopped being declared `Record<string, any>` and took `NG`, upstream's own type. That escape hatch silently dropped all 88 signatures: an application got no completion, and a misspelt member typechecked.
The two overrides: The overrides:
- `session_start(wallet_name: string, user_id: any): Promise<any>` (`index.d.ts:276`) — target signature unchanged; only the emulated credential injection disappears. - `session_start(wallet_name: string, user_id: any): Promise<any>` (`index.d.ts:276`) — target signature unchanged; only the emulated credential injection disappears.
- `sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>` (`index.d.ts:297`) — target signature unchanged. The native enforcement the guard stands in for is the engine's permission model (`verify_perm`, `engine/repo/src/commit.rs:897`), which today is **called only from tests** (its enclosing `Commit::verify` has no runtime caller — see `docs/nextgraph-current-state.md` § *Author-signature verification*). That absence says nothing about the target: write permissions are the engine's declared model, so the guard's *behaviour* (a refused write) is target-shaped even though its *mechanism* (a JS-side check) is emulation. Known limit, documented in `README.md`: the guard fires only on this proxy, and the lib's own writers call the injected `ng` directly, so it is best-effort until cap-enforcement. - `sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>` (`index.d.ts:297`) — target signature unchanged. The native enforcement the guard stands in for is the engine's permission model (`verify_perm`, `engine/repo/src/commit.rs:897`), which today is **called only from tests** (its enclosing `Commit::verify` has no runtime caller — see `docs/nextgraph-current-state.md` § *Author-signature verification*). That absence says nothing about the target: write permissions are the engine's declared model, so the guard's *behaviour* (a refused write) is target-shaped even though its *mechanism* (a JS-side check) is emulation. Known limit, documented in `README.md`: the guard fires only on this proxy, and the lib's own writers call the injected `ng` directly, so it is best-effort until cap-enforcement.
@@ -276,24 +276,24 @@ The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query
### Today — `@ng-eventually/polyfill` (namespace `docs`) ### Today — `@ng-eventually/polyfill` (namespace `docs`)
```ts ```ts
// docs.ts:46 // docs.ts:53
export async function docCreate( export async function docCreate(
sessionId: string, sessionId: string | number,
crdt: string, crdt: string,
cls: string, cls: string,
dest: string, dest: string,
store?: unknown, store?: unknown,
): Promise<Nuri>; ): Promise<Nuri>;
// docs.ts:85 // docs.ts:101
export async function sparqlUpdate( export async function sparqlUpdate(
sessionId: string, sessionId: string | number,
query: string, query: string,
anchor?: Nuri, anchor?: Nuri,
label = "sparqlUpdate", label = "sparqlUpdate",
): Promise<void>; ): Promise<unknown>;
// docs.ts:130 // docs.ts:146
export async function sparqlQuery( export async function sparqlQuery(
sessionId: string, sessionId: string | number,
query: string, query: string,
base?: string, base?: string,
anchor?: Nuri, anchor?: Nuri,
@@ -309,7 +309,12 @@ export async function sparqlQuery(
### Target ### Target
**PASSTHROUGH (level 2, VERIFIED)** — these mirror the real methods 1:1 minus the trailing `label` (a lib-internal access-log tag, never forwarded): **PASSTHROUGH (level 2, VERIFIED)** — these forward to the real methods, and the ONLY argument they do not pass on is the trailing `label` (a lib-internal access-log tag). Until 2026-08-14 this entry claimed a 1:1 mirror, which was false in two ways, both now fixed rather than documented as deltas:
- **The session id is `string | number`, upstream's own declared type for it** (`Session.session_id`, `sdk/js/web/src/index.ts:16` and the installed `index.d.ts:266`) — it used to be narrowed to `string` here, which made the value the SDK hands an application impossible to pass back into this library. It is RELAYED, never converted: the wasm side deserializes a `u64` (`sdk/js/lib-wasm/src/lib.rs:352-358` `sparql_query`, `:452-457` `sparql_update`, `:1575` `doc_create`), and stringifying it fails that deserialization for real — observed as `Deserialization error of session_id JsValue("1")`.
- **`sparqlUpdate` returns what the real method returns** — the commits the update produced (`lib.rs:481-483` serialises `AppResponseV0::Commits`; the installed `index.d.ts:297` types it `Promise<any>`). It was declared `Promise<void>` while already relaying the value at runtime, so the answer was thrown away for every caller. Typed `unknown` rather than `any`, exactly as `sparqlQuery` already renders the same upstream `Promise<any>`. A caller that ignores it is unaffected.
The real signatures forwarded to:
```ts ```ts
// index.d.ts:60 — the installed web SDK's doc_create // index.d.ts:60 — the installed web SDK's doc_create
@@ -389,7 +394,9 @@ export async function postToDocument(doc: NuriLike, opts: PostOptions): Promise<
export async function share(doc: NuriLike, toUser: string): Promise<void>; export async function share(doc: NuriLike, toUser: string): Promise<void>;
export async function read(targetInbox: NuriLike): Promise<Deposit[]>; export async function read(targetInbox: NuriLike): Promise<Deposit[]>;
export async function readForDocument(doc: NuriLike): Promise<Deposit[]>; export async function readForDocument(doc: NuriLike): Promise<Deposit[]>;
export const materialize = read; // `materialize` — a second published name for `read` — was REMOVED on 2026-08-14. It was
// an alias and nothing else: no call site, and upstream has no such member, so it was a
// symbol an application could learn and would then have to unlearn. Use `read`.
export async function readSynced(targetInbox: NuriLike): Promise<Deposit[]>; export async function readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
export async function processInbox(targetInbox: NuriLike): Promise<Deposit[]>; export async function processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
export function watch( export function watch(
@@ -412,7 +419,7 @@ Consequences per function:
- `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown**`docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the address→repo association lives in `inboxes: PubKey → RepoId`, a table of the **verifier** — one per user, `verifier.rs:105`). *(Corrected 2026-08-10: this said the table was "session-local, rebuilt empty". It is initialized empty (`verifier.rs:520,2820`) and then repopulated at every load — `Verifier::load` → `add_repo_without_saving` → `add_repo_`, `verifier.rs:534-566,2871,2887` — with the inbox private key persisted per repo, `user_storage/repo.rs:61,171,207,362`. The property that matters is that it is **per verifier**, not that it is ephemeral.)* Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`. - `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown**`docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the address→repo association lives in `inboxes: PubKey → RepoId`, a table of the **verifier** — one per user, `verifier.rs:105`). *(Corrected 2026-08-10: this said the table was "session-local, rebuilt empty". It is initialized empty (`verifier.rs:520,2820`) and then repopulated at every load — `Verifier::load` → `add_repo_without_saving` → `add_repo_`, `verifier.rs:534-566,2871,2887` — with the inbox private key persisted per repo, `user_storage/repo.rs:61,171,207,362`. The property that matters is that it is **per verifier**, not that it is ephemeral.)* Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`.
- `share` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated. - `share` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated.
- `read` / `materialize` / `readSynced` / `processInbox` / `watch`**stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient. - `read` / `readSynced` / `processInbox` / `watch`**stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient.
- `readForDocument(doc)` — the owner's side of a document's inbox, named by the DOCUMENT. Same LEVEL-1 SHAPE ruling as `read`: it is the recipient's own processing, which has no consumer-facing JS surface upstream, and enumerating its deposits is emulation detail. It exists so an application never handles an inbox address. - `readForDocument(doc)` — the owner's side of a document's inbox, named by the DOCUMENT. Same LEVEL-1 SHAPE ruling as `read`: it is the recipient's own processing, which has no consumer-facing JS surface upstream, and enumerating its deposits is emulation detail. It exists so an application never handles an inbox address.
- `share(doc, toUser)` **refuses an unknown recipient** since 2026-08-10. It used to provision one: a mistyped name minted that name's stores and an inbox, and the cap landed where nobody looks. Upstream a deposit is sealed to an inbox pubkey that reached you through an inbound contact, so you cannot address a name you invented. - `share(doc, toUser)` **refuses an unknown recipient** since 2026-08-10. It used to provision one: a mistyped name minted that name's stores and an inbox, and the cap landed where nobody looks. Upstream a deposit is sealed to an inbox pubkey that reached you through an inbound contact, so you cannot address a name you invented.
- `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15. - `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15.
@@ -531,7 +538,7 @@ interface VirtualUserRecord {
} }
// RegistrySession is INTERNAL since 2026-08-12 (shape kept here for the ruling below). // RegistrySession is INTERNAL since 2026-08-12 (shape kept here for the ruling below).
interface RegistrySession { interface RegistrySession {
sessionId: string; sessionId: string | number; // relayed untouched — upstream's own type (§ 7)
privateStoreId: string; privateStoreId: string;
protectedStoreId?: string; protectedStoreId?: string;
publicStoreId?: string; publicStoreId?: string;
@@ -661,7 +668,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery". - ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".
- ~~**`share` is importable from both entries.**~~ **FIXED 2026-08-07** with the entry merge: there is one entry and one `share`, under `inbox`. - ~~**`share` is importable from both entries.**~~ **FIXED 2026-08-07** with the entry merge: there is one entry and one `share`, under `inbox`.
- **One entry means the import line no longer says what disappears.** Until 2026-08-07 a second import path (`/polyfill`) WAS the deletion list. It is now the `POLYFILL-ERA` block in `src/index.ts`, this appendix's note above, and the per-subject rulings in this document. That is a documentation-carried signal where it used to be a mechanical one — the appendix is pinned by a test, the grouping is not. - **One entry means the import line no longer says what disappears.** Until 2026-08-07 a second import path (`/polyfill`) WAS the deletion list. It is now the `POLYFILL-ERA` block in `src/index.ts`, this appendix's note above, and the per-subject rulings in this document. That is a documentation-carried signal where it used to be a mechanical one — the appendix is pinned by a test, the grouping is not.
- **`inbox.read`/`materialize` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely. - **`inbox.read` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely.
- **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API. - **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API.
- **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `watchShape`'s design already assumes). - **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `watchShape`'s design already assumes).
- **The sync-returning `subscribeDoc` unsubscribe** vs the target's promise-resolved one (§ 8) — a deliberate, documented ergonomic delta; an adapter is one line at migration, but it is a delta. - **The sync-returning `subscribeDoc` unsubscribe** vs the target's promise-resolved one (§ 8) — a deliberate, documented ergonomic delta; an adapter is one line at migration, but it is a delta.
@@ -677,7 +684,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
```text ```text
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, NG, NgLike, Nuri, NuriLike, PrincipalId, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, NG, NgLike, Nuri, NuriLike, PrincipalId, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape
docs: docCreate, sparqlQuery, sparqlUpdate docs: docCreate, sparqlQuery, sparqlUpdate
inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch
storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
``` ```
@@ -32,7 +32,7 @@ import type { Nuri } from "../model/types";
* nothing else. Never exported from the package. * nothing else. Never exported from the package.
*/ */
export async function registerUpdate( export async function registerUpdate(
sessionId: string, sessionId: string | number,
query: string, query: string,
anchor: Nuri, anchor: Nuri,
label = "registerUpdate", label = "registerUpdate",
@@ -66,7 +66,7 @@ export async function registerUpdate(
* only caller, and reading is guarded separately (`inbox.read`). * only caller, and reading is guarded separately (`inbox.read`).
*/ */
export async function depositInto( export async function depositInto(
sessionId: string, sessionId: string | number,
query: string, query: string,
targetInbox: Nuri, targetInbox: Nuri,
label = "deposit", label = "deposit",
+10 -2
View File
@@ -139,7 +139,15 @@ export type { EventuallyConfig } from "./shared-wallet/bootstrap";
export { ensureIdentity } from "./shared-wallet/access-gate"; export { ensureIdentity } from "./shared-wallet/access-gate";
export type { SharedWalletConfig } from "./shared-wallet/access-gate"; export type { SharedWalletConfig } from "./shared-wallet/access-gate";
import type { NG } from "@ng-org/web";
import { makeNg } from "./surface/ng-proxy"; import { makeNg } from "./surface/ng-proxy";
/** SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`. */ /**
export const ng: Record<string, any> = makeNg(); * SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`.
*
* Declared `NG` — upstream's own type for its `ng` (`index.d.ts:138`). It was published as
* `Record<string, any>` until 2026-08-14, which announced a different surface from the one
* it forwards to: an application got no completion and no check on any of the 88 members.
*/
export const ng: NG = makeNg();
@@ -250,7 +250,8 @@ export function accountKey(id: string): string {
/** Minimal session shape the registry needs — provided by the consumer. */ /** Minimal session shape the registry needs — provided by the consumer. */
export interface RegistrySession { export interface RegistrySession {
sessionId: string; /** Relayed untouched to `ng` — upstream declares it `string | number` (`index.d.ts:266`). */
sessionId: string | number;
/** The shared wallet's private store id — the pointer anchor. */ /** The shared wallet's private store id — the pointer anchor. */
privateStoreId: string; privateStoreId: string;
/** The shared wallet's protected store id (native store). Optional: only the /** The shared wallet's protected store id (native store). Optional: only the
@@ -55,7 +55,7 @@ import type { Nuri } from "../model/types";
* afterwards, which the caller decides by filing the cap among the caps that holder holds. * afterwards, which the caller decides by filing the cap among the caps that holder holds.
*/ */
export async function physicalCreate( export async function physicalCreate(
sessionId: string, sessionId: string | number,
crdt = "Graph", crdt = "Graph",
cls = "data:graph", cls = "data:graph",
dest = "store", dest = "store",
@@ -81,7 +81,7 @@ export async function physicalCreate(
* read a virtual user's content that is `docs.sparqlQuery`, which is confined. * read a virtual user's content that is `docs.sparqlQuery`, which is confined.
*/ */
export async function physicalQuery( export async function physicalQuery(
sessionId: string, sessionId: string | number,
query: string, query: string,
base: string | undefined, base: string | undefined,
anchor: Nuri, anchor: Nuri,
@@ -95,7 +95,7 @@ export async function physicalQuery(
/** Write as the PHYSICAL user — the shim's own records. See {@link physicalQuery}. */ /** Write as the PHYSICAL user — the shim's own records. See {@link physicalQuery}. */
export async function physicalUpdate( export async function physicalUpdate(
sessionId: string, sessionId: string | number,
query: string, query: string,
anchor: Nuri, anchor: Nuri,
label = "physicalUpdate", label = "physicalUpdate",
@@ -95,15 +95,15 @@ export function sessionIsComing(): boolean {
* *
* The session id is RELAYED, never rebuilt and that is load-bearing * The session id is RELAYED, never rebuilt and that is load-bearing
* Upstream declares it `string | number` (`Session`, `index.d.ts:266`) and the broker * Upstream declares it `string | number` (`Session`, `index.d.ts:266`) and the broker
* returns a NUMBER; the whole chain below here types it `string` and hands it to `ng.*`, * returns a NUMBER; the chain below here now types it the same way and hands it to `ng.*`,
* whose binding takes it as-is. So this reads the field and passes it on untouched. It is * whose binding takes it as-is. So this reads the field and passes it on untouched. It is
* not a detail: normalizing it to a string was written here first, and the applicative e2e * not a detail: normalizing it to a string was written here first, and the applicative e2e
* refused every call in the batch with `Deserialization error of session_id JsValue("1")` * refused every call in the batch with `Deserialization error of session_id JsValue("1")`
* the wasm side deserializes the id by its own type, and a stringified number is not it. * the wasm side deserializes the id by its own type, and a stringified number is not it.
* *
* The `string` in the declared shape is therefore inherited, not asserted: the inaccuracy * The chain used to narrow it to `string`, which made the value the SDK hands an application
* is the chain's and predates this module (every consumer's thunk declared it the same way * unpassable through this library. Widened to upstream's own declared type on 2026-08-14, at
* and relayed the same value). Widening it belongs to the chain, not to the capture. * the capture and along every hop that relays it; the id is still only ever RELAYED.
*/ */
export function captureSession(event: unknown): boolean { export function captureSession(event: unknown): boolean {
if (typeof event !== "object" || event === null) return false; if (typeof event !== "object" || event === null) return false;
@@ -116,7 +116,7 @@ export function captureSession(event: unknown): boolean {
protected_store_id: protectedStoreId, protected_store_id: protectedStoreId,
public_store_id: publicStoreId, public_store_id: publicStoreId,
} = session as { } = session as {
session_id?: string; session_id?: string | number;
private_store_id?: string; private_store_id?: string;
protected_store_id?: string; protected_store_id?: string;
public_store_id?: string; public_store_id?: string;
+19 -4
View File
@@ -44,8 +44,15 @@ function rowCount(result: unknown): number {
* document in the (shared) private store: `docCreate(sid, "Graph", "data:graph", * document in the (shared) private store: `docCreate(sid, "Graph", "data:graph",
* "store")` (store_repo left undefined → private store). * "store")` (store_repo left undefined → private store).
*/ */
// The session id is `string | number` because that is what upstream DECLARES for it
// (`Session.session_id`, `sdk/js/web/src/index.ts:16` and the installed `index.d.ts:266`),
// and the wasm side deserializes it as a `u64` (`sdk/js/lib-wasm/src/lib.rs:352-358`
// `sparql_query`, `:452-457` `sparql_update`, `:1575` `doc_create`). It only ever TRAVELS
// through this chain — never normalise it, and above all never stringify it: a JS string
// fails that deserialization, observed live as
// `Deserialization error of session_id JsValue("1")`.
export async function docCreate( export async function docCreate(
sessionId: string, sessionId: string | number,
crdt: string, crdt: string,
cls: string, cls: string,
dest: string, dest: string,
@@ -82,13 +89,21 @@ export async function docCreate(
* *
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the * Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
* document NURI the update is scoped/base'd to (optional). * document NURI the update is scoped/base'd to (optional).
*
* Returns what the real method returns: upstream answers the COMMITS the update
* produced (`sdk/js/lib-wasm/src/lib.rs:481-483` serialises `AppResponseV0::Commits`;
* the installed `index.d.ts:297` types it `Promise<any>`). This function already
* relayed that value at runtime only the declared type said `void`, which threw the
* answer away for every caller. Typed `unknown` rather than `any`, exactly as
* {@link sparqlQuery} already renders the same upstream `Promise<any>`: the value is
* the broker's to shape, and a caller that ignores it is unaffected.
*/ */
export async function sparqlUpdate( export async function sparqlUpdate(
sessionId: string, sessionId: string | number,
query: string, query: string,
anchorLike?: NuriLike, anchorLike?: NuriLike,
label = "sparqlUpdate", label = "sparqlUpdate",
): Promise<void> { ): Promise<unknown> {
const { ng } = getConfig(); const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate"); const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate");
// The boundary, in two questions that are NOT the same one. // The boundary, in two questions that are NOT the same one.
@@ -117,7 +132,7 @@ export async function sparqlUpdate(
* query base IRI (usually `undefined`); `anchor` is the document NURI to query. * query base IRI (usually `undefined`); `anchor` is the document NURI to query.
*/ */
export async function sparqlQuery( export async function sparqlQuery(
sessionId: string, sessionId: string | number,
query: string, query: string,
base?: string, base?: string,
anchorLike?: NuriLike, anchorLike?: NuriLike,
+5 -3
View File
@@ -84,7 +84,7 @@ const P = {
/** The inbox documents live in the shared wallet, so we reuse the registry's /** The inbox documents live in the shared wallet, so we reuse the registry's
* injected session provider for the sessionId. Disappears at migration. */ * injected session provider for the sessionId. Disappears at migration. */
async function sessionId(): Promise<string> { async function sessionId(): Promise<string | number> {
return (await getStoreRegistryDeps().getSession()).sessionId; return (await getStoreRegistryDeps().getSession()).sessionId;
} }
@@ -519,8 +519,10 @@ export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
return delivered; return delivered;
} }
/** Alias for {@link read} — the name that reads as "process the inbox now". */ // `materialize` — a second published name for {@link read} — was REMOVED from the surface
export const materialize = read; // on 2026-08-14. It was an alias and nothing more: no call site anywhere, and upstream has
// no such member, so it was a symbol an application could learn and would have to unlearn.
// `read` is the name; the word "materialize" survives only as prose in the docs.
/** /**
* COLD, BARRIER-GATED read of `targetInbox` the reliable "process the inbox at * COLD, BARRIER-GATED read of `targetInbox` the reliable "process the inbox at
+65 -47
View File
@@ -4,61 +4,79 @@
* surface stays identical to `@ng-org/web`'s `ng`. * surface stays identical to `@ng-org/web`'s `ng`.
*/ */
import type { NG } from "@ng-org/web";
import { getConfig, getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; import { getConfig, getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import type { Nuri } from "../model/types"; import type { Nuri } from "../model/types";
export function makeNg(): Record<string, any> { /**
return new Proxy({} as Record<string, any>, { * Typed `NG` upstream's own type for `ng` (`NG = typeof NGModule`, 88 exported members,
get(_target, prop: string) { * installed `index.d.ts:136-231`). The proxy adds no member and removes none, so this is
* the honest declaration; it used to be `Record<string, any>`, which silently dropped all
* 88 signatures and let any misspelt member typecheck.
*
* The empty object is only the Proxy's TARGET: every property is answered by the `get`
* trap below, never by the target, so there is nothing to put in it. Typing the target is
* how `new Proxy` carries a type to its result not an escape hatch, and the surface it
* announces is checked against upstream's for every caller.
*/
export function makeNg(): NG {
return new Proxy({} as NG, {
get(_target, prop: string | symbol) {
const { ng } = getConfig(); const { ng } = getConfig();
// The overrides below are keyed by NAME, so they are only consulted for a string
// property. A symbol (`Symbol.toStringTag`, `Symbol.asyncIterator`, …) skips them and
// falls to the same passthrough as everything else at the bottom — bound identically,
// so the proxy stays transparent for every kind of key.
if (typeof prop === "string") {
// session_start → open the SHARED wallet invisibly.
//
// `login` used to be listed here too. `@ng-org/web` exposes no such method —
// zero occurrences in the installed declarations and in `sdk/js/lib-wasm/src/lib.rs`
// — so the proxy FABRICATED a member: `ng.login` answered a function instead of
// `undefined`, and calling it threw. The one place this wrapper added to the SDK
// surface, against its own header. Removed 2026-08-03.
if (prop === "session_start") {
return (...args: any[]) => {
// TODO(polyfill): supply shared-wallet credentials so no wallet UI
// is shown. For now, passthrough.
return ng[prop]!(...args);
};
}
// session_start → open the SHARED wallet invisibly. // sparql_update → write guard (emulated write-cap check).
// // Mirrors the target broker/verifier: a write is refused unless the wallet
// `login` used to be listed here too. `@ng-org/web` exposes no such method — // holds the document's WRITE cap. Emulated per-document via CapRegistry.
// zero occurrences in the installed declarations and in `sdk/js/lib-wasm/src/lib.rs` // args = (session_id, query, anchor?) — `anchor` is the target doc NURI.
// — so the proxy FABRICATED a member: `ng.login` answered a function instead of if (prop === "sparql_update") {
// `undefined`, and calling it threw. The one place this wrapper added to the SDK return (...args: any[]) => {
// surface, against its own header. Removed 2026-08-03. const anchor = args[2] as Nuri | undefined;
if (prop === "session_start") { const caps = getCaps();
return (...args: any[]) => { // Passthrough (no regression) unless a WRITE policy exists AND this
// TODO(polyfill): supply shared-wallet credentials so no wallet UI // specific document is governed by it. Ungoverned docs (mono-store
// is shown. For now, passthrough. // default, no cap declared) flow through exactly as before.
return ng[prop]!(...args); if (
}; typeof anchor === "string" &&
caps.hasWritePolicy() &&
caps.governsWrite(anchor) &&
!caps.canWrite(anchor, getCurrentUser())
) {
return Promise.reject(
new Error(
`[ng-eventually] write denied: current user lacks the write cap for ${anchor}`,
),
);
}
return ng.sparql_update!(...args);
};
}
// TODO(anticipated API): a sealed inbox deposit + capability operations — expose
// here with their anticipated signatures, emulated for now.
} }
// sparql_update → write guard (emulated write-cap check).
// Mirrors the target broker/verifier: a write is refused unless the wallet
// holds the document's WRITE cap. Emulated per-document via CapRegistry.
// args = (session_id, query, anchor?) — `anchor` is the target doc NURI.
if (prop === "sparql_update") {
return (...args: any[]) => {
const anchor = args[2] as Nuri | undefined;
const caps = getCaps();
// Passthrough (no regression) unless a WRITE policy exists AND this
// specific document is governed by it. Ungoverned docs (mono-store
// default, no cap declared) flow through exactly as before.
if (
typeof anchor === "string" &&
caps.hasWritePolicy() &&
caps.governsWrite(anchor) &&
!caps.canWrite(anchor, getCurrentUser())
) {
return Promise.reject(
new Error(
`[ng-eventually] write denied: current user lacks the write cap for ${anchor}`,
),
);
}
return ng.sparql_update!(...args);
};
}
// TODO(anticipated API): a sealed inbox deposit + capability operations — expose
// here with their anticipated signatures, emulated for now.
// Everything else: passthrough to the real SDK, unchanged. // Everything else: passthrough to the real SDK, unchanged.
const real = ng[prop]; const real = Reflect.get(ng, prop);
return typeof real === "function" ? real.bind(ng) : real; return typeof real === "function" ? real.bind(ng) : real;
}, },
}); });
+2 -2
View File
@@ -97,7 +97,7 @@ function bindings(
return anyRes.results?.bindings ?? []; return anyRes.results?.bindings ?? [];
} }
async function sessionId(): Promise<string> { async function sessionId(): Promise<string | number> {
return (await getStoreRegistryDeps().getSession()).sessionId; return (await getStoreRegistryDeps().getSession()).sessionId;
} }
@@ -122,7 +122,7 @@ async function sessionId(): Promise<string> {
* store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO). * store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO).
*/ */
async function readDoc( async function readDoc(
sid: string, sid: string | number,
doc: Nuri, doc: Nuri,
): Promise<Array<Record<string, { value: string } | undefined>>> { ): Promise<Array<Record<string, { value: string } | undefined>>> {
try { try {
+1 -1
View File
@@ -79,7 +79,7 @@ export function docChangeType(resp: DocChange): DocChangeType {
/** An unsubscribe function — idempotent (calling it twice is a no-op). */ /** An unsubscribe function — idempotent (calling it twice is a no-op). */
export type Unsubscribe = () => void; export type Unsubscribe = () => void;
async function sessionId(): Promise<string> { async function sessionId(): Promise<string | number> {
return (await getStoreRegistryDeps().getSession()).sessionId; return (await getStoreRegistryDeps().getSession()).sessionId;
} }
+42 -1
View File
@@ -29,10 +29,16 @@ import { configure } from "../src/index";
import { setCurrentUser } from "../src/shared-wallet/bootstrap"; import { setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps } from "../src/shared-wallet/bootstrap"; import { resetCaps } from "../src/shared-wallet/bootstrap";
// What a real `ng.sparql_update` answers: the COMMITS the update produced
// (`sdk/js/lib-wasm/src/lib.rs:481-483` serialises `AppResponseV0::Commits`). The fake
// used to answer `undefined` — a result the broker never returns — which is precisely
// what let the surface declare `Promise<void>` unchallenged.
const COMMITS = [{ id: "did:ng:c:commit-1" }, { id: "did:ng:c:commit-2" }];
function fakeNg() { function fakeNg() {
return { return {
doc_create: mock(async (..._a: unknown[]) => "did:ng:o:new-doc"), doc_create: mock(async (..._a: unknown[]) => "did:ng:o:new-doc"),
sparql_update: mock(async (..._a: unknown[]) => undefined), sparql_update: mock(async (..._a: unknown[]) => COMMITS),
sparql_query: mock(async (..._a: unknown[]) => ({ results: { bindings: [] } })), sparql_query: mock(async (..._a: unknown[]) => ({ results: { bindings: [] } })),
// A sentinel: makeNg(), if ever used, would `.bind` and call THIS through // A sentinel: makeNg(), if ever used, would `.bind` and call THIS through
// the JS Proxy. We assert the primitives call the raw fns above directly. // the JS Proxy. We assert the primitives call the raw fns above directly.
@@ -83,6 +89,41 @@ test("sparqlQuery forwards (sessionId, query, base, anchor) and returns the raw
]); ]);
}); });
// ── the session id: RELAYED, never converted ──────────────────────────────────
// Upstream declares it `string | number` (`Session.session_id`, `index.d.ts:266`) and the
// broker hands back a NUMBER, which the wasm binding deserializes as a `u64`. Stringifying
// it fails that deserialization for real (`Deserialization error of session_id JsValue("1")`),
// so what reaches the boundary must be the very value the caller passed — same `typeof`.
test("a NUMERIC session id reaches ng untouched, still a number", async () => {
const ng = inject();
// Anchor the update and the read on the document just CREATED: creating it mints its
// cap, so the reach guard (process-wide once any cap exists) is satisfied the way a real
// application satisfies it — rather than by naming a document this user does not hold.
const created = await docCreate(1, "Graph", "data:graph", "store", undefined);
await sparqlUpdate(2, "INSERT DATA {}", created);
await sparqlQuery(3, "SELECT * {}", undefined, created);
const createdSid = ng.doc_create.mock.calls[0]![0];
expect(createdSid).toBe(1);
expect(typeof createdSid).toBe("number");
const updated = ng.sparql_update.mock.calls[0]![0];
expect(updated).toBe(2);
expect(typeof updated).toBe("number");
const queried = ng.sparql_query.mock.calls[0]![0];
expect(queried).toBe(3);
expect(typeof queried).toBe("number");
});
test("sparqlUpdate hands back what the boundary returned", async () => {
// The commits must arrive at the caller unchanged — the surface used to declare
// `Promise<void>` and throw this answer away.
inject();
const returned = await sparqlUpdate("sid-c", "INSERT DATA {}", "did:ng:o:a");
expect(returned).toBe(COMMITS);
});
test("the primitives do NOT route through the public ng proxy (makeNg)", async () => { test("the primitives do NOT route through the public ng proxy (makeNg)", async () => {
// makeNg builds a JS Proxy over the injected ng. If a primitive went through // makeNg builds a JS Proxy over the injected ng. If a primitive went through
// it, calls would land on the proxy's `get` trap, not on our raw mock fns. // it, calls would land on the proxy's `get` trap, not on our raw mock fns.
+13 -3
View File
@@ -1,5 +1,6 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test"; import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { post, read, materialize, watch } from "../src/surface/inbox"; import { post, read, watch } from "../src/surface/inbox";
import * as polyfill from "../src/index";
import { userInbox, resetRegistryCache } from "../src/shared-wallet/account-registry"; import { userInbox, resetRegistryCache } from "../src/shared-wallet/account-registry";
import type { Deposit } from "../src/surface/inbox"; import type { Deposit } from "../src/surface/inbox";
import { configure } from "../src/index"; import { configure } from "../src/index";
@@ -249,14 +250,23 @@ test("from: null makes an anonymous deposit even when a current user is set", as
expect(deposits[0]!.from).toBeNull(); expect(deposits[0]!.from).toBeNull();
}); });
test("read returns deposits sorted by ts ascending and materialize is an alias", async () => { test("read returns deposits sorted by ts ascending", async () => {
await post(TARGET, { from: null, payload: "second", ts: 300 }); await post(TARGET, { from: null, payload: "second", ts: 300 });
await post(TARGET, { from: null, payload: "first", ts: 100 }); await post(TARGET, { from: null, payload: "first", ts: 100 });
await post(TARGET, { from: null, payload: "third", ts: 500 }); await post(TARGET, { from: null, payload: "third", ts: 500 });
const deposits = await materialize(TARGET); const deposits = await read(TARGET);
expect(deposits.map((d) => d.payload)).toEqual(["first", "second", "third"]); expect(deposits.map((d) => d.payload)).toEqual(["first", "second", "third"]);
}); });
test("the published inbox surface exposes `read` and no `materialize` alias", () => {
// `materialize` was a second published name for `read` — a symbol an application could
// learn and would have to unlearn, since upstream has no such member. This asserts the
// PUBLISHED entry (`src/index.ts`'s `inbox` namespace), which is what an app imports,
// not merely the module it re-exports.
expect(typeof polyfill.inbox.read).toBe("function");
expect("materialize" in polyfill.inbox).toBe(false);
});
test("read is scoped to one inbox — deposits in another inbox are not returned", async () => { test("read is scoped to one inbox — deposits in another inbox are not returned", async () => {
// The OTHER inbox is obtained from the system, not invented. A made-up NURI would be // The OTHER inbox is obtained from the system, not invented. A made-up NURI would be
// a target no deposit can legitimately reach (`inbox.post` refuses what is not an // a target no deposit can legitimately reach (`inbox.post` refuses what is not an
+4 -1
View File
@@ -83,6 +83,9 @@ test("write guard: passthrough when anchor is omitted (cannot scope the guard)",
getCaps().grantWrite(DOC, "alice"); getCaps().grantWrite(DOC, "alice");
setCurrentUser("bob"); setCurrentUser("bob");
const proxy = makeNg(); const proxy = makeNg();
await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough // Anchor explicitly `undefined` — upstream declares all three parameters (`index.d.ts:297`),
// and the guard reads `args[2]`, which is `undefined` whether the argument is omitted or
// passed as such. Same branch, same passthrough.
await proxy.sparql_update("sid", "INSERT DATA {}", undefined); // no anchor → passthrough
expect(ng.sparql_update).toHaveBeenCalledTimes(1); expect(ng.sparql_update).toHaveBeenCalledTimes(1);
}); });