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:
@@ -26,7 +26,6 @@ import {
|
||||
init,
|
||||
subscribeDoc,
|
||||
subscribeDocs,
|
||||
readUnion,
|
||||
inbox,
|
||||
storeRegistry,
|
||||
useShape as libUseShape,
|
||||
@@ -130,6 +129,52 @@ function asAnchor(s?: string): Nuri | undefined {
|
||||
return s === undefined ? undefined : asNuri(s);
|
||||
}
|
||||
|
||||
/** The reserved prefix: triples whose SUBJECT falls under it are this package's own
|
||||
* machinery, not the document's data. `readUnion` dropped them for the caller; an
|
||||
* application composing its own read drops them itself, and the contract says so. */
|
||||
const RESERVED_SUBJECT_PREFIX = "urn:ng-eventually:";
|
||||
|
||||
/**
|
||||
* Read ONE document with the published anchored query and group its rows per subject -
|
||||
* what an application composes since `readUnion` was removed in `1.0.0-dev.8`.
|
||||
*
|
||||
* It THROWS, exactly like `docs.sparqlQuery` under it: a document that cannot be read is
|
||||
* a failure, never an empty answer. Every caller below that wants to carry on past one
|
||||
* says so with a `catch` of its own, which is where upstream leaves that decision too.
|
||||
*/
|
||||
async function readOneDocument(
|
||||
sessionId: string,
|
||||
docLike: string,
|
||||
): Promise<Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }>> {
|
||||
const doc = asNuri(docLike);
|
||||
// Anchored -> default graph = this repo, so the body carries no `GRAPH <...>` wrapper.
|
||||
const res: unknown = await docs.sparqlQuery(
|
||||
sessionId,
|
||||
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
|
||||
undefined,
|
||||
doc,
|
||||
);
|
||||
const rows = Array.isArray(res)
|
||||
? (res as Array<Record<string, { value: string } | undefined>>)
|
||||
: ((res as { results?: { bindings?: Array<Record<string, { value: string } | undefined>> } })
|
||||
.results?.bindings ?? []);
|
||||
const bySubject = new Map<string, { subject: string; graph: Nuri; props: Record<string, string[]> }>();
|
||||
for (const row of rows) {
|
||||
const subject = row["s"]?.value;
|
||||
const p = row["p"]?.value;
|
||||
const o = row["o"]?.value;
|
||||
if (subject === undefined || !p || o === undefined) continue;
|
||||
if (subject.startsWith(RESERVED_SUBJECT_PREFIX)) continue;
|
||||
let entry = bySubject.get(subject);
|
||||
if (!entry) {
|
||||
entry = { subject, graph: doc, props: {} };
|
||||
bySubject.set(subject, entry);
|
||||
}
|
||||
(entry.props[p] ??= []).push(o);
|
||||
}
|
||||
return [...bySubject.values()];
|
||||
}
|
||||
|
||||
// ── The broker session, resolved once the iframe connects ──────────────────
|
||||
interface BrokerSession {
|
||||
session_id: string;
|
||||
@@ -435,13 +480,16 @@ const identity = new IdentityStore(
|
||||
};
|
||||
},
|
||||
|
||||
// ── read-model ───────────────────────────────────────────────────────────
|
||||
// ── the anchored document read ───────────────────────────────────────────
|
||||
/**
|
||||
* Create N docs, write a marker triple into each (anchored default graph), then
|
||||
* readUnion over them → one subject per doc. Optionally inject a bad NURI to
|
||||
* prove per-doc tolerance (the bad one is skipped, the batch survives).
|
||||
* Create N docs, write a marker triple into each (anchored default graph), then read
|
||||
* them ONE BY ONE with the published anchored query. Optionally inject a bad NURI, to
|
||||
* show where the tolerance lives now: at THIS call site, as a visible `catch`, not
|
||||
* inside a primitive. `readUnion` used to swallow it and answer with the rest, which
|
||||
* is a policy upstream does not have - an anchored query on a repo the verifier does
|
||||
* not know REJECTS (`resolve_target_for_sparql` -> `NgError::RepoNotFound`).
|
||||
*/
|
||||
async readUnionOverDocs(n: number, includeBad: boolean) {
|
||||
async readDocsOneByOne(n: number, includeBad: boolean) {
|
||||
const s = await sessionReady;
|
||||
const docNuris: Nuri[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
@@ -454,7 +502,7 @@ const identity = new IdentityStore(
|
||||
docNuris.push(d);
|
||||
}
|
||||
// Two SUBJECTS in the LAST document, so the probe can tell "one entry per document"
|
||||
// from "one entry per subject". Until 2026-08-10 `readUnion` folded every triple of a
|
||||
// from "one entry per subject". Until 2026-08-10 the read folded every triple of a
|
||||
// document into one bag keyed by the document, and this step stayed green throughout
|
||||
// because it only ever counted entries and never looked at what they were.
|
||||
if (n > 0) {
|
||||
@@ -465,22 +513,36 @@ const identity = new IdentityStore(
|
||||
);
|
||||
}
|
||||
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
|
||||
const subjects = await readUnion(toRead);
|
||||
const subjects: Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }> = [];
|
||||
const skipped: string[] = [];
|
||||
for (const d of toRead) {
|
||||
try {
|
||||
subjects.push(...(await readOneDocument(s.session_id, d)));
|
||||
} catch {
|
||||
// THIS harness chooses to carry on. Another caller may choose not to - that is
|
||||
// the point of the decision living here.
|
||||
skipped.push(d);
|
||||
}
|
||||
}
|
||||
return {
|
||||
docNuris,
|
||||
subjectCount: subjects.length,
|
||||
// What each entry actually IS — the assertion the count could not make.
|
||||
subjectIris: subjects.map((x) => x.subject),
|
||||
graphs: subjects.map((x) => x.graph),
|
||||
skipped,
|
||||
subjects,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* readUnion possession gate: create a doc as owner O (whose keyring gets its
|
||||
* cap), then read it as a DIFFERENT identity, which holds nothing → dropped.
|
||||
* The stranger has the document's NURI in hand throughout: naming is not reading.
|
||||
* The possession gate on the anchored read: create a doc as owner O (whose keyring
|
||||
* gets its cap), then read it as a DIFFERENT identity, which holds nothing. The
|
||||
* stranger has the document's NURI in hand throughout: naming is not reading.
|
||||
*
|
||||
* The stranger is now REFUSED rather than handed an empty list - `readUnion` dropped
|
||||
* an unreachable document from its batch, and there is no batch to drop it from.
|
||||
*/
|
||||
async readUnionCapGate() {
|
||||
async anchoredReadCapGate() {
|
||||
const s = await sessionReady;
|
||||
resetCaps();
|
||||
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
@@ -488,12 +550,18 @@ const identity = new IdentityStore(
|
||||
setCurrentUser("owner-O");
|
||||
getCaps().open(doc, "protected");
|
||||
setCurrentUser("someone-else");
|
||||
const asStranger = await readUnion([doc]);
|
||||
let strangerRefused = false;
|
||||
let strangerCount = -1;
|
||||
try {
|
||||
strangerCount = (await readOneDocument(s.session_id, doc)).length;
|
||||
} catch {
|
||||
strangerRefused = true;
|
||||
}
|
||||
setCurrentUser("owner-O");
|
||||
const asOwner = await readUnion([doc]);
|
||||
const asOwner = await readOneDocument(s.session_id, doc);
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
return { strangerCount: asStranger.length, ownerCount: asOwner.length };
|
||||
return { strangerRefused, strangerCount, ownerCount: asOwner.length };
|
||||
},
|
||||
|
||||
// ── reactivity (doc_subscribe) ───────────────────────────────────────────
|
||||
@@ -704,9 +772,9 @@ const identity = new IdentityStore(
|
||||
/**
|
||||
* RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a
|
||||
* DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the
|
||||
* account's entity docs of `scope` (listMyEntityDocs → readUserStore) and readUnion
|
||||
* them, purely from the persistent wallet — nothing from phase 1's session state
|
||||
* carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
|
||||
* account's entity docs of `scope` (listMyEntityDocs → readUserStore) and read each
|
||||
* with its own anchored query, purely from the persistent wallet — nothing from
|
||||
* phase 1's session state carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
|
||||
* doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here
|
||||
* the broker-login bootstrap already opens the user's repos, so rawRowCount is
|
||||
* non-zero even without the heal — this is a reconnection REGRESSION guard, not a
|
||||
@@ -725,7 +793,7 @@ const identity = new IdentityStore(
|
||||
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
|
||||
// many rows the bare anchored query resolves for a not-yet-opened repo (the
|
||||
// premise: 0 until opened). Uses the low-level docs primitive directly, bypassing
|
||||
// readUnion's open step.
|
||||
// `readOneDocument`'s open step.
|
||||
//
|
||||
// Placed AFTER `listMyEntityDocs`, which is what restores the caps of the user's
|
||||
// own documents from the Store branch. Before it, the boundary refuses the read
|
||||
@@ -738,7 +806,18 @@ const identity = new IdentityStore(
|
||||
} catch (e: any) {
|
||||
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
|
||||
}
|
||||
const subjects = await readUnion(listed.length ? listed : [asNuri(entityNuri)]);
|
||||
// One anchored read per listed document, and the tolerance is HERE, visibly: a
|
||||
// reconnect probe that lost one document to a broker hiccup still reports what the
|
||||
// others held, because THIS caller decided so. The primitive rejects.
|
||||
const subjects: Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }> = [];
|
||||
const unread: string[] = [];
|
||||
for (const d of listed.length ? listed : [asNuri(entityNuri)]) {
|
||||
try {
|
||||
subjects.push(...(await readOneDocument(s.session_id, d)));
|
||||
} catch {
|
||||
unread.push(d);
|
||||
}
|
||||
}
|
||||
const markers: string[] = [];
|
||||
for (const subj of subjects) {
|
||||
for (const vals of Object.values(subj.props)) {
|
||||
@@ -751,6 +830,7 @@ const identity = new IdentityStore(
|
||||
listedCount: listed.length,
|
||||
foundEntity: listed.includes(asNuri(entityNuri)),
|
||||
subjectCount: subjects.length,
|
||||
unread,
|
||||
markerPresent: markers.includes(marker),
|
||||
markers,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user