diff --git a/packages/polyfill/src/surface/watch-shape.ts b/packages/polyfill/src/surface/watch-shape.ts index e709673..26bcf51 100644 --- a/packages/polyfill/src/surface/watch-shape.ts +++ b/packages/polyfill/src/surface/watch-shape.ts @@ -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 { - /** 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 { * 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( shapeType: unknown, scope: Scope, @@ -214,9 +244,38 @@ export function watchShape( 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( 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( } /** 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 { const containers: Nuri[] = []; const user = getCurrentUser(); @@ -288,7 +351,7 @@ export function watchShape( 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( } /** 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 { const token = ++refreshToken; try { @@ -318,7 +386,7 @@ export function watchShape( await readAndPublish(docs, token); } catch (error) { if (token !== refreshToken) return; - setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error }); + setUnknown(error); } } diff --git a/packages/polyfill/test/watch-shape-surfaces-failure.test.ts b/packages/polyfill/test/watch-shape-surfaces-failure.test.ts new file mode 100644 index 0000000..ca93d98 --- /dev/null +++ b/packages/polyfill/test/watch-shape-surfaces-failure.test.ts @@ -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 { <${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 { + 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 { + 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(); + }); + } +});