feat!: useShapeQuery lit par la requete de forme du SDK

This commit is contained in:
Sylvain Duchesne
2026-09-21 11:41:31 +02:00
parent a5e553e65b
commit 77e78ec81d
29 changed files with 1190 additions and 971 deletions
+225
View File
@@ -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);
+249 -233
View File
@@ -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",
]);
/**