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
@@ -54,14 +54,14 @@
* acted on.
*/
import { readUnion } from "../surface/read-model";
import { readDocumentSubjects } from "../surface/read-document";
import { sparqlUpdate } from "../surface/docs";
import { readInboxCapPairs } from "./branch-registers";
import { getSyncState } from "./open-repo";
import { mustNotAttempt } from "./reach";
import { session } from "../shared-wallet/account-registry";
import { assertNuri, escapeLiteral } from "../surface/sparql";
import type { UnionSubject } from "../surface/read-model";
import type { DocumentSubject } from "../surface/read-document";
import type { Nuri } from "../model/types";
/**
@@ -109,14 +109,19 @@ export type IndexApplication =
* This is the question consumption forced, and it has three sources of "no rows", only one
* of which is an absence:
*
* - the read THREW — `onUnreadable` fired. Transient. Not an absence.
* - the document was NEVER ATTEMPTED, because this holder holds no cap for it and
* `readUnion` drops it through `mustNotAttempt` before issuing a query. This looked
* deterministic and is not: for a stranger's PUBLIC document — the reference arm's
* central case — possession is acquired by a network fetch that swallows every error
* and MEMOISES the failure (`public-store.ts`). One outage on that fetch therefore
* presents as "no cap", and it stays that way for the session, so classing it as an
* absence destroyed the deposit AND every re-deposit after it. Not an absence.
* - the read THREW. Transient. Not an absence. Its callers return before reaching this
* function at all, which is why `readFailed` below can only be false today; the
* parameter stays because the answer for a failed read is part of the question this
* function exists to answer, and hiding it in the call order would lose it.
* - the document COULD NOT BE REACHED, because this holder holds no cap for it. Since
* `1.0.0-dev.8` that arrives as a THROW from `docs.sparqlQuery` rather than as a
* silently dropped document, so the previous bullet catches it - but the reasoning
* is the same one and worth keeping: it looked deterministic and is not. For a
* stranger's PUBLIC document - the reference arm's central case - possession is
* acquired by a network fetch that swallows every error and MEMOISES the failure
* (`public-store.ts`). One outage on that fetch therefore presents as "no cap", and
* it stays that way for the session, so classing it as an absence destroyed the
* deposit AND every re-deposit after it. Not an absence.
* - the repo is PRESENT BUT UNSYNCED — and this is the one that cost round 6. A fresh
* session over a persistent wallet has not received the repo's commits, so the anchored
* query legitimately matches nothing and **raises no error at all**. Classing that as an
@@ -144,8 +149,8 @@ function noRowsMeansAbsent(doc: Nuri, readFailed: boolean): boolean {
/**
* The values a subject carries for a predicate.
*
* OWN properties only. `props` arrives as a plain object literal (`readUnion` builds
* `const props: Record<string, string[]> = {}`), so it inherits from `Object.prototype`:
* OWN properties only. `props` arrives as a plain object literal (`readDocumentSubjects`
* builds `props: {}`), so it inherits from `Object.prototype`:
* an index declaring `constructor`, `toString` or `valueOf` as its field would otherwise
* find a FUNCTION where a list of values belongs, and spreading it throws. The non-string
* filter is the same caution — this data crossed a process boundary, and a value that is
@@ -156,7 +161,7 @@ function noRowsMeansAbsent(doc: Nuri, readFailed: boolean): boolean {
* came back has the shape the type promises.
*/
export function valuesOf(
subject: UnionSubject | undefined,
subject: DocumentSubject | undefined,
predicate: string,
): readonly string[] {
const props = subject?.props;
@@ -168,7 +173,7 @@ export function valuesOf(
/** Every field the document declares for itself — zero, one, or (wrongly) several. */
export function declaredFields(
subjects: readonly UnionSubject[],
subjects: readonly DocumentSubject[],
index: Nuri,
): readonly string[] {
return valuesOf(
@@ -187,7 +192,7 @@ export function declaredFields(
* ABSENT, so the index could SHRINK through nothing but additions.
*/
export function indexedObjects(
subjects: readonly UnionSubject[],
subjects: readonly DocumentSubject[],
index: Nuri,
): ReadonlySet<string> {
const out = new Set<string>();
@@ -244,16 +249,22 @@ export async function applyIndexDeposit(
return { outcome: "skipped", object: reference, reason: "self-reference" };
}
// Told whether the read FAILED, rather than left to guess from an empty answer. The two
// demand opposite handling now that deposits are consumed — see the `unreadable` outcome.
// A BOOLEAN, not the error value: `throw null` and `throw undefined` are legal, and a
// sentinel comparison against `null` made exactly those failures invisible.
// A FAILED read and an EMPTY one demand opposite handling now that deposits are
// consumed - see the `unreadable` outcome - so they are never allowed to arrive as the
// same value. `readDocumentSubjects` throws where it used to hand the error to a
// callback and answer `[]`, which is the same distinction made by the language instead
// of by a convention. Tracked as a BOOLEAN, not as the error value: `throw null` and
// `throw undefined` are legal, and a sentinel comparison against `null` made exactly
// those failures invisible.
let indexReadFailed = false;
let indexError: unknown;
const subjects = await readUnion([index], (_doc, error) => {
let subjects: readonly DocumentSubject[] = [];
try {
subjects = await readDocumentSubjects(index, "indexRead");
} catch (error) {
indexReadFailed = true;
indexError = error;
});
}
if (indexReadFailed) {
return {
outcome: "unreadable",
@@ -297,18 +308,18 @@ export async function applyIndexDeposit(
return { outcome: "unchanged", object: reference };
}
let resolved: readonly UnionSubject[];
let resolved: readonly DocumentSubject[] = [];
let referenceReadFailed = false;
let referenceError: unknown;
try {
resolved = await readUnion([reference], (_doc, error) => {
referenceReadFailed = true;
referenceError = error;
});
resolved = await readDocumentSubjects(reference, "indexReference");
} catch (error) {
return { outcome: "unreadable", why: `${reference}: ${String(error)}` };
referenceReadFailed = true;
referenceError = error;
}
// The BROKER failed. Stop the drain: everything still queued has to survive an outage,
// The read did not answer - a broker outage, or a reference this holder cannot reach at
// all, which `docs.sparqlQuery` now refuses out loud instead of leaving as an empty
// answer. Stop the drain either way: everything still queued has to survive an outage,
// and this deposit is already gone.
if (referenceReadFailed) {
return {