0eb25286c8
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.
176 lines
8.1 KiB
TypeScript
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;
|
|
}
|