/** * `useShapeQuery` 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 `useShapeQuery` publishes * `isPending` / `isSuccess` / `isError` precisely to answer the question three ways. * * -- What changed under it in `1.0.0-dev.6`, and what did not --------------- * Until then the read was this package's own pipeline, and the failure it could not be * allowed to mistake was its first step: `listMyEntityDocs` not answering, caught, logged, * and the empty set flowing 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 shipped * twice (`8c8ade7`, `f6d1734`). * * The read now goes through the SDK's shape query, and that step is gone with the pipeline. * The DISTINCTION is not: it is the thing under test here, and the two ways it can now be * broken are both below. The shape query REFUSES TO OPEN - it throws at the call - and the * surface must say `isError`. The shape query opens and the engine never answers - upstream * catches that failure and logs it, so readiness simply never settles - and the surface * must stay `isPending` for ever rather than fall through to the empty answer. * * -- 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 SDK's, and it is one the real one produces ----------- * Nothing here plants a state or reaches into the library to make one of its functions * throw. The documents were CREATED through the surface, over the same wallet, in the * session before; then the injected shape query stops opening, which is what the real * `getOrCreate` does when it cannot subscribe the shape it was handed, and what this * package itself does when `configure` was never given one. */ import { test, expect, describe, afterAll, beforeEach } from "bun:test"; import { configure, useShapeQuery, storeRegistry } from "../src/index"; import type { ShapeObservable, ShapeQuery } from "../src/surface/use-shape-query"; import { configureStoreRegistry } from "../src/shared-wallet/bootstrap"; import { sparqlUpdate } from "../src/surface/docs"; import type { NgLike, StoreType, UseShapeLike } from "../src/model/types"; import type { Nuri } from "../src/model/types"; import { makeOrm, type FakeOrm, type OrmQuad } from "./orm-fake"; import { SESSION, forgetEverything, makeWallet, signIn, storeOfType, type Quad } from "./wallet-fake"; import type { BaseType, ShapeType } from "@ng-org/shex-orm"; 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} and requiring a title. */ const EventShape = { shape: "http://festipod.org/EventShape", schema: { "http://festipod.org/EventShape": { iri: "http://festipod.org/EventShape", predicates: [ { iri: RDF_TYPE, readablePredicate: "@type", dataTypes: [{ literals: [EVENT], valType: "iri" }], minCardinality: 1, maxCardinality: 1, }, { iri: TITLE, readablePredicate: "title", dataTypes: [{ valType: "string" }], minCardinality: 1, maxCardinality: 1, }, ], }, }, } satisfies ShapeType; /** * Wire the library onto `quads`, with the shape query answering over THE SAME wallet. * * That shared dataset is the point: the objects the read hands back are the ones the * surface really wrote, in the session before, rather than a list this file composed. */ function bootPage(quads: Quad[]): FakeOrm { const wallet = makeWallet(quads); const asOrmQuads = (): OrmQuad[] => quads.map((q) => ({ graph: q.g, subject: q.s, predicate: q.p, object: q.o })); const orm = makeOrm({ source: asOrmQuads }); configure({ ng: wallet as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike, ormSubscription: orm.query, }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() }); return orm; } /** * 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: StoreType): Promise<{ quads: Quad[]; note: Nuri }> { const quads: Quad[] = []; forgetEverything(); bootPage(quads); await signIn("alice"); const note = await storeRegistry.createEntityDoc(await storeOfType(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); 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 }; } /** The titles a snapshot carries - what a view would render. */ function titles(snap: ShapeQuery): string[] { return snap.data.map((d) => String(d.title)).sort(); } const ALL_SCOPES: StoreType[] = ["public", "protected", "private"]; /** The whole local dataset - upstream's own marker, and the one reading an application * reaches for when it wants everything it holds. */ const EVERYTHING = { graphs: "did:ng:i" }; 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 use-shape-query anywhere. `forgetEverything` is the whole teardown. forgetEverything(); }); describe("useShapeQuery 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); await signIn("alice"); const obs = useShapeQuery(EventShape, EVERYTHING) 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); await signIn("alice"); const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable; const { stop } = await subscribeAndSettle(obs); const snap = obs.getSnapshot(); expect(whatTheAppSees(snap)).toBe("some"); expect(titles(snap)).toEqual(["the note"]); stop(); }); } // The defect, in the form the new read path can take it. Alice HAS a document; the shape // query cannot be opened; the surface must not 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 shape query that cannot be opened reads as UNKNOWN, not as nothing`, async () => { const { quads } = await aFirstSessionThatCreated(scope); forgetEverything(); const orm = bootPage(quads); await signIn("alice"); orm.refuse("OrmError: the shape could not be subscribed"); const obs = useShapeQuery(EventShape, EVERYTHING) 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(/OrmError/); // 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 other way the read can fail now, and the one upstream actually produces: the query // OPENS and the engine never answers. `orm_start_graph` failing is caught and logged // there, and the readiness promise is built with `resolve` alone, so nothing settles it. // What matters here is the reading an application gets: "still asking", for ever if need // be - and never "there is nothing", which is the sentence it would render. for (const scope of ALL_SCOPES) { test(`[${scope}] a shape query that never answers stays STILL-ASKING, not nothing`, async () => { const { quads } = await aFirstSessionThatCreated(scope); forgetEverything(); const wallet = makeWallet(quads); const orm = makeOrm({ autoDeliver: false, source: () => quads.map((q) => ({ graph: q.g, subject: q.s, predicate: q.p, object: q.o })), }); configure({ ng: wallet as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike, ormSubscription: orm.query, }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() }); await signIn("alice"); const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable; const stop = obs.subscribe(() => {}); await new Promise((r) => setTimeout(r, 50)); const snap = obs.getSnapshot(); expect(whatTheAppSees(snap)).toBe("still-asking"); expect(snap.isPending).toBe(true); expect(snap.isSuccess).toBe(false); expect(snap.data).toEqual([]); // And the moment the engine does answer, it is the document that comes back - so the // pending state was a wait, not a dead end. orm.deliver(); await until("the answer", () => obs.getSnapshot().isSuccess); expect(titles(obs.getSnapshot())).toEqual(["the note"]); stop(); }); } // The half a one-shot call never has to answer: the subscriber has ALREADY rendered a // list when the read stops working. 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); forgetEverything(); const orm = bootPage(quads); await signIn("alice"); const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable; const { renders, stop } = await subscribeAndSettle(obs); expect(whatTheAppSees(obs.getSnapshot())).toBe("some"); const rendersBefore = renders(); // The shape query stops opening, and the application asks again - the imperative // refresh the surface publishes for exactly this. orm.refuse("OrmError: the shape could not be subscribed"); 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(titles(snap)).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(); }); } });