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
@@ -32,7 +32,7 @@ import type { Nuri } from "../model/types";
* nothing else. Never exported from the package.
*/
export async function registerUpdate(
sessionId: string,
sessionId: string | number,
query: string,
anchor: Nuri,
label = "registerUpdate",
@@ -66,7 +66,7 @@ export async function registerUpdate(
* only caller, and reading is guarded separately (`inbox.read`).
*/
export async function depositInto(
sessionId: string,
sessionId: string | number,
query: string,
targetInbox: Nuri,
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 type { SharedWalletConfig } from "./shared-wallet/access-gate";
import type { NG } from "@ng-org/web";
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. */
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. */
privateStoreId: string;
/** 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.
*/
export async function physicalCreate(
sessionId: string,
sessionId: string | number,
crdt = "Graph",
cls = "data:graph",
dest = "store",
@@ -81,7 +81,7 @@ export async function physicalCreate(
* read a virtual user's content — that is `docs.sparqlQuery`, which is confined.
*/
export async function physicalQuery(
sessionId: string,
sessionId: string | number,
query: string,
base: string | undefined,
anchor: Nuri,
@@ -95,7 +95,7 @@ export async function physicalQuery(
/** Write as the PHYSICAL user — the shim's own records. See {@link physicalQuery}. */
export async function physicalUpdate(
sessionId: string,
sessionId: string | number,
query: string,
anchor: Nuri,
label = "physicalUpdate",
@@ -95,15 +95,15 @@ export function sessionIsComing(): boolean {
*
* ── 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
* 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
* 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")`
* — 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
* is the chain's and predates this module (every consumer's thunk declared it the same way
* and relayed the same value). Widening it belongs to the chain, not to the capture.
* The chain used to narrow it to `string`, which made the value the SDK hands an application
* unpassable through this library. Widened to upstream's own declared type on 2026-08-14, at
* the capture and along every hop that relays it; the id is still only ever RELAYED.
*/
export function captureSession(event: unknown): boolean {
if (typeof event !== "object" || event === null) return false;
@@ -116,7 +116,7 @@ export function captureSession(event: unknown): boolean {
protected_store_id: protectedStoreId,
public_store_id: publicStoreId,
} = session as {
session_id?: string;
session_id?: string | number;
private_store_id?: string;
protected_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",
* "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(
sessionId: string,
sessionId: string | number,
crdt: string,
cls: string,
dest: string,
@@ -82,13 +89,21 @@ export async function docCreate(
*
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
* 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(
sessionId: string,
sessionId: string | number,
query: string,
anchorLike?: NuriLike,
label = "sparqlUpdate",
): Promise<void> {
): Promise<unknown> {
const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate");
// 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.
*/
export async function sparqlQuery(
sessionId: string,
sessionId: string | number,
query: string,
base?: string,
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
* 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;
}
@@ -519,8 +519,10 @@ export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
return delivered;
}
/** Alias for {@link read} — the name that reads as "process the inbox now". */
export const materialize = read;
// `materialize` — a second published name for {@link read} — was REMOVED from the surface
// 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
+65 -47
View File
@@ -4,61 +4,79 @@
* 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 type { Nuri } from "../model/types";
export function makeNg(): Record<string, any> {
return new Proxy({} as Record<string, any>, {
get(_target, prop: string) {
/**
* Typed `NG` — upstream's own type for `ng` (`NG = typeof NGModule`, 88 exported members,
* 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();
// 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.
//
// `login` used to be listed here too. `@ng-org/web` exposes no such method —
// zero occurrences in the installed declarations and in `sdk/js/lib-wasm/src/lib.rs`
// — so the proxy FABRICATED a member: `ng.login` answered a function instead of
// `undefined`, and calling it threw. The one place this wrapper added to the SDK
// surface, against its own header. Removed 2026-08-03.
if (prop === "session_start") {
return (...args: any[]) => {
// TODO(polyfill): supply shared-wallet credentials so no wallet UI
// is shown. For now, passthrough.
return ng[prop]!(...args);
};
// sparql_update → write guard (emulated write-cap check).
// Mirrors the target broker/verifier: a write is refused unless the wallet
// holds the document's WRITE cap. Emulated per-document via CapRegistry.
// args = (session_id, query, anchor?) — `anchor` is the target doc NURI.
if (prop === "sparql_update") {
return (...args: any[]) => {
const anchor = args[2] as Nuri | undefined;
const caps = getCaps();
// Passthrough (no regression) unless a WRITE policy exists AND this
// specific document is governed by it. Ungoverned docs (mono-store
// default, no cap declared) flow through exactly as before.
if (
typeof anchor === "string" &&
caps.hasWritePolicy() &&
caps.governsWrite(anchor) &&
!caps.canWrite(anchor, getCurrentUser())
) {
return Promise.reject(
new Error(
`[ng-eventually] write denied: current user lacks the write cap for ${anchor}`,
),
);
}
return ng.sparql_update!(...args);
};
}
// TODO(anticipated API): a sealed inbox deposit + capability operations — expose
// here with their anticipated signatures, emulated for now.
}
// sparql_update → write guard (emulated write-cap check).
// Mirrors the target broker/verifier: a write is refused unless the wallet
// holds the document's WRITE cap. Emulated per-document via CapRegistry.
// args = (session_id, query, anchor?) — `anchor` is the target doc NURI.
if (prop === "sparql_update") {
return (...args: any[]) => {
const anchor = args[2] as Nuri | undefined;
const caps = getCaps();
// Passthrough (no regression) unless a WRITE policy exists AND this
// specific document is governed by it. Ungoverned docs (mono-store
// default, no cap declared) flow through exactly as before.
if (
typeof anchor === "string" &&
caps.hasWritePolicy() &&
caps.governsWrite(anchor) &&
!caps.canWrite(anchor, getCurrentUser())
) {
return Promise.reject(
new Error(
`[ng-eventually] write denied: current user lacks the write cap for ${anchor}`,
),
);
}
return ng.sparql_update!(...args);
};
}
// TODO(anticipated API): a sealed inbox deposit + capability operations — expose
// here with their anticipated signatures, emulated for now.
// Everything else: passthrough to the real SDK, unchanged.
const real = ng[prop];
const real = Reflect.get(ng, prop);
return typeof real === "function" ? real.bind(ng) : real;
},
});
+2 -2
View File
@@ -97,7 +97,7 @@ function bindings(
return anyRes.results?.bindings ?? [];
}
async function sessionId(): Promise<string> {
async function sessionId(): Promise<string | number> {
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).
*/
async function readDoc(
sid: string,
sid: string | number,
doc: Nuri,
): Promise<Array<Record<string, { value: string } | undefined>>> {
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). */
export type Unsubscribe = () => void;
async function sessionId(): Promise<string> {
async function sessionId(): Promise<string | number> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}