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
+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
});
});