f36ed5c164
Revue adversariale : la barriere livree au tour precedent rendait le refus
TERMINAL. Le signal du trousseau s'ecrit `if (query !== null) publish(...)`, parce
qu'avant la barriere il n'y avait qu'une seule raison de n'avoir pas de requete -
rien n'avait encore ete tente - et qu'une cle qui arrive ne pouvait changer que ce
qu'une requete OUVERTE avait le droit de montrer. La barriere en a ajoute une
seconde : elle abandonne AVANT que la requete existe, donc le seul canal qui fait
converger une cle tardive passait a cote de l'etat qui en avait le plus besoin.
Une page qui s'abonne pendant que son trousseau se restaure, ou un document
partage sous les yeux de son destinataire, publiait `isError` et y restait. Ce
n'est pas une surface plus stricte, c'est une pire : echanger un vide mensonger
contre une erreur bloquee ne repare rien, et cela casse une garantie que ce paquet
publie.
Le signal rouvre donc un scope refuse, et seulement quand le refus est leve
(`namedDocumentsReachable`). Le garde-fou porte autant que la reouverture :
rouvrir a chaque changement de trousseau rejouerait la barriere pour chaque cle
qu'une connexion depose, redemanderait au reseau tout document dont le
telechargement avait ECHOUE - un echec n'est deliberement pas memoise - et
republierait la meme erreur a chaque abonne. La variante indiscriminee casse deux
tests, dont celui de demontage qui preexistait.
La barriere passe en parallele. Elle etait serie, donc N documents qui ne poussent
jamais de `State` coutaient N replis bornes, 8s chacun en production. Les refus
restent lus dans l'ordre du scope apres coup.
Et ce qui a laisse passer les deux : l'e2e ne parcourait pas ce chemin. Les deux
journeys `useShapeQuery` lisent `{graphs: "did:ng:i"}`, donc 43/43 n'a jamais rien
prouve sur la barriere. Quatre controles ajoutes sur un scope NOMME, contre le
vrai broker : la proprietaire lit son document, un porteur de rien recoit
`isError` et jamais un succes vide, un document PUBLIC s'ouvre pour lui depuis la
seule reference via la requete de la barriere, et une cle qui tombe pour un AUTRE
document laisse le scope refuse en place.
831 lines
33 KiB
TypeScript
831 lines
33 KiB
TypeScript
/**
|
|
* 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 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 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, 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 } from "../src/emulated-verifier/open-repo";
|
|
import { mintCap } from "../src/emulated-verifier/caps";
|
|
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. */
|
|
const EVERYTHING = { graphs: "did:ng:i" };
|
|
const FP = "http://festipod.org/";
|
|
const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" };
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
/** Reverse of escapeLiteral: single left-to-right pass over `\x`. */
|
|
function unescapeLiteral(s: string): string {
|
|
let out = "";
|
|
for (let i = 0; i < s.length; i++) {
|
|
if (s[i] === "\\" && i + 1 < s.length) {
|
|
const next = s[++i];
|
|
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
|
} else out += s[i];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface SubRec { nuri: string; cb: (r: unknown) => void }
|
|
|
|
/**
|
|
* 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(options: { pushState?: boolean } = {}) {
|
|
// A broker that never pushes the first `State` is the pathological case the bootstrap
|
|
// open's bounded fallback exists for - not the ordinary one, which CONTRACT 3 in
|
|
// `e2e/` measures at about a millisecond. It is what makes the barrier's cost visible
|
|
// without waiting for it.
|
|
const pushState = options.pushState !== false;
|
|
const quads: Quad[] = [];
|
|
let docCounter = 0;
|
|
const subs: SubRec[] = [];
|
|
|
|
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
|
|
|
const sparql_update = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[2] as string | undefined;
|
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
|
let g: string;
|
|
let body: string;
|
|
if (gm) {
|
|
g = gm[1]!;
|
|
body = gm[2]!;
|
|
} else {
|
|
if (!anchor) return undefined;
|
|
g = anchor;
|
|
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
|
}
|
|
const sm = body.match(/<([^>]+)>/);
|
|
if (!sm) return undefined;
|
|
const s = sm[1]!;
|
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
|
let m: RegExpExecArray | null;
|
|
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
|
while ((m = pairRe.exec(after)) !== null) {
|
|
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 });
|
|
}
|
|
for (const sub of subs) {
|
|
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const sparql_query = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[3] as string | undefined;
|
|
// Pointer SELECT (store-root -> doc-shim).
|
|
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
}
|
|
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
|
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) {
|
|
if (q.g !== anchor) continue;
|
|
if (onlySubject !== null && q.s !== onlySubject) continue;
|
|
const rec = bySubject.get(q.s) ?? {};
|
|
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
|
bySubject.set(q.s, rec);
|
|
}
|
|
const bindings = [...bySubject.values()]
|
|
.filter((r) => r.id)
|
|
.map((r) => ({
|
|
id: { value: r.id! },
|
|
docPublic: { value: r.docPublic ?? "" },
|
|
docProtected: { value: r.docProtected ?? "" },
|
|
docPrivate: { value: r.docPrivate ?? "" },
|
|
}));
|
|
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 } };
|
|
}
|
|
}
|
|
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 });
|
|
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
|
|
if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0);
|
|
return () => {};
|
|
});
|
|
|
|
return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads };
|
|
}
|
|
|
|
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(),
|
|
});
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
resetCaps();
|
|
}
|
|
|
|
const tick = () => new Promise((r) => setTimeout(r, 5));
|
|
|
|
/**
|
|
* 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,
|
|
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);
|
|
});
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
// The cap registry is process-wide: leaving caps behind would put the possession
|
|
// gate in force for a suite that never declares any.
|
|
resetCaps();
|
|
});
|
|
|
|
describe("useShapeQuery", () => {
|
|
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");
|
|
const doc = await createEntityDoc("alice", "protected");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "Alpha");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
let notes = 0;
|
|
const unsub = obs.subscribe(() => {
|
|
notes += 1;
|
|
});
|
|
|
|
// 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([]);
|
|
|
|
orm.deliver();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(snap.isError).toBe(false);
|
|
expect(titles(snap.data)).toEqual(["Alpha"]);
|
|
expect(notes).toBeGreaterThan(0);
|
|
unsub();
|
|
});
|
|
|
|
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 document of this shape anywhere - the scope is genuinely empty.
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.isSuccess).toBe(true); // answered, NOT stuck pending
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.data).toEqual([]); // empty - distinguishable from "still asking"
|
|
unsub();
|
|
});
|
|
|
|
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");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "One");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["One"]);
|
|
|
|
// 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");
|
|
seedEvent(orm, doc2, `${doc2}:q:one`, "Two");
|
|
await tick();
|
|
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["One", "Two"]);
|
|
unsub();
|
|
});
|
|
|
|
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");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "Never arrives");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
unsub();
|
|
});
|
|
|
|
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. 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("(g) a named `graphs` list reaches the SDK verbatim and reads those documents only", async () => {
|
|
const ng = makeFake();
|
|
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(titles(all.getSnapshot().data)).toEqual(["Named", "Unnamed"]);
|
|
unsubAll();
|
|
|
|
const scope = { graphs: [kept] };
|
|
const obs = useShapeQuery(EventShape, scope);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
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("(h) an empty `graphs` returns no objects, and says so as SUCCESS", async () => {
|
|
const ng = makeFake();
|
|
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".
|
|
const obs = useShapeQuery(EventShape, { graphs: [] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
unsub();
|
|
});
|
|
|
|
it("(i) `subjects` narrows to the subject IRIs named, literally", async () => {
|
|
const ng = makeFake();
|
|
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`.
|
|
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(titles(both.getSnapshot().data)).toEqual(["First", "Second"]);
|
|
unsubBoth();
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
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();
|
|
});
|
|
|
|
// -- a NAMED document is reached before the query opens, since `1.0.0-dev.9` ---
|
|
//
|
|
// `graphs` does not only narrow a search: each entry NAMES a document, and this surface
|
|
// used to name one, hand it to the engine, and empty the result with the capability
|
|
// filter - publishing `isSuccess` with `data: []`, which is the one snapshot it exists
|
|
// to reserve for "this scope holds nothing". The four cases below are the distinct ones:
|
|
// the document that cannot be reached, the one that can and is empty, the list where
|
|
// only one member fails, and the emulation not yet in force.
|
|
|
|
it("(l) a named document the holder holds no capability for is isError, not an empty success", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("kate");
|
|
// Creating one document is what puts the emulation in force - the cap registry is
|
|
// inert until a first cap exists, and (o) below is the case where it still is.
|
|
await createEntityDoc("kate", "protected");
|
|
const theirs = "did:ng:o:someone-elses";
|
|
seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs");
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [theirs] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
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)).toContain("does not hold this document");
|
|
// RULE 2, and it is the half a filter could never give: the question was not put at
|
|
// all. Kate's session never asked the engine about a document she cannot open.
|
|
expect(orm.openCount()).toBe(0);
|
|
unsub();
|
|
});
|
|
|
|
it("(m) a named document that IS reachable and holds nothing of the shape is isSuccess with data []", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("liam");
|
|
// His own, so the barrier passes; nothing of the shape is ever seeded into it.
|
|
const mine = await createEntityDoc("liam", "protected");
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [mine] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
// THE line that must not move. An empty document is a document that answered, and
|
|
// the repair above would only have relocated the lie if this had become an error.
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
unsub();
|
|
});
|
|
|
|
it("(n) one unreachable document in a named list refuses the whole scope", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("maya");
|
|
const mine = await createEntityDoc("maya", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
const theirs = "did:ng:o:not-mayas";
|
|
seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs");
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [mine, theirs] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
// Not a short list. Answering "Mine" alone would say the scope holds one object when
|
|
// the truth is that half of it was never asked - the tolerance `readUnion` was
|
|
// removed for in `1.0.0-dev.8`, arriving by another door.
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
expect(orm.openCount()).toBe(0);
|
|
unsub();
|
|
});
|
|
|
|
it("(o) INERT: before the first cap exists a named document reads, and nothing refuses", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("nora");
|
|
// No document created here, so no cap was ever minted: the emulation is not in force,
|
|
// which is the state every early path runs in. A barrier that fired here would break
|
|
// all of them.
|
|
const early = "did:ng:o:early-doc";
|
|
seedEvent(orm, early, `${early}:q:one`, "Early");
|
|
expect(getCaps().isEnforcing()).toBe(false);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [early] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Early"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(p) the empty-string `graphs` is the whole dataset too, and is not guarded either", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("omar");
|
|
const mine = await createEntityDoc("omar", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
// Somebody else's, in the same local dataset: the filter is what keeps it out, and
|
|
// must stay the one that decides here - `""` names no document to refuse.
|
|
seedEvent(orm, "did:ng:o:not-omars", "did:ng:o:not-omars:q:one", "Theirs");
|
|
|
|
// `normalizeScope` maps `""` onto `did:ng:i` (`@ng-org/orm`'s `types.ts`), so the two
|
|
// spellings are one marker. A barrier that treated `""` as a document name would
|
|
// refuse the whole dataset for a reference nobody wrote.
|
|
const obs = useShapeQuery(EventShape, { graphs: "" });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Mine"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(q) unsubscribing while the barrier is still crossing leaves no query open", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("pia");
|
|
const mine = await createEntityDoc("pia", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
|
|
// The barrier is asynchronous now, so there is a window in which the observable has
|
|
// subscribers, no query, and an open on its way. A teardown landing in that window
|
|
// must not be overtaken by the open it superseded - otherwise the shape query is
|
|
// created after the last listener left and nothing is ever going to close it.
|
|
const obs = useShapeQuery(EventShape, { graphs: [mine] });
|
|
const unsub = obs.subscribe(() => {});
|
|
unsub(); // synchronously, while `ensureRepoOpen` is still awaiting the first `State`
|
|
await tick();
|
|
await tick();
|
|
await tick();
|
|
|
|
expect(orm.openCount()).toBe(0);
|
|
});
|
|
|
|
// -- the cap that arrives LATE, which is the ordinary case --------------
|
|
//
|
|
// A page reload, a `settle` still in flight, an inbox delivery landing while the
|
|
// person is looking at the screen: in all three the view is subscribed BEFORE the
|
|
// keyring holds what it needs. The contract promises that such a view converges by
|
|
// itself - "a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and
|
|
// publishes the document it now opens" - and `CapRegistry.onChange` is the channel.
|
|
// A refusal that cannot be re-tried on that signal is not a stricter answer, it is a
|
|
// dead view.
|
|
|
|
it("(r) a NAMED document whose cap arrives after the subscribe converges, it does not stay isError", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
// Alice writes the note. Bob is the one reading, and he holds nothing yet - the
|
|
// state a page has while its connection work is still restoring the keyring.
|
|
setCurrentUser("alice");
|
|
const note = await createEntityDoc("alice", "protected");
|
|
seedEvent(orm, note, `${note}:q:one`, "Shared");
|
|
|
|
setCurrentUser("bob");
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
const obs = useShapeQuery(EventShape, { graphs: [note] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
// Honest for now: he cannot read it, and being told so beats an empty success.
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
|
|
// ...and now the cap lands, exactly as the applied share does it
|
|
// (`branch-registers.addLink` for the connected holder, `inbox-dispatch` for a named
|
|
// one): filed into the keyring, which fires `CapRegistry.onChange`.
|
|
getCaps().learn(mintCap(note));
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Shared"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(s) a cap for some OTHER document does not re-open a scope that is still unreachable", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const note = await createEntityDoc("alice", "protected");
|
|
seedEvent(orm, note, `${note}:q:one`, "Shared");
|
|
|
|
setCurrentUser("bob");
|
|
const obs = useShapeQuery(EventShape, { graphs: [note] });
|
|
let notes = 0;
|
|
const unsub = obs.subscribe(() => {
|
|
notes += 1;
|
|
});
|
|
await tick();
|
|
await tick();
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
const notesAfterRefusal = notes;
|
|
|
|
// A keyring change that says nothing about THIS scope - every connection files
|
|
// several, and an inbox delivery for another document files one more. Re-opening on
|
|
// each of them would re-run the barrier, re-ask the network for any document whose
|
|
// fetch had FAILED (a failure is deliberately not memoised) and re-publish the same
|
|
// error to every subscriber, for as long as the session lasts.
|
|
//
|
|
// The count of NOTIFICATIONS is what separates the targeted retry from the
|
|
// indiscriminate one: re-opening republishes, and republishing wakes every view
|
|
// bound to this observable. Asserting on the ORM instead proves nothing here - the
|
|
// barrier refuses before the engine is ever asked, either way.
|
|
getCaps().learn(mintCap("did:ng:o:something-else"));
|
|
await tick();
|
|
await tick();
|
|
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
expect(notes).toBe(notesAfterRefusal);
|
|
unsub();
|
|
});
|
|
|
|
// -- what the barrier COSTS, which is not nothing -------------------------
|
|
|
|
it("(t) named documents cross the barrier TOGETHER, so N that never sync cost one wait, not N", async () => {
|
|
const ng = makeFake({ pushState: false });
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setOpenTimeoutForTests(40); // after `inject`, which resets it to the 8s default
|
|
setCurrentUser("quinn");
|
|
const a = await createEntityDoc("quinn", "protected");
|
|
const b = await createEntityDoc("quinn", "protected");
|
|
const c = await createEntityDoc("quinn", "protected");
|
|
seedEvent(orm, a, `${a}:q:one`, "A");
|
|
seedEvent(orm, b, `${b}:q:one`, "B");
|
|
seedEvent(orm, c, `${c}:q:one`, "C");
|
|
const named: string[] = [a, b, c];
|
|
const subscribesTo = (): number =>
|
|
ng.doc_subscribe.mock.calls.filter((call) => named.includes(String(call[0]))).length;
|
|
expect(subscribesTo()).toBe(0);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [a, b, c] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
|
|
// The deterministic form of "the waits overlap": all three opens are in flight
|
|
// before any of them has answered. A serial barrier would show exactly one here and
|
|
// pay the bounded fallback three times over before publishing anything - which on
|
|
// the production timeout is 24 seconds of `isPending` for a scope naming three
|
|
// documents that never push.
|
|
expect(subscribesTo()).toBe(3);
|
|
|
|
// ...and it still answers, once the fallbacks have fired.
|
|
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["A", "B", "C"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(u) INERT is not untouched: no refusal, but the cold-start open is real and is paid", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("rex");
|
|
const early = "did:ng:o:inert-named";
|
|
seedEvent(orm, early, `${early}:q:one`, "Early");
|
|
expect(getCaps().isEnforcing()).toBe(false);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [early] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
// Nothing REFUSES - that half is genuinely inert, since `mayReach` answers true for
|
|
// everything before the first cap exists. But the open is not: `ensureRepoOpen` runs
|
|
// whatever the registry is enforcing, and issues a real `doc_subscribe` held for the
|
|
// session. That is deliberate and it is the cold-start heal - without it a named
|
|
// document this session has never synced answers `isSuccess` with `data: []`, the
|
|
// very lie the barrier is here to remove - but it is a CHANGE, and a scope naming a
|
|
// document now waits on that barrier where it used to open the query at once.
|
|
expect(ng.doc_subscribe.mock.calls.some((call) => call[0] === early)).toBe(true);
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(titles(snap.data)).toEqual(["Early"]);
|
|
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
|
|
});
|
|
});
|