refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever l'ambiguïté à chaque phrase — et le contrat publié, lu par une application, était le pire endroit pour laisser traîner ça. packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill, contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts → e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md. Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes, y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est fait occurrence par occurrence, pas par substitution. Le contrat énonce désormais son identité en une phrase : « This package is a polyfill of NextGraph's SDK. »
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* read-model — the listing primitive of the polyfill: read a bounded, by-need set
|
||||
* of documents, each with its own anchored `sparql_query`, and return the triples
|
||||
* grouped per subject. This is the mechanism documented in docs/read-model.md.
|
||||
*
|
||||
* ── Why per-doc anchored, rather than an anchorless union-scan ─────────────
|
||||
* An anchored `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)`
|
||||
* is restricted to the anchor repo's graph: `resolve_target_for_sparql(Repo)` →
|
||||
* `Some(repo_graph_name)`, which becomes the query's default graph. A body with no
|
||||
* `GRAPH` wrapper reads only that default graph → only that doc's triples, O(1) per
|
||||
* doc, independent of how many other graphs the local store holds.
|
||||
*
|
||||
* The footgun this avoids: an anchorless query (`anchor` undefined → `UserSite` →
|
||||
* `set_default_graph_as_union`) spans EVERY named graph currently in the session
|
||||
* store. On a shared / bloated wallet that accumulates across runs, that is
|
||||
* O(wallet size) → the observed ~90s timeouts. So the read path never union-scans
|
||||
* all graphs — it reads exactly the bounded by-need set, one anchored query per doc.
|
||||
*
|
||||
* NB (verified, docs/read-model.md § probe step 4): an explicit `GRAPH ?g { … }`
|
||||
* body iterates the named graphs regardless of the default graph, so an anchor does
|
||||
* not bound such a body. The per-doc read therefore uses a default-graph body (no
|
||||
* `GRAPH` wrapper) so the anchor's one-repo restriction actually applies.
|
||||
*
|
||||
* ── Why not the reactive ORM fan-out ──────────────────────────────────────
|
||||
* `useShape({ graphs: […manyDocs] })` drives `orm_start_graph` over a fan-out of
|
||||
* per-entity graphs; a freshly-created / not-yet-synced doc in that fan-out makes
|
||||
* `RepoNotFound` abort the whole subscription → the readyPromise never resolves →
|
||||
* the ~75s hang (docs/nextgraph-current-state.md § The ORM fan-out hang). Listing
|
||||
* is instead a set of one-shot anchored `sparql_query`s. There is no reactive
|
||||
* union query, so reactivity is assembled by re-querying on a change signal.
|
||||
*
|
||||
* ── Generic by construction ───────────────────────────────────────────────
|
||||
* No application domain here: the consumer passes the doc NURIs to read (from
|
||||
* the discovery index for public events, or its own scope docs for my-entities)
|
||||
* and interprets the returned per-subject property bags. All NextGraph I/O routes
|
||||
* through the T01.a `docs` primitives (the real injected `ng`), so this module
|
||||
* imports no `@ng-org` package.
|
||||
*
|
||||
* At the real multi-store migration the per-doc anchored read is unchanged (native
|
||||
* SPARQL, anchored to one repo); only bringing a repo into the session (open by cap)
|
||||
* changes — the anchored query already resolves a same-session repo directly.
|
||||
*/
|
||||
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { getCaps, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
||||
import { mustNotAttempt } from "../emulated-verifier/reach";
|
||||
import { ensureReposOpen } from "../emulated-verifier/open-repo";
|
||||
import { assertNuri } from "./sparql";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import { isMachinerySubject } from "../emulated-verifier/machinery";
|
||||
import type { Nuri, NuriLike } from "../model/types";
|
||||
|
||||
// Keep the primitives referenced so tree-shaking never drops the import used by
|
||||
// the (side-effecting) open step below; `docCreate`/`sparqlUpdate` are not used
|
||||
// here but the module intentionally depends only on the docs primitive surface.
|
||||
void docCreate;
|
||||
void sparqlUpdate;
|
||||
|
||||
/** One subject read from a doc, with its properties (predicate → values). */
|
||||
export interface UnionSubject {
|
||||
/**
|
||||
* The subject IRI (`?s`), exactly as the document carries it.
|
||||
*
|
||||
* Typed `string`, not `Nuri`: a subject is an ordinary RDF subject and may be ANY
|
||||
* IRI. An application that writes its entity under the document's own NURI gets a
|
||||
* NURI back here, but that is its convention, not this type's promise — a document
|
||||
* may hold several subjects under IRIs of the consumer's choosing, and each comes
|
||||
* back as written.
|
||||
*
|
||||
* The need that once made this `Nuri` is real and is met by {@link graph}: a
|
||||
* consumer must be able to pass back what it just read without a cast —
|
||||
* `shareNote(note.doc)`, `leaveMessage(note.doc)` — and a cast at that boundary
|
||||
* would re-open exactly the confusion the template literal types exist to close.
|
||||
* `graph` is the document reference, so that is the field to carry around.
|
||||
*/
|
||||
subject: string;
|
||||
/**
|
||||
* The document this subject was read from — the reference the caller passed to
|
||||
* {@link readUnion}, unchanged. This is the anchor, it identifies the document
|
||||
* stably, and it is what goes back into any call of this surface.
|
||||
*/
|
||||
graph: Nuri;
|
||||
/** predicate IRI → the list of object values (literals or IRIs) for it. */
|
||||
props: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
||||
function bindings(
|
||||
result: unknown,
|
||||
): Array<Record<string, { value: string } | undefined>> {
|
||||
if (!result) return [];
|
||||
if (Array.isArray(result))
|
||||
return result as Array<Record<string, { value: string }>>;
|
||||
const anyRes = result as {
|
||||
results?: { bindings?: Array<Record<string, { value: string }>> };
|
||||
};
|
||||
return anyRes.results?.bindings ?? [];
|
||||
}
|
||||
|
||||
async function sessionId(): Promise<string> {
|
||||
return (await getStoreRegistryDeps().getSession()).sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one doc with an anchored default-graph query, tolerant per-doc.
|
||||
*
|
||||
* The anchor (`doc` NURI) restricts the query to that repo's graph as the default
|
||||
* graph (`resolve_target_for_sparql(Repo)` → `Some(repo_graph_name)`); a body with
|
||||
* no `GRAPH` wrapper reads exactly that default graph → only this doc's triples.
|
||||
* This is O(1) in the doc's own size and independent of the rest of the (possibly
|
||||
* bloated / shared) session store — it never iterates other graphs.
|
||||
*
|
||||
* COLD-START (fresh session, same persistent wallet): the repo is NOT in
|
||||
* `self.repos` until something opens it, and an anchored query against an unopened
|
||||
* repo silently returns 0 rows (never `RepoNotFound`). {@link readUnion} therefore
|
||||
* opens the batch's repos ({@link ensureReposOpen}) BEFORE this read runs, so the
|
||||
* anchored query resolves a same-session repo directly. A genuinely-absent repo
|
||||
* still yields `[]` (in isolation, never aborting the others). Returns the doc's
|
||||
* rows, or `[]` on failure.
|
||||
*
|
||||
* At the real multi-store migration this becomes a real sync: opening a per-user
|
||||
* store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO).
|
||||
*/
|
||||
async function readDoc(
|
||||
sid: string,
|
||||
doc: Nuri,
|
||||
): Promise<Array<Record<string, { value: string } | undefined>>> {
|
||||
try {
|
||||
const nuri = assertNuri(doc);
|
||||
// Anchored to `nuri` → default graph = this repo. No `GRAPH ?g` wrapper, so
|
||||
// the anchor's one-repo restriction applies (an explicit `GRAPH ?g` body would
|
||||
// iterate all named graphs regardless of the anchor — see docs § probe step 4).
|
||||
const res = await sparqlQuery(
|
||||
sid,
|
||||
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
|
||||
undefined,
|
||||
nuri,
|
||||
"readDoc",
|
||||
);
|
||||
return bindings(res);
|
||||
} catch (error) {
|
||||
console.error("[read-model] read failed for", doc, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a BOUNDED, by-need set of docs — each with its OWN anchored query — and
|
||||
* return the triples grouped per subject. `docs` are the NURIs to read (the
|
||||
* consumer resolves them by need — index for public, own scope docs for mine).
|
||||
* Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the
|
||||
* batch.
|
||||
*
|
||||
* A document holding SEVERAL subjects yields several entries — one per distinct
|
||||
* subject, carrying that subject as written, all sharing the document as their
|
||||
* `graph`. Properties of different subjects are never merged. Placing one business
|
||||
* entity per document stays the recommended practice (a key is per repo, so
|
||||
* isolation needs a repo per entity), but it is a recommendation about writing: the
|
||||
* read reports what is there.
|
||||
*
|
||||
* Never an anchorless union-scan over all graphs (which is O(wallet size) and wrong
|
||||
* on a shared / bloated wallet — the footgun this path exists to avoid). Each doc is
|
||||
* read with an anchored default-graph query, O(1) per doc, independent of wallet
|
||||
* size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`.
|
||||
*/
|
||||
export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
|
||||
const sid = await sessionId();
|
||||
// Drop the empties BEFORE validating, not after: this call has always tolerated a
|
||||
// list with holes in it — a scope index can carry a blank entry, and a caller
|
||||
// building a list from optional values should not have to compact it. Validating
|
||||
// first turned that tolerance into a throw, which took down a whole reconnect run.
|
||||
// Empty is absence, and absence is not a malformed reference.
|
||||
const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion"));
|
||||
if (unique.length === 0) return [];
|
||||
|
||||
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
|
||||
// target repos are not yet in `self.repos`, so an anchored read would return 0
|
||||
// rows. Open/subscribe each repo ONCE (idempotent, per session) and await its
|
||||
// initial-state push before the anchored reads. No-op once opened / when the
|
||||
// injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts.
|
||||
//
|
||||
// Called on the WHOLE set, before the boundary is consulted, because opening is also
|
||||
// where a document in a PUBLIC store hands over its cap (see public-store.ts): a
|
||||
// document filtered out first would never get the chance to answer. `ensureRepoOpen`
|
||||
// still refuses to open what this user may not touch — it asks, it does not enter.
|
||||
await ensureReposOpen(unique);
|
||||
|
||||
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
|
||||
// hold before reading anything: upstream you cannot address a repo you have no cap
|
||||
// for, so asking about one is not "a read that will be refused", it is a read that
|
||||
// has no meaning. (The passage points enforce rule 1 regardless — see reach.ts — so
|
||||
// a lapse here is caught, not exploited.)
|
||||
const reachable = unique.filter((d) => !mustNotAttempt(d));
|
||||
|
||||
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
|
||||
const perDoc = await Promise.all(
|
||||
reachable.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
|
||||
);
|
||||
|
||||
// Possession gate, kept as defence in depth behind rule 2 above: `reachable`
|
||||
// already excluded these, so this loop should never drop anything. The read is
|
||||
// anchored per document, so the unit of possession is the document: the cap key is
|
||||
// the doc NURI, whatever subjects that document turns out to carry.
|
||||
const caps = getCaps();
|
||||
|
||||
// Keyed by (document, subject) — an entry is one subject INSIDE one graph, which is
|
||||
// the identity upstream gives an object too: the ORM carries `@id` and `@graph` as
|
||||
// two distinct read-only properties, and fabricates an `@id` when the writer leaves
|
||||
// it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`). Several objects per
|
||||
// graph is therefore the PROVIDED case, and `@id` is what tells them apart within a
|
||||
// `@graph`. Two documents carrying the same subject IRI stay two entries: they are
|
||||
// two objects, distinguished by their graph.
|
||||
//
|
||||
// Placing one business entity per document remains the recommended practice — a key
|
||||
// is per repo, so isolating an entity requires a repo of its own. That is a
|
||||
// recommendation about WRITING, and the read does not get to enforce it by making
|
||||
// the other arrangement invisible.
|
||||
const bySubject = new Map<string, UnionSubject>();
|
||||
for (const { doc, rows } of perDoc) {
|
||||
if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue;
|
||||
// Anchored to `doc`, so every row belongs to `doc` — hence `graph` is the caller's
|
||||
// reference to it. The subject comes back exactly as the document carries it.
|
||||
for (const row of rows) {
|
||||
// The polyfill's own compartments live as reserved SUBJECTS inside the very
|
||||
// documents the consumer reads (the Header branch carrying a document's inbox
|
||||
// address is the first). They are machinery, not this entity's properties —
|
||||
// drop them here, once, for every compartment present and future.
|
||||
const s = row.s?.value;
|
||||
if (isMachinerySubject(s)) continue;
|
||||
const p = row.p?.value;
|
||||
const o = row.o?.value;
|
||||
if (s === undefined || !p || o === undefined) continue;
|
||||
// NUL cannot appear in an IRI, so the pair never collides with either half.
|
||||
const key = `${doc}\u0000${s}`;
|
||||
let entry = bySubject.get(key);
|
||||
if (!entry) {
|
||||
entry = { subject: s, graph: doc, props: {} };
|
||||
bySubject.set(key, entry);
|
||||
}
|
||||
(entry.props[p] ??= []).push(o);
|
||||
}
|
||||
}
|
||||
return [...bySubject.values()];
|
||||
}
|
||||
Reference in New Issue
Block a user