Files
ng-eventually/packages/polyfill/test/use-shape-query-surfaces-failure.test.ts
T
Sylvain Duchesne d4b392a119 chore: les dix injections de useShape passent par le faux partage
Dix sites, pas les deux que le suivi annoncait : `(() => {}) as unknown as
UseShapeLike` survivait la ou le type etait encore `any` et ou le cast ne
disait donc rien. Ils prennent le faux partage, sans cast.
2026-09-24 19:00:25 +02:00

339 lines
15 KiB
TypeScript

/**
* `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 { makeUseShapeHook } from "./use-shape-fake";
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<BaseType>;
/**
* 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: makeUseShapeHook().hook,
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 { <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);
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 };
}
/** 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: makeUseShapeHook().hook,
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();
});
}
});