/** * `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(); }); } });