Files
ng-eventually/packages/polyfill/src/surface/docs.ts
T
Sylvain Duchesne 12eba6eea6 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.
2026-08-14 10:00:40 +02:00

169 lines
8.3 KiB
TypeScript

/**
* Low-level document + SPARQL primitives.
*
* These call the real injected `ng` (`getConfig().ng`) directly — never the
* public `ng` proxy (`makeNg`). This is a validated hard constraint, not a style
* choice: the public `ng` is a JS `Proxy` over `@ng-org/web`'s iframe-RPC proxy,
* and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling
* with **`DataCloneError: function ... could not be cloned`** — the footgun this
* rule exists to prevent. Reaching the real `ng` held in the config avoids the
* double-proxy. Do not import from `./ng-proxy`.
*
* Signatures mirror the real `@ng-org/web` `ng` surface (verified against the
* app's storeRegistry usage), so this is a drop-in for those raw calls.
*/
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
import { logAccess, enabled as accessLogEnabled } from "../shared-wallet/access-log";
import { isNuri, toNuri } from "../model/nuri";
import { assertMayReach, assertMayWrite } from "../emulated-verifier/reach";
import { fetchReadCap } from "../emulated-verifier/public-store";
import type { Nuri, NuriLike } from "../model/types";
// The low common point for ALL document access: every read in the SDK routes
// through `sparqlQuery`, every write through `sparqlUpdate` (+ container creation
// through `docCreate`) — each ultimately calling the real injected `ng` here. The
// access log is therefore instrumented HERE so no access path escapes it. Callers
// pass a semantic `label` (readDoc|readUnion|listMyEntityDocs|writeEntity|deposit
// |…); it is a lib-internal probe param, NOT forwarded to the real `ng` (the docs
// primitives forward the exact SDK signature — see test/docs.test.ts). When the
// log is OFF (default) the extra param is inert and costs one boolean read.
/** Count rows in a raw SPARQL SELECT result, tolerant of the possible shapes. */
function rowCount(result: unknown): number {
if (!result) return 0;
if (Array.isArray(result)) return result.length;
const anyRes = result as { results?: { bindings?: unknown[] } };
return anyRes.results?.bindings?.length ?? 0;
}
/**
* Create one document → its NURI.
*
* Mirrors `ng.doc_create(session_id, crdt, cls, dest, store_repo?)`. For a graph
* document in the (shared) private store: `docCreate(sid, "Graph", "data:graph",
* "store")` (store_repo left undefined → private store).
*/
// 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 | number,
crdt: string,
cls: string,
dest: string,
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
// The BROKER boundary. `ng` is a permissive property bag (`NgLike`), so what
// comes back is `any` and this function's `Promise<Nuri>` would otherwise be an
// unchecked promise — every typed NURI downstream rests on it. Validate once,
// here, rather than let a non-reference propagate as a document.
if (typeof nuri !== "string" || !isNuri(nuri)) {
throw new Error(
`[ng-eventually] docCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
);
}
// **Creating a document gives you its cap.** Upstream that is not a courtesy but
// the mechanism: `doc_create` commits `AddRepo { read_cap }` to the store's Store
// branch, so the creator holds it from the first instant. Without this, a caller
// could create a document through this primitive and then be refused reading or
// writing it — which is what the e2e run against the live broker exposed.
//
// `physical.ts`'s counterpart deliberately does NOT do this: the shim's own
// documents belong to no virtual user, and `store-registry` files their caps
// itself, where it knows whose they are.
getCaps().mint(nuri);
// A container creation is a WRITE; the NURI only exists after the call.
logAccess("WRITE", nuri, "docCreate");
return nuri;
}
/**
* Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.).
*
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
* document NURI the update is scoped/base'd to (optional).
*
* 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 | number,
query: string,
anchorLike?: NuriLike,
label = "sparqlUpdate",
): 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.
//
// Reaching is possession. Writing is OWNERSHIP — upstream the right to write is
// membership of the repo (`verify_permission`, reachable only from `Commit::verify`,
// so on commits and never on reads), and how you came by the READ key changes nothing
// about it. A public store hands its read cap to whoever asks; a cap deposited in your
// inbox is a Link someone gave you. Neither makes you a member.
//
// NO public-store fetch here, unlike the read below: asking the network for a read key
// has no bearing on a write.
if (anchor !== undefined) {
assertMayReach(anchor, "docs.sparqlUpdate");
await assertMayWrite(anchor, "docs.sparqlUpdate");
}
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
logAccess("WRITE", anchor ?? "(no anchor)", label);
return ng.sparql_update(sessionId, query, anchor);
}
/**
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
*
* Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the
* query base IRI (usually `undefined`); `anchor` is the document NURI to query.
*/
export async function sparqlQuery(
sessionId: string | number,
query: string,
base?: string,
anchorLike?: NuriLike,
label = "sparqlQuery",
): Promise<unknown> {
const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlQuery");
// The boundary: an ANCHORED read may only touch what the connected virtual user
// reaches. An anchorless query spans the local union — a different problem (it is
// O(wallet size), and the read path never uses it), not one this guard can bound.
//
// Asking the (emulated) network first, as `ensureRepoOpen` does: a document in a
// public store gives its cap to whoever asks, and this is a door an application can
// reach with nothing but a bare reference. Inert once the cap is held, memoised
// otherwise — see public-store.ts.
if (anchor !== undefined) {
await fetchReadCap(anchor);
assertMayReach(anchor, "docs.sparqlQuery");
}
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
const result = await ng.sparql_query(sessionId, query, base, anchor);
// Log AFTER the read so the row count (a strong leak signal: a doc rendering
// rows under an identity that should see nothing) can be appended. Skip the
// rowCount work entirely when the log is off.
if (accessLogEnabled()) {
// `rows` here are raw RDF triple bindings (the SPARQL `?s ?p ?o` result), NOT
// domain objects — one document's entity is spread across several triple rows.
// Spell that out so the log isn't mistaken for an object count (the app-level
// object/shape count is logged separately by useShapeQuery → dataStats).
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " triple-rows");
}
return result;
}