Files
ng-eventually/packages/sdk/src/surface/docs.ts
T
Sylvain Duchesne 0eb25286c8 refactor: renommer client → sdk, et fusionner les deux portes en une
Deux mouvements de surface, aucun changement de comportement.

**`packages/client` → `packages/sdk`, `@ng-eventually/client` → `@ng-eventually/sdk`.**
« client » ne disait rien : ce paquet EST le SDK que l'application appelle, et c'est
tout ce qu'elle appelle. L'ancien nom reste comme mot-clé de recherche dans
`docs/source-layout-by-fate.md` et le tableau des paquets du README.

**Une seule entrée.** L'entrée `./polyfill` disparaît ; ses symboles applicatifs —
`configure`, `configureStoreRegistry`, `setCurrentUser`, `connectedUser` et leurs types
— vivent dans un bloc `POLYFILL-ERA` de `src/index.ts`.

Ce que la seconde porte portait mérite d'être nommé avant d'être retiré : *ce qu'on
importe de ce chemin est exactement ce qu'on supprimera à la migration*. Une seule
porte perd ce signal — rien à la ligne d'import ne distingue `configure`, qui part, de
`docs`, que le vrai SDK remplace sur place. Trois choses le portent désormais : le bloc
lui-même, l'inventaire d'exports de `docs/api-contract.md` (épinglé par
`test/vocabulary.test.ts`, donc il ne peut pas rancir en silence), et le contrôle de
vocabulaire sur les noms publiés.

**Six symboles quittent la surface au passage**, et la fusion est ce qui a rendu le
choix visible plutôt qu'hérité :

- `getConfig` / `getStoreRegistryDeps` — câblage interne, atteint par
  `shared-wallet/bootstrap` ;
- `resetConfig` / `resetStoreRegistry` / `resetCaps` — remises à zéro de test, atteintes
  par leur chemin interne, ce qui est leur raison d'être ;
- le `share` direct — `inbox.share` a toujours été la même fonction, et la publier deux
  fois brouillait la frontière qu'elle servait à marquer.

Corrections d'affirmations fausses trouvées en chemin : le contrat annonçait `isNuri` /
`hasReadCap` sur la porte SDK alors qu'ils ne sont plus exportés depuis le passage au
permissif en entrée (`NuriLike` validé à la porte) ; le README du paquet documentait
`capFor`, `shareCap`, `getCaps` et `publishRepoLink`, dont aucun n'existe ; et le README
de l'app d'exemple affirmait que la suite e2e la pilote, ce qui reste à faire.

179 tests unitaires, typecheck bibliothèque / exemple / harnais, e2e 42/42 contre le
broker en ligne — mesuré une fois après le renommage, une fois après la fusion.
2026-08-07 11:16:57 +02:00

176 lines
8.1 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).
*/
export async function docCreate(
sessionId: string,
crdt: string,
cls: string,
dest: string,
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
// The BROKER boundary. `ng` is a permissive property bag (`NgLike`), so what
// comes back is `any` and this function's `Promise<Nuri>` would otherwise be an
// unchecked promise — every typed NURI downstream rests on it. Validate once,
// here, rather than let a non-reference propagate as a document.
if (typeof nuri !== "string" || !isNuri(nuri)) {
throw new Error(
`[ng-eventually] docCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
);
}
// **Creating a document gives you its cap.** Upstream that is not a courtesy but
// the mechanism: `doc_create` commits `AddRepo { read_cap }` to the store's Store
// branch, so the creator holds it from the first instant. Without this, a caller
// could create a document through this primitive and then be refused reading or
// writing it — which is what the e2e run against the live broker exposed.
//
// `physical.ts`'s counterpart deliberately does NOT do this: the shim's own
// documents belong to no virtual user, and `store-registry` files their caps
// itself, where it knows whose they are.
getCaps().mint(nuri);
// A container creation is a WRITE; the NURI only exists after the call.
logAccess("WRITE", nuri, "docCreate");
return nuri;
}
/**
* Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.).
*
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
* document NURI the update is scoped/base'd to (optional).
*/
export async function sparqlUpdate(
sessionId: string,
query: string,
anchorLike?: NuriLike,
label = "sparqlUpdate",
): Promise<void> {
const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate");
// The boundary: a write may only touch what the connected virtual user reaches.
//
// NO public-store fetch here, unlike the read below, and that asymmetry is the point:
// a public store makes its repos world-READABLE. Writing needs the write cap, which
// it never serves. A cap this holder has only because the network handed it over is
// therefore refused a write outright — otherwise a bare reference to a public
// document would buy one, and a consumer would build on something that fails upstream.
if (anchor !== undefined) {
assertMayReach(anchor, "docs.sparqlUpdate");
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);
}
/**
* Deposit into ANOTHER virtual user's inbox — the one write that legitimately
* crosses the boundary, and therefore the one that skips {@link assertMayReach}.
*
* Why this is a separate primitive rather than a flag: depositing is not "a write
* that happens to be allowed", it is a different act. You cannot read the inbox you
* deposit into, you hold no cap for it, and you get nothing back — upstream it is an
* anonymous sealed box. Naming the exception makes it greppable and keeps
* {@link sparqlUpdate} free of a bypass that would otherwise be reusable for
* anything.
*
* The recipient's ownership of the inbox is what bounds this: `inbox.post` is the
* only caller, and reading is guarded separately (`inbox.read`).
*/
export async function depositInto(
sessionId: string,
query: string,
targetInbox: Nuri,
label = "deposit",
): Promise<void> {
const { ng } = getConfig();
logAccess("WRITE", targetInbox, label, " (cross-user deposit)");
return ng.sparql_update(sessionId, query, targetInbox);
}
/**
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
*
* Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the
* query base IRI (usually `undefined`); `anchor` is the document NURI to query.
*/
export async function sparqlQuery(
sessionId: string,
query: string,
base?: string,
anchorLike?: NuriLike,
label = "sparqlQuery",
): Promise<unknown> {
const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlQuery");
// The boundary: an ANCHORED read may only touch what the connected virtual user
// reaches. An anchorless query spans the local union — a different problem (it is
// O(wallet size), and the read path never uses it), not one this guard can bound.
//
// Asking the (emulated) network first, as `ensureRepoOpen` does: a document in a
// public store gives its cap to whoever asks, and this is a door an application can
// reach with nothing but a bare reference. Inert once the cap is held, memoised
// otherwise — see public-store.ts.
if (anchor !== undefined) {
await fetchReadCap(anchor);
assertMayReach(anchor, "docs.sparqlQuery");
}
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
const result = await ng.sparql_query(sessionId, query, base, anchor);
// Log AFTER the read so the row count (a strong leak signal: a doc rendering
// rows under an identity that should see nothing) can be appended. Skip the
// rowCount work entirely when the log is off.
if (accessLogEnabled()) {
// `rows` here are raw RDF triple bindings (the SPARQL `?s ?p ?o` result), NOT
// domain objects — one document's entity is spread across several triple rows.
// Spell that out so the log isn't mistaken for an object count (the app-level
// object/shape count is logged separately by useShapeQuery → dataStats).
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " triple-rows");
}
return result;
}