/** * read-document - read ONE document with an anchored SPARQL query, and group the rows * it returns per subject. Library-internal: nothing here is published. * * -- What this replaced, and why the batch went ---------------------------- * Until `1.0.0-dev.8` this library published `readUnion(docs)`: the same anchored read, * issued once per document, in parallel, with a batch policy of its own (a failing * document was swallowed into an empty result unless the caller passed a callback to be * told). Nothing upstream has a counterpart for that - it was N calls plus a policy - * and its provenance annotation cited `sparql_query`, a citation that is exact attached * to a claim that is false. **A composition cannot be aligned on a single function.** * * Every caller in production passed ONE document. So the batch is gone, and what is left * is the thing upstream really does have: one anchored `sparql_query`. There is no * swallow left to explain either - this throws, and the caller decides, which is the * only honest answer once "one document failed" and "the read failed" are the same * event. * * -- Why ANCHORED, and never anchorless ----------------------------------- * 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, so only that document's triples - O(1) * in the document's own size, 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 in the session store. On a shared * wallet that accumulates across runs, that is O(wallet size) - the observed ~90s * timeouts. Replacing several anchored reads with one anchorless query would also be a * silent WIDENING of what a read can reach, so it is not an optimisation available here. * * 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. This read therefore uses a default-graph body - no `GRAPH` wrapper - so * the anchor's one-repo restriction actually applies. * * -- The open in front of it is the EMULATION, not the query -------------- * On a fresh session over the same persistent wallet the target repo is present but * UNSYNCED, and an anchored query against it legitimately matches nothing - no error, * just zero rows. So {@link ensureRepoOpen} runs first and awaits the sync barrier, which * is also where a document in a PUBLIC store hands over its `ReadCap`. That step is * `polyfill-era` machinery: it is what one shared wallet costs, it is annotated where it * lives (`emulated-verifier/open-repo.ts`), and it goes at migration with the shim. * * Calling it here rather than leaving it to the caller is deliberate and load-bearing: * `emulated-verifier/index-deposit.ts` decides "absent" versus "unreadable" from * {@link getSyncState}, and a read that never opened the repo would leave that state * `unknown` - which that arm reads as "a zero-row answer MEANS it". Skipping the open * would therefore turn a cold start into a destroyed deposit queue, silently. It is the * failure that path has paid for twice; the barrier is not an optimisation to move. */ import { sparqlQuery } from "./docs"; import { ensureRepoOpen } from "../emulated-verifier/open-repo"; import { isMachinerySubject } from "../emulated-verifier/machinery"; import { session } from "../shared-wallet/account-registry"; import type { Nuri } from "../model/types"; /** * One subject read from a document, with its properties (predicate -> values). * * `subject` is 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 reference * gets a reference back here, but that is its convention, not this type's promise - a * document may hold several subjects under IRIs of its writer's choosing, and each comes * back as written. */ export interface DocumentSubject { /** The subject IRI (`?s`), exactly as the document carries it. */ subject: string; /** predicate IRI -> the list of object values (literals or IRIs) for it. */ props: Record; } /** Tolerant extraction of SPARQL SELECT bindings across the shapes `ng` can return. */ function bindings( result: unknown, ): Array> { if (!result) return []; if (Array.isArray(result)) return result as Array>; const anyRes = result as { results?: { bindings?: Array> }; }; return anyRes.results?.bindings ?? []; } /** * Read `doc`'s triples with one anchored query and return them grouped per subject. * * **Throws** on anything that stops the read: a reference this holder may not reach * (`docs.sparqlQuery` refuses it), a broker that does not answer, a query that rejects. * That is the whole point of having no batch any more - there is no other document for a * failure to be tolerated in favour of, so "it failed" reaches the caller as a failure * rather than as an empty answer. A document that is genuinely empty, or whose repo has * not synced, returns `[]` WITHOUT throwing - and telling those two apart is the * caller's job ({@link getSyncState} is what answers it). * * A document holding SEVERAL subjects yields several entries - one per distinct subject, * carrying that subject as written. 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 that is a recommendation about * WRITING: the read reports what is there, and does not get to enforce it by making the * other arrangement invisible. * * The library's own compartments live as reserved SUBJECTS inside the very documents a * consumer reads (the Header branch carrying a document's inbox address is the first). * They are machinery, not the entity's properties, and are dropped here - once, for * every compartment present and future. */ // @provenance readDocumentSubjects kind=invention level=none ref=none - the anchored query it issues is `docs.sparqlQuery`, aligned and annotated THERE; what is added here has no upstream counterpart at any level: the cold-start open a shared wallet forces, and the grouping of a SELECT's rows into per-subject property bags. Internal, and named as ours so a later reader knows it is ours to change export async function readDocumentSubjects( doc: Nuri, label = "readDocument", ): Promise { // The cold-start open, and the public store's chance to hand over its key, BEFORE the // anchored read - see the module header for why this cannot move to the caller. await ensureRepoOpen(doc); const { sessionId } = await session(); // Anchored to `doc` -> default graph = this repo. No `GRAPH ?g` wrapper, so the // anchor's one-repo restriction applies. const rows = bindings( await sparqlQuery(sessionId, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, doc, label), ); const bySubject = new Map(); for (const row of rows) { 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; let entry = bySubject.get(s); if (!entry) { entry = { subject: s, props: {} }; bySubject.set(s, entry); } (entry.props[p] ??= []).push(o); } return [...bySubject.values()]; }