feat!: useShapeQuery lit par la requete de forme du SDK
This commit is contained in:
@@ -1,113 +1,112 @@
|
||||
/**
|
||||
* `useShapeQuery` says "there is nothing" or "I could not find out" — never the first for the second.
|
||||
* `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
|
||||
* cannot - it returns "an empty set, if still loading" - and `useShapeQuery` 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; 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.
|
||||
* -- 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 two questions, and why the second needs its own test ──────────────
|
||||
* 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
|
||||
* 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.
|
||||
* -- 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, mock, beforeEach, afterAll } from "bun:test";
|
||||
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, Nuri, StoreType, UseShapeLike } from "../src/model/types";
|
||||
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";
|
||||
|
||||
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>`;
|
||||
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}. */
|
||||
/** 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, dataTypes: [{ literals: [EVENT], valType: "iri" }] }],
|
||||
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` 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.
|
||||
* Wire the library onto `quads`, with the shape query answering over THE SAME wallet.
|
||||
*
|
||||
* 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.
|
||||
* 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[], lostAt: (query: string) => boolean): void {
|
||||
function bootPage(quads: Quad[]): FakeOrm {
|
||||
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 });
|
||||
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
|
||||
* 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, () => false);
|
||||
bootPage(quads);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
|
||||
await sparqlUpdate(
|
||||
@@ -118,17 +117,17 @@ async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad
|
||||
return { quads, note };
|
||||
}
|
||||
|
||||
/** A first session where alice signs in and creates NOTHING — a genuinely empty scope. */
|
||||
/** 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, () => false);
|
||||
bootPage(quads);
|
||||
await signIn("alice");
|
||||
return quads;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the APPLICATION reads off a snapshot — the whole point of the surface, written the
|
||||
* 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.
|
||||
*/
|
||||
@@ -160,10 +159,15 @@ async function subscribeAndSettle(
|
||||
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 reading that still has to
|
||||
* enumerate this identity's documents, which is the step these tests make fail. */
|
||||
/** 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(() => {
|
||||
@@ -171,7 +175,7 @@ beforeEach(() => {
|
||||
});
|
||||
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
|
||||
// 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
|
||||
@@ -182,14 +186,14 @@ afterAll(() => {
|
||||
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
|
||||
// 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);
|
||||
bootPage(quads);
|
||||
await signIn("alice");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
@@ -209,7 +213,7 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
|
||||
const { quads } = await aFirstSessionThatCreated(scope);
|
||||
|
||||
forgetEverything();
|
||||
bootPage(quads, () => false);
|
||||
bootPage(quads);
|
||||
await signIn("alice");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
@@ -217,24 +221,24 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(whatTheAppSees(snap)).toBe("some");
|
||||
expect(snap.data.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
|
||||
expect(titles(snap)).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.
|
||||
// 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 store that does not answer reads as UNKNOWN, not as nothing`, async () => {
|
||||
test(`[${scope}] a shape query that cannot be opened reads as UNKNOWN, not as nothing`, async () => {
|
||||
const { quads } = await aFirstSessionThatCreated(scope);
|
||||
|
||||
let armed = false;
|
||||
forgetEverything();
|
||||
bootPage(quads, (query) => armed && query.includes(LISTING_READ));
|
||||
const orm = bootPage(quads);
|
||||
await signIn("alice");
|
||||
armed = true;
|
||||
orm.refuse("OrmError: the shape could not be subscribed");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
const { stop } = await subscribeAndSettle(obs);
|
||||
@@ -244,7 +248,7 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
|
||||
expect(snap.isError).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(String((snap.error as Error)?.message)).toMatch(/BrokerError/);
|
||||
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({
|
||||
@@ -254,17 +258,58 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
|
||||
});
|
||||
}
|
||||
|
||||
// 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 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.
|
||||
// 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);
|
||||
|
||||
let armed = false;
|
||||
forgetEverything();
|
||||
bootPage(quads, (query) => armed && query.includes(LISTING_READ));
|
||||
const orm = bootPage(quads);
|
||||
await signIn("alice");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
@@ -272,17 +317,17 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
|
||||
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;
|
||||
// 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(snap.data.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user