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
@@ -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();
});
}
});