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:
@@ -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);
|
||||
}
|
||||
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user