fix: une lecture réactive qui n'a pas pu répondre ne dit plus « rien »

watchShape attrapait l'échec de résolution des documents, journalisait, et
passait une liste vide. Or une barrière sur zéro document est franchie
trivialement — la surface publiait donc { data: [], isPending: false,
isSuccess: true }, octet pour octet l'instantané « synchronisé et vide ». La
seule distinction pour laquelle ce module existe était celle qu'il détruisait.

Un échec de résolution devient isError, jamais isSuccess. Et comme un
observable ne peut pas dé-émettre, l'état « je ne sais plus » conserve la
DERNIÈRE lecture qui a répondu, avec isSuccess à faux : une liste vide n'est
jamais la réponse d'un échec.

Le canal choisi est l'état de chargement, parce que c'est celui qu'une
application lit déjà pour distinguer « en attente » de « vide ». La troisième
valeur ne lui coûte aucun vocabulaire neuf.

Vérification faite en amont plutôt qu'en supposant : readyPromise n'aurait pas
aidé — construit avec resolve seul, rien ne le rejette, et l'échec
d'orm_start_graph n'est qu'un console.error. Le « je n'ai pas pu savoir » de la
cible EST son « toujours en attente ». La classification invention tient, et les
annotations le disent désormais.

C'est le dernier membre connu de cette famille dans le polyfill : après
connectedUser, resolveAccount, userInbox, readInboxCapPairs et
listMyEntityDocs, la couche réactive était le dernier endroit où un échec se
présentait comme une absence.
This commit is contained in:
Sylvain Duchesne
2026-08-17 09:55:54 +02:00
parent 76ae9ffbb7
commit a8d53010c2
2 changed files with 371 additions and 15 deletions
+82 -14
View File
@@ -49,6 +49,30 @@
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
* (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error.
* `isError` fires ONLY on a real thrown exception in the pipeline.
*
* ── A scope that did not answer is NOT an empty scope ──────────────────────
* The pipeline's first step ASKS a question — which documents are mine in this scope
* — and until 2026-08-17 a failure to answer it was logged and treated as "none":
* the empty set flowed on, the barrier over zero docs is trivially reached, and the
* surface published `{ data: [], isSuccess: true }`. That snapshot is BYTE-FOR-BYTE
* the synced-but-empty one this module exists to distinguish, so the one distinction
* it publishes was the one it destroyed — an application rendered "you have nothing"
* for "the store did not answer". `listMyEntityDocs` stopped handing out that reading
* one layer down (`90712e0`); catching it here put it straight back.
*
* A resolution failure now travels the LOAD-STATE channel, which is where "this is
* not an answer" already lives on this surface: `isError` with the thrown `error`,
* and never `isSuccess`. The channel is the honest place because it is the one an
* application already has to read to tell pending from empty — the same three-way
* question, and the third state costs it no new vocabulary.
*
* ── An observable cannot un-emit: `data` survives an error ─────────────────
* A one-shot call rejects and is done. An observable has already handed a list to a
* subscriber that rendered it, so its failure snapshot has to say something about
* that list — and collapsing it to `[]` would hand out, in `data`, exactly the empty
* answer this fix removes. So `data` KEEPS the last read that actually answered, and
* `isError` says the current answer is unknown. `isSuccess` is false throughout: the
* array is a memory, not a reply to the question just asked.
*/
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
@@ -70,9 +94,12 @@ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
* the generic per-subject property bags of the read-model (NO application domain);
* the app maps them to its own entity types in phase B.
*/
// @provenance ShapeQuery kind=invention level=none ref=none — no upstream type carries load state; the distinction IS expressible (`OrmSubscription.readyPromise`) but this vocabulary is ours
// @provenance ShapeQuery kind=invention level=none ref=none — no upstream type carries load state; `OrmSubscription.readyPromise` expresses pending-vs-ready and nothing else (built with `resolve` alone, and `orm_start_graph` failing is only logged — a failed read is an eternal pending), so `isError` has no counterpart at any level
export interface ShapeQuery<T = UnionSubject> {
/** The subjects of the requested shape/scope. Empty array when none (never undefined). */
/** The subjects of the requested shape/scope. Empty array when none (never undefined).
* When `isError`, this is the last read that ANSWERED — kept, not cleared, because an
* observable cannot un-emit and `[]` here would be the false "you have nothing" the
* error state exists to prevent. Read it as a memory then, not as a reply. */
data: T[];
/** True while the sync barrier for the current doc set is not yet reached OR the
* first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */
@@ -81,7 +108,10 @@ export interface ShapeQuery<T = UnionSubject> {
* first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with
* `data: []` — the distinction this surface exists for. */
isSuccess: boolean;
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`). */
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`):
* the scope could not be resolved, or the union could not be read. It means the
* current answer is UNKNOWN — never that the scope is empty. Mutually exclusive with
* both `isPending` and `isSuccess`. */
isError: boolean;
/** The caught error when `isError`, else `undefined`. */
error: unknown;
@@ -165,7 +195,7 @@ function barrierReached(docs: Nuri[]): boolean {
* what kicks off resolution, opening and the first read; before then `getSnapshot`
* reports the initial pending snapshot.
*/
// @provenance watchShape kind=invention level=none ref=none — nothing upstream distinguishes syncing from synced-empty at the hook; the header's 'planned useShape upgrade' has NO upstream provenance
// @provenance watchShape kind=invention level=none ref=none — nothing upstream distinguishes syncing from synced-empty at the hook, and nothing anywhere expresses the third state it now publishes: a failed `orm_start_graph` leaves `readyPromise` unsettled forever, so upstream's "could not find out" IS its "still pending"; the header's 'planned useShape upgrade' has NO upstream provenance
export function watchShape<T = UnionSubject>(
shapeType: unknown,
scope: Scope,
@@ -214,9 +244,38 @@ export function watchShape<T = UnionSubject>(
emit();
}
/**
* Publish "the current answer is UNKNOWN" — the one snapshot every failure in the
* pipeline produces, wherever it was thrown.
*
* `data` carries the LAST READ THAT ANSWERED rather than `[]`. A subscriber has
* already rendered that list and an observable cannot un-emit it; clearing it would
* put the empty answer back — in the field an application actually renders, and for
* the very case that must never read as empty. `isSuccess` stays false, so the array
* is never offered as a reply to the question that just failed, and a subscriber that
* gates on `isSuccess` shows nothing new while one that renders `data` keeps what it
* had. Before the first answer there is nothing to keep and `data` is `[]` — the
* initial value, published under `isError`, never under `isSuccess`.
*/
function setUnknown(error: unknown): void {
setSnapshot({
data: snapshot.data,
isPending: false,
isSuccess: false,
isError: true,
error,
});
}
/** Resolve the logical scope → the current doc set: the CURRENT wallet's own
* entity documents for that scope, and nothing else. Tolerant: a resolution
* failure yields whatever resolved.
* entity documents for that scope, and nothing else.
*
* It PROPAGATES, and that is the whole point: the set it returns is what the rest
* of the pipeline calls "the scope", so a swallowed failure here does not degrade
* the answer, it INVENTS one — zero documents, a barrier trivially reached over
* them, and `isSuccess` published over a store nobody read. Nothing downstream can
* tell that set apart from a scope that is genuinely empty, because it IS the same
* set. The caller's error state exists for this.
*
* There is no "everything public" to fold in. You cannot discover; you can only
* follow links, and a link reaches you through an inbox or through a document
@@ -229,11 +288,7 @@ export function watchShape<T = UnionSubject>(
resolving = true;
try {
if (user) {
try {
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
} catch (error) {
console.error("[watch-shape] listMyEntityDocs failed", error);
}
}
} finally {
resolving = false;
@@ -242,7 +297,15 @@ export function watchShape<T = UnionSubject>(
}
/** Subscribe to the CONTAINER document (the scope index) so a change to the doc
* SET re-resolves. Idempotent per NURI. */
* SET re-resolves. Idempotent per NURI.
*
* This one SWALLOWS, deliberately and unlike `resolveDocs` next door. It resolves
* no data and publishes no snapshot — it wires a change signal — so its failure
* cannot dress an unread store up as an empty one. And the answer is not lost with
* it: `resolveDocs` runs immediately after over the SAME account, so a store that
* cannot be reached surfaces there, as an error, one line later. What is lost is
* reactivity to a later change of the SET, which is a different (and much quieter)
* problem than the one this module was fixed for. */
async function ensureContainerSubs(): Promise<void> {
const containers: Nuri[] = [];
const user = getCurrentUser();
@@ -288,7 +351,7 @@ export function watchShape<T = UnionSubject>(
subjects = await readUnion(docs);
} catch (error) {
if (token !== refreshToken) return;
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
setUnknown(error);
return;
}
if (token !== refreshToken) return; // superseded by a newer refresh/reread
@@ -304,7 +367,12 @@ export function watchShape<T = UnionSubject>(
}
/** Full cycle: resolve the scope, (re)establish container subs, open the docs
* (await the barrier), sync per-doc subs, then read + publish. */
* (await the barrier), sync per-doc subs, then read + publish.
*
* This catch is the surface's ONE answer to "the pipeline could not answer" —
* scope resolution included, since `resolveDocs` propagates. It is reached only
* when the cycle is still the current one; a superseded cycle's failure is
* dropped, because a newer cycle owns the question by then. */
async function refresh(): Promise<void> {
const token = ++refreshToken;
try {
@@ -318,7 +386,7 @@ export function watchShape<T = UnionSubject>(
await readAndPublish(docs, token);
} catch (error) {
if (token !== refreshToken) return;
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
setUnknown(error);
}
}
@@ -0,0 +1,288 @@
/**
* `watchShape` says "there is nothing" or "I could not find out" — never the first for the second.
*
* The whole reason this surface exists is one distinction: an application must be able to
* tell a scope that is EMPTY from a scope it has not heard back about. `useShape` upstream
* cannot — it returns "an empty set, if still loading" — and `watchShape` publishes
* `isPending` / `isSuccess` / `isError` precisely to answer the question three ways.
*
* Until 2026-08-17 it destroyed that distinction on the one input that mattered. Step 1 of
* its pipeline asks `listMyEntityDocs` which documents are mine in this scope; the failure
* was caught, logged, and the empty set flowed on. A barrier over zero documents is
* trivially reached, so the surface published `{ data: [], isPending: false, isSuccess:
* true }` — the exact snapshot a genuinely empty scope produces, byte for byte. An
* application rendered "you have created nothing" for "the store did not answer", which is
* the outage this library already shipped twice (`8c8ade7`, `f6d1734`) and closed one layer
* down the day before (`90712e0`, which made that very call throw). Catching it here put it
* straight back one floor up.
*
* ── The two questions, and why the second needs its own test ──────────────
* A one-shot call rejects and is done. An observable has already handed a list to a
* subscriber that RENDERED it, so a failure that arrives afterwards has to say something
* about that list — and `data: []` would be the empty answer again, in the one field an
* application actually paints. So there are two cases here, not one: the failure BEFORE any
* answer, and the failure AFTER one.
*
* ── The fault is the broker's, and it is one a real one produces ──────────
* Nothing here plants a state or reaches in to make a library function throw. The documents
* were CREATED through the surface, over the same wallet, in the session before; then the
* connection dies — the broker answers normally and stops answering, and every query after
* that rejects the way the wasm binding rejects a transport error. Same montage as
* `listing-surfaces-failure.test.ts`, one layer up.
*/
import { test, expect, describe, mock, beforeEach, afterAll } from "bun:test";
import { configure, watchShape, storeRegistry } from "../src/index";
import type { ShapeObservable, ShapeQuery } from "../src/surface/watch-shape";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { sparqlUpdate } from "../src/surface/docs";
import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types";
import { SESSION, forgetEverything, makeWallet, signIn, type Quad } from "./wallet-fake";
const SHIM = "urn:ng-eventually:shim";
/** The Main-branch read of a store — "which documents are in here". Step 1 of the pipeline. */
const LISTING_READ = `<${SHIM}:contains>`;
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const EVENT = "http://festipod.org/Event";
const TITLE = "http://festipod.org/title";
/** A minimal SHEX ShapeType pinning `rdf:type` to {@link EVENT}. */
const EventShape = {
shape: "http://festipod.org/EventShape",
schema: {
"http://festipod.org/EventShape": {
iri: "http://festipod.org/EventShape",
predicates: [{ iri: RDF_TYPE, dataTypes: [{ literals: [EVENT], valType: "iri" }] }],
},
},
};
/**
* Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query
* `lostAt` accepts — that one included, and every one after it, for the rest of the page.
*
* A dropped connection is not selective, so neither is this: the switch decides *when* the
* link dies, never *which* call is unlucky. Passing `() => false` is a broker that stays up.
*
* `doc_subscribe` is carried here rather than left off (`wallet-fake` has none, and the
* library then takes its documented no-op open path): with no subscription there is no
* `State`, so the sync BARRIER would be skipped instead of crossed, and `isPending` would
* fall away for a reason the real platform never gives. It pushes what the platform pushes
* — `TabInfo` then `State`, in that order — and a link that is down pushes NOTHING, which
* is what a link that is down does.
*/
function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void {
const wallet = makeWallet(quads);
let lost = false;
const ng = {
doc_create: wallet.doc_create,
sparql_update: wallet.sparql_update,
sparql_query: mock(async (...a: unknown[]) => {
if (lost || lostAt(a[1] as string)) {
lost = true;
throw new Error("BrokerError: connection lost");
}
return wallet.sparql_query(...a);
}),
doc_subscribe: mock(async (_nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
if (lost) throw new Error("BrokerError: connection lost");
setTimeout(() => {
if (lost) return;
cb({ V0: { TabInfo: {} } });
cb({ V0: { State: {} } });
}, 0);
return () => {};
}),
};
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
}
/**
* The session before: alice signs in and creates one document carrying one Event, over a
* broker that works. Returns the quads the wallet now holds — the durable half that
* outlives the page.
*/
async function aFirstSessionThatCreated(scope: Scope): Promise<{ quads: Quad[]; note: Nuri }> {
const quads: Quad[] = [];
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
const note = await storeRegistry.createEntityDoc(scope);
await sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <urn:test:evt> <${RDF_TYPE}> <${EVENT}> ; <${TITLE}> "the note" }`,
note,
);
return { quads, note };
}
/** A first session where alice signs in and creates NOTHING — a genuinely empty scope. */
async function aFirstSessionThatCreatedNothing(): Promise<Quad[]> {
const quads: Quad[] = [];
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
return quads;
}
/**
* What the APPLICATION reads off a snapshot — the whole point of the surface, written the
* way a view would branch on it. Four readings, and the two this file is about are
* `"nothing"` and `"unknown"`: they must never be the same one.
*/
type Reading = "still-asking" | "nothing" | "some" | "unknown";
function whatTheAppSees(snap: ShapeQuery): Reading {
if (snap.isError) return "unknown";
if (snap.isPending) return "still-asking";
return snap.data.length === 0 ? "nothing" : "some";
}
/** Wait, bounded, for a condition on the observable. A deadline names itself. */
async function until(what: string, pred: () => boolean): Promise<void> {
const deadline = Date.now() + 2000;
while (!pred()) {
if (Date.now() > deadline) throw new Error(`deadline waiting for: ${what}`);
await new Promise((r) => setTimeout(r, 5));
}
}
/** Subscribe like an application does, and wait until the first question has an answer. */
async function subscribeAndSettle(
obs: ShapeObservable,
): Promise<{ renders: () => number; stop: () => void }> {
let renders = 0;
const stop = obs.subscribe(() => {
renders += 1;
});
await until("the first answer", () => !obs.getSnapshot().isPending);
return { renders: () => renders, stop };
}
const ALL_SCOPES: Scope[] = ["public", "protected", "private"];
beforeEach(() => {
forgetEverything();
});
afterAll(() => {
// The caps registry, the config and the registry caches are PROCESS-WIDE, and `bun test`
// runs every file in ONE process — so this teardown is not housekeeping. Caps left behind
// put the possession gate in force for a suite that declares none, and `subscribe.test.ts`
// is then refused at the door for documents it legitimately owns; a config left behind
// hands `access-log.test.ts` this file's `ng`. Both failed exactly that way before this
// ran, and neither names watch-shape anywhere. `forgetEverything` is the whole teardown.
forgetEverything();
});
describe("watchShape tells 'there is nothing' from 'I could not find out'", () => {
// The NORMAL cases first, and they are not a formality: a surface that reported `isError`
// unconditionally would pass every failure case below. These two are what those are a
// departure from — and the first of them is the reading that must never be reused for a
// failure, so it has to be pinned before anything can be said to differ from it.
for (const scope of ALL_SCOPES) {
test(`[${scope}] a scope this account never wrote to reads as NOTHING`, async () => {
const quads = await aFirstSessionThatCreatedNothing();
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
const obs = watchShape(EventShape, scope) as ShapeObservable;
const { stop } = await subscribeAndSettle(obs);
const snap = obs.getSnapshot();
expect(whatTheAppSees(snap)).toBe("nothing");
expect(snap.isSuccess).toBe(true);
expect(snap.isError).toBe(false);
expect(snap.data).toEqual([]);
stop();
});
}
for (const scope of ALL_SCOPES) {
test(`[${scope}] a scope holding a document reads as SOME`, async () => {
const { quads } = await aFirstSessionThatCreated(scope);
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
const obs = watchShape(EventShape, scope) as ShapeObservable;
const { stop } = await subscribeAndSettle(obs);
const snap = obs.getSnapshot();
expect(whatTheAppSees(snap)).toBe("some");
expect(snap.data.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
stop();
});
}
// The defect. Alice HAS a document; the store cannot be read; the surface used to answer
// with the empty-scope snapshot above — same `data`, same `isSuccess`, nothing to tell
// them apart. It is the more dangerous of the two failure values, because "you created
// nothing" is a sentence an application renders without hesitating.
for (const scope of ALL_SCOPES) {
test(`[${scope}] a store that does not answer reads as UNKNOWN, not as nothing`, async () => {
const { quads } = await aFirstSessionThatCreated(scope);
let armed = false;
forgetEverything();
bootPage(quads, (query) => armed && query.includes(LISTING_READ));
await signIn("alice");
armed = true;
const obs = watchShape(EventShape, scope) as ShapeObservable;
const { stop } = await subscribeAndSettle(obs);
const snap = obs.getSnapshot();
expect(whatTheAppSees(snap)).toBe("unknown");
expect(snap.isError).toBe(true);
expect(snap.isSuccess).toBe(false);
expect(snap.isPending).toBe(false);
expect(String((snap.error as Error)?.message)).toMatch(/BrokerError/);
// Said as the difference itself, since one reading standing in for the other IS the
// defect: this is not the snapshot the empty scope publishes.
expect(whatTheAppSees(snap)).not.toBe(whatTheAppSees({
data: [], isPending: false, isSuccess: true, isError: false, error: undefined,
}));
stop();
});
}
// The half a one-shot call never has to answer: the subscriber has ALREADY rendered a
// list when the link dies. The observable cannot un-emit it, so the failure snapshot must
// not put `[]` in the field the view paints — that would be the empty answer again,
// arriving later and to a screen that has something on it.
for (const scope of ALL_SCOPES) {
test(`[${scope}] a list already rendered is not replaced by an empty one`, async () => {
const { quads } = await aFirstSessionThatCreated(scope);
let armed = false;
forgetEverything();
bootPage(quads, (query) => armed && query.includes(LISTING_READ));
await signIn("alice");
const obs = watchShape(EventShape, scope) as ShapeObservable;
const { renders, stop } = await subscribeAndSettle(obs);
expect(whatTheAppSees(obs.getSnapshot())).toBe("some");
const rendersBefore = renders();
// The link dies, and the application asks again — the imperative refresh the surface
// publishes for exactly this.
armed = true;
obs.refetch();
await until("the failure to reach the subscriber", () => obs.getSnapshot().isError);
const snap = obs.getSnapshot();
expect(whatTheAppSees(snap)).toBe("unknown");
expect(snap.isSuccess).toBe(false);
// The list it had is still there — a memory, never offered as a fresh answer.
expect(snap.data.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
// And the subscriber was TOLD: a view that renders `data` and never re-reads the
// load state would otherwise keep showing the list as though it were current.
expect(renders()).toBeGreaterThan(rendersBefore);
stop();
});
}
});