feat!: useShapeQuery lit par la requete de forme du SDK
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* orm-fake - a stand-in for the SDK's shape query, `RdfOrmSubscription.getOrCreate`.
|
||||
*
|
||||
* Not a `*.test.ts`, so `bun test` does not pick it up: it is the injection two suites
|
||||
* share, and it is the seam the package is built on - an application hands `configure`
|
||||
* the real ORM, and a suite hands it this.
|
||||
*
|
||||
* -- Why a small ENGINE and not a canned answer ----------------------------
|
||||
* `useShapeQuery` no longer interprets a scope: `graphs`, `subjects` and the
|
||||
* whole-dataset marker go to the engine verbatim. A fake that ignored them and returned
|
||||
* a fixed list would make every scope test green while proving nothing - the failure
|
||||
* mode `test-the-case-space` is about. So this answers the way
|
||||
* `schema_shape_to_sparql` / `query_quads_for_shape` do, and the three readings of the
|
||||
* axes are real here:
|
||||
*
|
||||
* - `graphs: []` (or absent) -> NO objects. `graphs` carrying `did:ng:i` or `""` ->
|
||||
* every other entry is discarded and the graph filter drops entirely. Otherwise the
|
||||
* filter is LITERAL - a graph is in the list or it is not, no prefix matching.
|
||||
* - `subjects: []` -> no filtering; otherwise literal, over ROOT subjects.
|
||||
* - the shape -> every predicate of `schema[shape]` must be present on the subject,
|
||||
* and a predicate carrying `literals` must match one of them. The object is
|
||||
* projected under each predicate's `readablePredicate`, plus `@id` and `@graph`.
|
||||
*
|
||||
* -- What it does NOT invent -----------------------------------------------
|
||||
* Readiness resolves on ONE path - a delivery - exactly as upstream's `resolveReady`
|
||||
* runs only on the arrival of initial data. Nothing here ever REJECTS `readyPromise`,
|
||||
* because nothing upstream does: `orm_start_graph` failing is caught and logged, and a
|
||||
* refused read is an eternal pending. A suite that wants the pending-for-ever state
|
||||
* asks for it by not delivering, which is the state the real thing produces.
|
||||
*
|
||||
* {@link FakeOrm.refuse} is the other real failure: `getOrCreate` THROWS at the call.
|
||||
*/
|
||||
|
||||
import type { OrmSubscriptionHandle, OrmSubscriptionLike } from "../src/model/types";
|
||||
import type { Scope } from "@ng-org/orm";
|
||||
import type { BaseType, Predicate, ShapeType } from "@ng-org/shex-orm";
|
||||
|
||||
/** Upstream's whole-dataset marker; `""` means the same thing. */
|
||||
const ENTIRE_DATASET = "did:ng:i";
|
||||
|
||||
/** One triple in the dataset this fake engine answers from. */
|
||||
export interface OrmQuad {
|
||||
graph: string;
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
type Listener = (changes: { adds: unknown[]; updates: unknown[]; removes: unknown[] }) => void;
|
||||
|
||||
interface OpenQuery {
|
||||
shape: ShapeType<BaseType>;
|
||||
scope: Scope;
|
||||
listeners: Set<Listener>;
|
||||
items: BaseType[];
|
||||
delivered: boolean;
|
||||
closed: boolean;
|
||||
resolveReady: () => void;
|
||||
}
|
||||
|
||||
export interface FakeOrm {
|
||||
/** What goes into `configure({ ormSubscription })`. */
|
||||
query: OrmSubscriptionLike;
|
||||
/** Add a triple to the dataset. After a delivery this also pushes, as an engine patch
|
||||
* reaching the signal object does. */
|
||||
seed(graph: string, subject: string, predicate: string, object: string): void;
|
||||
/** Deliver the initial payload to every query still waiting for one - what resolves
|
||||
* readiness upstream, and the only thing that does. */
|
||||
deliver(): void;
|
||||
/** Make every SUBSEQUENT `getOrCreate` throw. The one failure this path really has. */
|
||||
refuse(message: string): void;
|
||||
/** Stop refusing. */
|
||||
answer(): void;
|
||||
/** The scopes this fake was asked for, in order - so a suite can assert the scope
|
||||
* reached the SDK exactly as the caller wrote it. */
|
||||
asked: Array<{ shape: string; scope: Scope }>;
|
||||
/** How many queries are open right now (`close()` decrements). */
|
||||
openCount(): number;
|
||||
}
|
||||
|
||||
/** The literal values a predicate constrains its object to, if any. */
|
||||
function literalsOf(p: Predicate): string[] {
|
||||
const out: string[] = [];
|
||||
for (const dt of p.dataTypes ?? []) {
|
||||
for (const lit of dt.literals ?? []) if (typeof lit === "string") out.push(lit);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Does `subject` in `graph` satisfy every predicate of the shape? If so, the object. */
|
||||
function project(
|
||||
quads: OrmQuad[],
|
||||
graph: string,
|
||||
subject: string,
|
||||
predicates: Predicate[],
|
||||
): BaseType | null {
|
||||
const own = quads.filter((q) => q.graph === graph && q.subject === subject);
|
||||
const out: Record<string, unknown> = { "@id": subject, "@graph": graph };
|
||||
for (const p of predicates) {
|
||||
const wanted = literalsOf(p);
|
||||
const values = own
|
||||
.filter((q) => q.predicate === p.iri)
|
||||
.map((q) => q.object)
|
||||
.filter((o) => wanted.length === 0 || wanted.includes(o));
|
||||
if (values.length === 0) return null; // a required triple pattern with no match
|
||||
out[p.readablePredicate] = values.length === 1 ? values[0] : values;
|
||||
}
|
||||
return out as BaseType;
|
||||
}
|
||||
|
||||
/** The objects the engine would return for one shape in one scope. */
|
||||
function answerFor(quads: OrmQuad[], shape: ShapeType<BaseType>, scope: Scope): BaseType[] {
|
||||
const graphs = (typeof scope.graphs === "string" ? [scope.graphs] : (scope.graphs ?? [])).map(
|
||||
(g) => (g === "" ? ENTIRE_DATASET : g),
|
||||
);
|
||||
if (graphs.length === 0) return []; // upstream: no objects are returned
|
||||
const everywhere = graphs.includes(ENTIRE_DATASET);
|
||||
const subjects = scope.subjects ?? [];
|
||||
const predicates = shape.schema[shape.shape]?.predicates ?? [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: BaseType[] = [];
|
||||
for (const q of quads) {
|
||||
if (!everywhere && !graphs.includes(q.graph)) continue;
|
||||
if (subjects.length > 0 && !subjects.includes(q.subject)) continue;
|
||||
const key = q.graph + "/" + q.subject;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const obj = project(quads, q.graph, q.subject, predicates);
|
||||
if (obj !== null) out.push(obj);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake shape query over an in-memory dataset.
|
||||
*
|
||||
* `autoDeliver` (the default) hands every query its initial payload on the next tick,
|
||||
* which is the convenient mode for suites that are not about readiness. Turn it off and
|
||||
* call {@link FakeOrm.deliver} to hold the pending state open.
|
||||
*/
|
||||
export function makeOrm(
|
||||
options: { autoDeliver?: boolean; source?: () => OrmQuad[] } = {},
|
||||
): FakeOrm {
|
||||
const quads: OrmQuad[] = [];
|
||||
/** Where the engine reads from - its own dataset, or one a suite already holds (the
|
||||
* fake wallet's, so the objects read back are the ones the surface really wrote). */
|
||||
const dataset = options.source ?? (() => quads);
|
||||
const open = new Set<OpenQuery>();
|
||||
const asked: Array<{ shape: string; scope: Scope }> = [];
|
||||
const autoDeliver = options.autoDeliver !== false;
|
||||
let refusal: string | null = null;
|
||||
|
||||
function recompute(q: OpenQuery): void {
|
||||
if (!q.delivered || q.closed) return;
|
||||
q.items = answerFor(dataset(), q.shape, q.scope);
|
||||
for (const l of q.listeners) l({ adds: q.items, updates: [], removes: [] });
|
||||
}
|
||||
|
||||
function deliverOne(q: OpenQuery): void {
|
||||
if (q.delivered || q.closed) return;
|
||||
q.delivered = true;
|
||||
q.items = answerFor(dataset(), q.shape, q.scope);
|
||||
q.resolveReady();
|
||||
for (const l of q.listeners) l({ adds: q.items, updates: [], removes: [] });
|
||||
}
|
||||
|
||||
const query = (<T extends BaseType>(
|
||||
shapeType: ShapeType<T>,
|
||||
scope: Scope,
|
||||
): OrmSubscriptionHandle => {
|
||||
if (refusal !== null) throw new Error(refusal);
|
||||
const shape = shapeType as unknown as ShapeType<BaseType>;
|
||||
asked.push({ shape: shape.shape, scope });
|
||||
let resolveReady!: () => void;
|
||||
const readyPromise = new Promise<void>((r) => (resolveReady = r));
|
||||
const record: OpenQuery = {
|
||||
shape,
|
||||
scope,
|
||||
listeners: new Set(),
|
||||
items: [],
|
||||
delivered: false,
|
||||
closed: false,
|
||||
resolveReady,
|
||||
};
|
||||
open.add(record);
|
||||
if (autoDeliver) setTimeout(() => deliverOne(record), 0);
|
||||
return {
|
||||
readyPromise,
|
||||
get signalObject(): Iterable<unknown> {
|
||||
return record.items;
|
||||
},
|
||||
addChangeListener(listener: Listener): void {
|
||||
record.listeners.add(listener);
|
||||
},
|
||||
removeChangeListener(listener: Listener): void {
|
||||
record.listeners.delete(listener);
|
||||
},
|
||||
close(): void {
|
||||
record.closed = true;
|
||||
record.listeners.clear();
|
||||
open.delete(record);
|
||||
},
|
||||
};
|
||||
}) as OrmSubscriptionLike;
|
||||
|
||||
return {
|
||||
query,
|
||||
seed(graph, subject, predicate, object): void {
|
||||
quads.push({ graph, subject, predicate, object });
|
||||
for (const q of open) recompute(q);
|
||||
},
|
||||
deliver(): void {
|
||||
for (const q of [...open]) deliverOne(q);
|
||||
},
|
||||
refuse(message): void {
|
||||
refusal = message;
|
||||
},
|
||||
answer(): void {
|
||||
refusal = null;
|
||||
},
|
||||
asked,
|
||||
openCount: () => open.size,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,38 +1,36 @@
|
||||
/**
|
||||
* use-shape-query.test.ts — behavioural tests for `useShapeQuery`
|
||||
* (src/surface/use-shape-query.ts),
|
||||
* against a STATEFUL fake `ng` with a CONTROLLABLE `doc_subscribe`.
|
||||
* use-shape-query.test.ts - behavioural tests for `useShapeQuery`
|
||||
* (src/surface/use-shape-query.ts), against the SDK's shape query stood in for by
|
||||
* `orm-fake.ts` and a stateful fake `ng` for everything else the page does.
|
||||
*
|
||||
* The fake emulates just enough of the broker:
|
||||
* - `doc_create` mints monotonic doc NURIs.
|
||||
* - `sparql_update` parses the shim account writes + the per-entity index
|
||||
* `contains` append + arbitrary anchored triple writes into an in-memory quad
|
||||
* store (same tolerant parser shape as store-registry.test / read-model.test).
|
||||
* - `sparql_query` answers the shim account SELECT, the scope-index `contains`
|
||||
* SELECT, and the anchored per-doc `?s ?p ?o` read (readUnion) — each scoped to
|
||||
* the anchor graph.
|
||||
* - `doc_subscribe` models the platform push order TabInfo→State: on subscribe it
|
||||
* records the callback and fires a `TabInfo` immediately, but the sync BARRIER
|
||||
* `State` is fired only when the TEST releases it (`fireState`) — so we can
|
||||
* assert isPending BEFORE the barrier and isSuccess AFTER. A later write to a
|
||||
* subscribed doc fires a `Patch` push (reactivity).
|
||||
* -- The montage, and why it takes TWO fakes -------------------------------
|
||||
* The read goes through the SDK now, so the thing that answers it is the injected
|
||||
* shape query (`test/orm-fake.ts`, a small engine that honours the scope axes the way
|
||||
* `schema_shape_to_sparql` does). Everything AROUND the read is still this package's:
|
||||
* signing in, creating a document, filing its capability. That is the fake `ng` - and
|
||||
* keeping it is what makes the documents here real ones, obtained from
|
||||
* `createEntityDoc` rather than named by the test.
|
||||
*
|
||||
* These prove the four distinctions the surface exists for:
|
||||
* (a) isPending at first, isSuccess after the first State (barrier);
|
||||
* (b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction);
|
||||
* (c) a write then push → data updates (reactivity, no polling);
|
||||
* (d) timed-out → isSuccess (best-effort), NOT isError;
|
||||
* plus the three readings of the SDK `Scope` the surface now takes: the whole dataset,
|
||||
* a named `graphs` list, and the empty list that upstream answers with no objects.
|
||||
* These prove the distinctions the surface exists for:
|
||||
* (a) isPending until the initial payload arrives, isSuccess once it has;
|
||||
* (b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction);
|
||||
* (c) an engine patch updates data (reactivity, no polling);
|
||||
* (d) a query that never delivers stays isPending - it never becomes "nothing";
|
||||
* plus the three readings of the SDK `Scope`, which this module no longer interprets
|
||||
* and therefore has to hand over VERBATIM, and the capability filter over what comes
|
||||
* back.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
||||
import { useShapeQuery } from "../src/surface/use-shape-query";
|
||||
import { configure } from "../src/index";
|
||||
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||
import { configureStoreRegistry, setCurrentUser, getCaps } from "../src/shared-wallet/bootstrap";
|
||||
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
|
||||
import { resetOpenedRepos, setOpenTimeoutForTests, getSyncState } from "../src/emulated-verifier/open-repo";
|
||||
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
||||
import { makeOrm, type FakeOrm } from "./orm-fake";
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
import type { NgLike, UseShapeLike } from "../src/model/types";
|
||||
|
||||
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
|
||||
@@ -57,26 +55,13 @@ function unescapeLiteral(s: string): string {
|
||||
interface SubRec { nuri: string; cb: (r: unknown) => void }
|
||||
|
||||
/**
|
||||
* The stateful fake with a controllable doc_subscribe. `holdState: true` means a
|
||||
* fresh subscription does NOT auto-fire its `State` — the test fires it via
|
||||
* `fireState(nuri)`. `holdState: false` (default) auto-fires `State` on subscribe
|
||||
* (synced immediately), which is the convenient mode for the reactivity/empty cases.
|
||||
* The stateful fake `ng` - the shim account, the per-scope index and the document
|
||||
* creation the page does around the read. It is NOT what answers the read any more.
|
||||
*/
|
||||
function makeFake(opts?: { holdState?: boolean }) {
|
||||
function makeFake() {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
const subs: SubRec[] = [];
|
||||
const hold = opts?.holdState ?? false;
|
||||
// Nuris whose barrier `State` has been released (auto-fire on future subscribe).
|
||||
const released = new Set<string>();
|
||||
// The shim ANCHOR (private-store-root) is ALWAYS loaded/synced on the real broker
|
||||
// (the store repo is bootstrapped at connect), so its barrier `State` is always
|
||||
// available. `resolveAccount`/`ensureAccount` now open it (the cold-start heal)
|
||||
// before touching the shim — pre-release it here so `holdState` (which gates the
|
||||
// per-ENTITY docs the tests control) never blocks the anchor open. This mirrors the
|
||||
// real invariant the production heal relies on.
|
||||
released.add(`did:ng:${SESSION.privateStoreId}`);
|
||||
let releaseEverything = false;
|
||||
|
||||
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||
|
||||
@@ -104,17 +89,7 @@ function makeFake(opts?: { holdState?: boolean }) {
|
||||
const p = m[1] ?? "urn:ng-eventually:shim:Account";
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g, s, p, o });
|
||||
// The doc-shim (named by the write-once pointer triple) is INFRASTRUCTURE, like
|
||||
// the store-root: `doc_create` bootstrapped it into the session, so its barrier
|
||||
// `State` is immediately available. Pre-release it so `holdState` (which gates the
|
||||
// per-ENTITY docs the tests control) never blocks the doc-shim open. The pointer is
|
||||
// published BEFORE the doc-shim barrier open (resolveShimDoc first-login order).
|
||||
if (p === "urn:ng-eventually:shim:shimDoc") {
|
||||
released.add(o);
|
||||
for (const sub of subs) if (sub.nuri === o) sub.cb({ V0: { State: {} } });
|
||||
}
|
||||
}
|
||||
// A write to a subscribed doc fires a Patch push (reactivity signal).
|
||||
for (const sub of subs) {
|
||||
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
|
||||
}
|
||||
@@ -132,9 +107,7 @@ function makeFake(opts?: { holdState?: boolean }) {
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||
const subjM = query.match(
|
||||
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
|
||||
);
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
@@ -157,70 +130,37 @@ function makeFake(opts?: { holdState?: boolean }) {
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:inboxCap>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:inboxCap")
|
||||
.map((q) => ({ c: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
for (const marker of ["inboxCap", "readCap", "contains"] as const) {
|
||||
if (query.includes(`<urn:ng-eventually:shim:${marker}>`)) {
|
||||
const key = marker === "contains" ? "e" : "c";
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `urn:ng-eventually:shim:${marker}`)
|
||||
.map((q) => ({ [key]: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:readCap>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:readCap")
|
||||
.map((q) => ({ c: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:contains>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
||||
.map((q) => ({ e: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`).
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor)
|
||||
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
});
|
||||
|
||||
const doc_subscribe = mock(
|
||||
async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
||||
subs.push({ nuri, cb });
|
||||
// Platform pushes TabInfo FIRST (never the barrier).
|
||||
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
|
||||
// Fire the barrier State if this fake auto-syncs, or if this nuri was already
|
||||
// released (so a doc subscribed AFTER a release still crosses the barrier).
|
||||
if (!hold || releaseEverything || released.has(nuri)) {
|
||||
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
||||
subs.push({ nuri, cb });
|
||||
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
|
||||
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
/** Release the barrier for `nuri` (fire State now + auto-fire for future subs). */
|
||||
function fireState(nuri: string): void {
|
||||
released.add(nuri);
|
||||
for (const sub of subs) if (sub.nuri === nuri) sub.cb({ V0: { State: {} } });
|
||||
}
|
||||
/** Release the barrier for EVERY doc, present and future. */
|
||||
function releaseAll(): void {
|
||||
releaseEverything = true;
|
||||
for (const sub of subs) sub.cb({ V0: { State: {} } });
|
||||
}
|
||||
|
||||
return {
|
||||
doc_create,
|
||||
sparql_update,
|
||||
sparql_query,
|
||||
doc_subscribe,
|
||||
_quads: quads,
|
||||
fireState,
|
||||
releaseAll,
|
||||
subs,
|
||||
};
|
||||
return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads };
|
||||
}
|
||||
|
||||
function inject(ng: ReturnType<typeof makeFake>) {
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
function inject(ng: ReturnType<typeof makeFake>, orm: FakeOrm) {
|
||||
configure({
|
||||
ng: ng as unknown as NgLike,
|
||||
useShape: (() => {}) as unknown as UseShapeLike,
|
||||
ormSubscription: orm.query,
|
||||
});
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
@@ -230,36 +170,55 @@ function inject(ng: ReturnType<typeof makeFake>) {
|
||||
resetCaps();
|
||||
}
|
||||
|
||||
// Insert a triple straight into a doc's graph in the fake store (no push).
|
||||
function seed(ng: ReturnType<typeof makeFake>, doc: string, p: string, o: string): void {
|
||||
ng._quads.push({ g: doc, s: doc, p, o });
|
||||
}
|
||||
|
||||
// The same, under a subject of its own rather than the document's reference: one document
|
||||
// may hold several subjects, which is what the scope's `subjects` axis filters on.
|
||||
function seedSubject(
|
||||
ng: ReturnType<typeof makeFake>,
|
||||
doc: string,
|
||||
subject: string,
|
||||
p: string,
|
||||
o: string,
|
||||
): void {
|
||||
ng._quads.push({ g: doc, s: subject, p, o });
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
// A minimal SHEX ShapeType pinning rdf:type to `${FP}Event`.
|
||||
/**
|
||||
* A minimal SHEX ShapeType pinning `rdf:type` to `${FP}Event` and carrying a title.
|
||||
*
|
||||
* Both predicates are `minCardinality: 1`, because that is what
|
||||
* `schema_shape_to_sparql` turns into REQUIRED triple patterns: a shape with none of
|
||||
* them would match every subject in the dataset. `rdf-orm build` generates exactly this
|
||||
* object from a `.shex` file, and the generator is not what is under test here.
|
||||
*/
|
||||
const EventShape = {
|
||||
shape: `${FP}EventShape`,
|
||||
schema: {
|
||||
[`${FP}EventShape`]: {
|
||||
iri: `${FP}EventShape`,
|
||||
predicates: [{ iri: TYPE, dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }] }],
|
||||
predicates: [
|
||||
{
|
||||
iri: TYPE,
|
||||
readablePredicate: "@type",
|
||||
dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }],
|
||||
minCardinality: 1,
|
||||
maxCardinality: 1,
|
||||
},
|
||||
{
|
||||
iri: `${FP}title`,
|
||||
readablePredicate: "title",
|
||||
dataTypes: [{ valType: "string" }],
|
||||
minCardinality: 1,
|
||||
maxCardinality: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
} satisfies ShapeType<BaseType>;
|
||||
|
||||
/** The titles a snapshot carries, sorted - what a view would render. */
|
||||
function titles(data: BaseType[]): string[] {
|
||||
return data.map((d) => String(d.title)).sort();
|
||||
}
|
||||
|
||||
/** Seed one shape-matching object into a document's graph. */
|
||||
function seedEvent(orm: FakeOrm, doc: string, subject: string, title: string): void {
|
||||
orm.seed(doc, subject, TYPE, `${FP}Event`);
|
||||
orm.seed(doc, subject, `${FP}title`, title);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetRegistryCache();
|
||||
});
|
||||
afterEach(() => {
|
||||
setCurrentUser(null);
|
||||
});
|
||||
@@ -274,14 +233,13 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe("useShapeQuery", () => {
|
||||
it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => {
|
||||
const ng = makeFake({ holdState: true });
|
||||
inject(ng);
|
||||
it("(a) isPending until the initial payload arrives, then isSuccess with the data", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm({ autoDeliver: false });
|
||||
inject(ng, orm);
|
||||
setCurrentUser("alice");
|
||||
// One protected entity doc for alice, carrying an Event triple.
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "Alpha");
|
||||
seedEvent(orm, doc, `${doc}:q:one`, "Alpha");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
let notes = 0;
|
||||
@@ -289,33 +247,30 @@ describe("useShapeQuery", () => {
|
||||
notes += 1;
|
||||
});
|
||||
|
||||
// Before the barrier: pending, no data.
|
||||
// Before the engine answers: pending, no data.
|
||||
await tick();
|
||||
expect(obs.getSnapshot().isPending).toBe(true);
|
||||
expect(obs.getSnapshot().isSuccess).toBe(false);
|
||||
expect(obs.getSnapshot().data).toEqual([]);
|
||||
|
||||
// Release the barrier for every opened doc (present + future) → synced.
|
||||
ng.releaseAll();
|
||||
await tick();
|
||||
await tick();
|
||||
orm.deliver();
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.data.length).toBe(1);
|
||||
expect(snap.data[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
|
||||
expect(titles(snap.data)).toEqual(["Alpha"]);
|
||||
expect(notes).toBeGreaterThan(0);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction)", async () => {
|
||||
const ng = makeFake(); // auto-fires State → synced immediately
|
||||
inject(ng);
|
||||
it("(b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction)", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("bob");
|
||||
// bob has NO entity docs in this scope — the scope is genuinely empty.
|
||||
// bob has no document of this shape anywhere - the scope is genuinely empty.
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
@@ -324,139 +279,160 @@ describe("useShapeQuery", () => {
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true); // synced, NOT stuck pending
|
||||
expect(snap.isSuccess).toBe(true); // answered, NOT stuck pending
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.data).toEqual([]); // empty — distinguishable from "still syncing"
|
||||
expect(snap.data).toEqual([]); // empty - distinguishable from "still asking"
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(c) a write then push updates data (reactivity, no polling)", async () => {
|
||||
const ng = makeFake(); // synced immediately
|
||||
inject(ng);
|
||||
it("(c) an engine patch updates data (reactivity, no polling)", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("carol");
|
||||
const doc = await createEntityDoc("carol", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "One");
|
||||
seedEvent(orm, doc, `${doc}:q:one`, "One");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
expect(obs.getSnapshot().data.length).toBe(1);
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["One"]);
|
||||
|
||||
// Write a SECOND event doc + fire the push via a write to the ALREADY-subscribed
|
||||
// doc. Because a new doc must appear in the set, write into the scope-INDEX
|
||||
// (createEntityDoc appends to it, and the index is subscribed → re-resolve).
|
||||
// A second object lands in a second document - the engine pushes it into the live
|
||||
// subscription, and the snapshot follows. No document subscription of ours, no timer.
|
||||
const doc2 = await createEntityDoc("carol", "protected");
|
||||
seed(ng, doc2, TYPE, `${FP}Event`);
|
||||
seed(ng, doc2, `${FP}title`, "Two");
|
||||
// createEntityDoc's index append fired a Patch on the index doc → re-resolve.
|
||||
await tick();
|
||||
seedEvent(orm, doc2, `${doc2}:q:one`, "Two");
|
||||
await tick();
|
||||
|
||||
const titles = obs
|
||||
.getSnapshot()
|
||||
.data.flatMap((s) => s.props[`${FP}title`] ?? [])
|
||||
.sort();
|
||||
expect(titles).toEqual(["One", "Two"]);
|
||||
// No setInterval anywhere — reactivity was push-driven.
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["One", "Two"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(d) timed-out → isSuccess (best-effort), NOT isError", async () => {
|
||||
// A doc whose subscription NEVER pushes a `State`: open-repo's bounded fallback
|
||||
// fires and marks the nuri "timed-out" (NOT "synced"). We shrink the fallback to
|
||||
// a few ms so this is fast, and assert the barrier is genuinely reached via
|
||||
// timed-out (getSyncState === "timed-out") and that the snapshot maps that to
|
||||
// isSuccess, never isError.
|
||||
const ng = makeFake({ holdState: true }); // State is never released
|
||||
inject(ng);
|
||||
setOpenTimeoutForTests(20); // fallback fires quickly instead of after 8s
|
||||
it("(d) a query that never delivers stays isPending - never 'there is nothing'", async () => {
|
||||
// The state the real ORM produces when the engine refuses a start: `orm_start_graph`
|
||||
// is called inside a `try { ... } catch (e) { console.error(e) }` and the readiness
|
||||
// promise is built with `resolve` alone, so nothing ever settles it. The surface must
|
||||
// keep saying "still asking" - the one thing it must never do is publish the
|
||||
// empty-and-answered snapshot, which is what an application renders as "you have
|
||||
// created nothing".
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm({ autoDeliver: false });
|
||||
inject(ng, orm);
|
||||
setCurrentUser("dave");
|
||||
const doc = await createEntityDoc("dave", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "Timed");
|
||||
seedEvent(orm, doc, `${doc}:q:one`, "Never arrives");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
// Before the fallback fires: still pending (subscribed, no State).
|
||||
expect(obs.getSnapshot().isPending).toBe(true);
|
||||
|
||||
// Let the bounded fallback elapse → open-repo marks each opened doc timed-out.
|
||||
// Long enough for FOUR of them in a row: the whole-dataset reading opens the three
|
||||
// store index documents as well as the entity document, and with the `State` held
|
||||
// back each one waits out the full fallback before the next begins.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await tick();
|
||||
await tick();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// The entity doc's barrier resolved via timed-out (never a State).
|
||||
expect(getSyncState(doc)).toBe("timed-out");
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isPending).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true); // timed-out is best-effort success
|
||||
expect(snap.isPending).toBe(false);
|
||||
// The data still read (best-effort): the doc's triples resolved.
|
||||
expect(snap.data.length).toBe(1);
|
||||
expect(snap.data).toEqual([]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// ── the scope axis: the three readings upstream gives it ──────────────────
|
||||
it("(e) opening the shape query threw -> isError, and never an empty answer", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("erin");
|
||||
orm.refuse("the shape could not be subscribed");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(String((snap.error as Error).message)).toMatch(/could not be subscribed/);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(f) `configure` without a shape query says so, rather than answering empty", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
// ...and then a bootstrap that forgot the injection. The read has nothing to go
|
||||
// through, and an empty set would be the one lie this surface exists to prevent.
|
||||
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
});
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(String((snap.error as Error).message)).toMatch(/ormSubscription/);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- the scope axis: the three readings upstream gives it ------------------
|
||||
//
|
||||
// These are not variants of one case. `graphs` names ONE document's graph per entry;
|
||||
// `did:ng:i` (or `""`) discards the rest and means the whole dataset; `[]` or
|
||||
// `undefined` returns no objects at all. The first two answer differently over the SAME
|
||||
// wallet, and the third is the one a caller reaches by accident - so each is pinned.
|
||||
// `undefined` returns no objects at all. This module no longer interprets any of it -
|
||||
// the scope is handed to the SDK verbatim - so each case pins BOTH that the object
|
||||
// arrived unchanged and what the engine then answers.
|
||||
|
||||
it("(e) a named `graphs` list reads those documents and no others", async () => {
|
||||
it("(g) a named `graphs` list reaches the SDK verbatim and reads those documents only", async () => {
|
||||
const ng = makeFake();
|
||||
inject(ng);
|
||||
setCurrentUser("erin");
|
||||
const kept = await createEntityDoc("erin", "protected");
|
||||
seed(ng, kept, TYPE, `${FP}Event`);
|
||||
seed(ng, kept, `${FP}title`, "Named");
|
||||
const other = await createEntityDoc("erin", "protected");
|
||||
seed(ng, other, TYPE, `${FP}Event`);
|
||||
seed(ng, other, `${FP}title`, "Unnamed");
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("frank");
|
||||
const kept = await createEntityDoc("frank", "protected");
|
||||
seedEvent(orm, kept, `${kept}:q:one`, "Named");
|
||||
const other = await createEntityDoc("frank", "protected");
|
||||
seedEvent(orm, other, `${other}:q:one`, "Unnamed");
|
||||
|
||||
// The whole dataset sees both - that is the reading the named list departs from.
|
||||
const all = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsubAll = all.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
expect(all.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? []).sort()).toEqual([
|
||||
"Named",
|
||||
"Unnamed",
|
||||
]);
|
||||
expect(titles(all.getSnapshot().data)).toEqual(["Named", "Unnamed"]);
|
||||
unsubAll();
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: [kept] });
|
||||
const scope = { graphs: [kept] };
|
||||
const obs = useShapeQuery(EventShape, scope);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(snap.data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Named"]);
|
||||
expect(obs.getSnapshot().isSuccess).toBe(true);
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["Named"]);
|
||||
// The object the caller wrote is the object the SDK received: no translation, no
|
||||
// copy, nothing added. That is what removing the pipeline bought.
|
||||
expect(orm.asked.at(-1)!.scope).toBe(scope);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(f) an empty `graphs` returns no objects, and says so as SUCCESS", async () => {
|
||||
it("(h) an empty `graphs` returns no objects, and says so as SUCCESS", async () => {
|
||||
const ng = makeFake();
|
||||
inject(ng);
|
||||
setCurrentUser("frank");
|
||||
const doc = await createEntityDoc("frank", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "Unreachable");
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("gina");
|
||||
const doc = await createEntityDoc("gina", "protected");
|
||||
seedEvent(orm, doc, `${doc}:q:one`, "Unreachable");
|
||||
|
||||
// Upstream: "Setting value to `[]` or leaving it `undefined`, no objects are returned."
|
||||
// It is an ANSWER, not a failure - and not the pending state either, which is what an
|
||||
// application would otherwise be left staring at.
|
||||
// `graphs` became REQUIRED in `@ng-org/orm@0.1.2-alpha.21`, so the "leaving it
|
||||
// `undefined`" half of that sentence is no longer reachable by omitting the key. `[]`
|
||||
// is the other half, and it is the one that still spells "empty" - `normalizeScope`
|
||||
// maps both onto the same empty list, so this asks the same question it always did.
|
||||
// is the other half, and it is the one that still spells "empty".
|
||||
const obs = useShapeQuery(EventShape, { graphs: [] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
@@ -470,26 +446,22 @@ describe("useShapeQuery", () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(g) `subjects` narrows to the subject IRIs named, literally", async () => {
|
||||
it("(i) `subjects` narrows to the subject IRIs named, literally", async () => {
|
||||
const ng = makeFake();
|
||||
inject(ng);
|
||||
setCurrentUser("gina");
|
||||
const doc = await createEntityDoc("gina", "protected");
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("hank");
|
||||
const doc = await createEntityDoc("hank", "protected");
|
||||
// Two subjects in ONE document: the `subjects` axis is what tells them apart, and
|
||||
// `graphs` cannot - both carry the same `@graph`.
|
||||
seedSubject(ng, doc, `${FP}one`, TYPE, `${FP}Event`);
|
||||
seedSubject(ng, doc, `${FP}one`, `${FP}title`, "First");
|
||||
seedSubject(ng, doc, `${FP}two`, TYPE, `${FP}Event`);
|
||||
seedSubject(ng, doc, `${FP}two`, `${FP}title`, "Second");
|
||||
seedEvent(orm, doc, `${FP}one`, "First");
|
||||
seedEvent(orm, doc, `${FP}two`, "Second");
|
||||
|
||||
const both = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsubBoth = both.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
expect(both.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? []).sort()).toEqual([
|
||||
"First",
|
||||
"Second",
|
||||
]);
|
||||
expect(titles(both.getSnapshot().data)).toEqual(["First", "Second"]);
|
||||
unsubBoth();
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] });
|
||||
@@ -497,8 +469,52 @@ describe("useShapeQuery", () => {
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
expect(obs.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Second"]);
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["Second"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- the capability filter, which this module still owns -------------------
|
||||
|
||||
it("(j) an object whose document the holder has no capability for does not come back", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("iris");
|
||||
// Hers: created through the surface, so its capability is filed as a by-product.
|
||||
const mine = await createEntityDoc("iris", "protected");
|
||||
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
||||
// Somebody else's, sitting in the same local dataset - which is exactly what
|
||||
// `did:ng:i` returns, since the engine adds nothing to narrow it by what the reader
|
||||
// may open. The capability filter is the only thing between it and the caller.
|
||||
seedEvent(orm, "did:ng:o:someone-elses", "did:ng:o:someone-elses:q:one", "Theirs");
|
||||
expect(getCaps().isEnforcing()).toBe(true);
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["Mine"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- teardown ------------------------------------------------------------
|
||||
|
||||
it("(k) the last unsubscribe closes the shape query", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("jane");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const first = obs.subscribe(() => {});
|
||||
const second = obs.subscribe(() => {});
|
||||
await tick();
|
||||
expect(orm.openCount()).toBe(1);
|
||||
|
||||
first();
|
||||
expect(orm.openCount()).toBe(1); // still a subscriber
|
||||
second();
|
||||
expect(orm.openCount()).toBe(0); // the query is closed, not leaked
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,12 @@ const TARGET_WORDS = new Set([
|
||||
"publisher", "topic", "advert",
|
||||
// the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`)
|
||||
"observable", "deep", "signal", "set",
|
||||
// `subscription` is the ORM's own noun for the handle its shape query returns -
|
||||
// `OrmSubscription` and `DiscreteOrmSubscription` in
|
||||
// `sdk/js/orm/src/connector/`, and `branch_subscriptions` in the engine. Added when
|
||||
// `OrmSubscriptionLike` was published, which is the type of the one an application
|
||||
// injects; the verb `subscribe` was already here and the noun is the same thing.
|
||||
"subscription",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user