feat!: retirer readUnion de la surface publiee, et lire un document par requete ancree

readUnion etait N requetes ancrees plus une politique de lot : une lecture
qui echoue avalee en reponse vide, un document dont on ne detient pas la cle
ecarte au lieu d'etre refuse, un regroupement en sacs de proprietes, et le
filtrage du prefixe reserve. Rien en amont ne repond a cette composition, et
son annotation citait sparql_query -- une citation exacte attachee a une
affirmation fausse. Une composition ne s'aligne pas sur une fonction.

Ce qui reste est ce qu'il y avait dessous : docs.sparqlQuery ancre sur un
document, qui va deja chercher la cle servie par le magasin public -- donc
une reference nue suffit toujours pour lire un document public, sans rien
changer a useShapeQuery. Lire plusieurs documents, c'est cet appel une fois
par document, et la tolerance a une lecture qui echoue revient a l'appelant,
la ou l'amont la laisse : une requete ancree sur un depot que le verifier ne
detient pas rejette.

Cote src, l'unique lecture interne restante devient read-document.ts : un
document, une requete ancree, precedee de l'ouverture du depot -- barriere
que index-deposit consulte pour distinguer une absence d'un depot non
synchronise. Le harnais e2e et l'exemple composent la leur, avec leur catch
visible au point d'appel.

Version majeure : 1.0.0-dev.8.
This commit is contained in:
Sylvain Duchesne
2026-09-21 14:08:05 +02:00
parent 05a0f7b148
commit 0c21adebf2
18 changed files with 659 additions and 516 deletions
@@ -0,0 +1,141 @@
/**
* 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<string, string[]>;
}
/** Tolerant extraction of SPARQL SELECT bindings across the shapes `ng` can return. */
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 ?? [];
}
/**
* 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<DocumentSubject[]> {
// 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<string, DocumentSubject>();
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()];
}