8c890411bb
Quatrieme tour. Ce n'est pas un drapeau de plus : c'est la forme qui change. Les tours 2 et 3 construisaient tous deux la reprise ainsi - noter qu'on a refuse, puis reagir a un evenement ulterieur - et c'est la note qui etait fautive les deux fois : une fois conditionnee a quelque chose qui ne pouvait pas devenir vrai, une fois ecrite apres la fenetre ou arrive l'evenement decisif. Un front manque n'est pas rattrapable par construction. Donc : - LA DECISION EST UN NIVEAU. Les documents nommes sont reinterroges a chaque publication, jamais reportes depuis la barriere. Une capacite qui arrive change `isError` en document par le canal qui existait deja, et rien n'etant memorise, il n'y a aucun evenement a manquer. C'est aussi ce qui rend sans objet la question du porteur : une requete ouverte pendant qu'un autre etait connecte ne peut pas revenir a cet abonne en succes auquel il n'a pas droit. - UNE TENTATIVE QUI A COURU CONTRE UN CHANGEMENT S'EN APERCOIT au lieu d'attendre. `open()` echantillonne `CapRegistry.version()` avant la barriere et compare apres son echec ; un ecart signifie qu'elle a decide sur un terrain perime, et elle retente d'elle-meme. Cela termine : une tentative ne se repete que parce que le trousseau a bouge, les mouvements sont finis, et ceux qu'une tentative provoque elle-meme sont les capacites qu'elle depose - au plus une par document nomme. La comparaison de porteur disparait : `holderKey()` vaut "" tant que personne n'est connecte, donc un refus pris avant que l'identite se fixe - la ou les composants se montent - ne correspondait plus jamais ensuite. Un instantane d'echec inchange n'est plus republie : la surface documente sa stabilite, et huit arrivees sans rapport produisaient huit objets porteurs de huit `Error` distinctes pour un etat identique. Enfin, un defaut a moi, trouve en mesurant : le changement rendait la suite intermittente. `resetOpenedRepos()` abandonnait les ouvertures en vol sans les terminer, donc un appelant attendait les 8s du repli sur un abonnement demonte, puis repartait avec son etat de synchro efface - et « unknown » est la lecture LAXE, celle qui accepte qu'une lecture a zero ligne soit une absence averee. Une absence-qui-cache-un-echec latente, de la famille meme de cette tache. Une reinitialisation termine desormais ce qu'elle abandonne, en « timed-out ». Deux fixtures qui se couraient apres sont corrigees, raison ecrite a cote.
1072 lines
46 KiB
TypeScript
1072 lines
46 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 } = {}) {
|
|
/** Anchors whose anchored read throws. Mutable after boot, so a suite builds a healthy
|
|
* world first and breaks only the one call it is about. */
|
|
const unreachable = new Set<string>();
|
|
// 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[] = [];
|
|
|
|
// A NAMESPACE OF ITS OWN, and it is not cosmetic. `wallet-fake.ts` mints into
|
|
// `did:ng:o:doc<n>` too, and the bootstrap open registry (`emulated-verifier/open-repo.ts`)
|
|
// is process-global - so the two fakes aliased each other's repos across test files, and
|
|
// an in-flight open abandoned here could be awaited there against a broker that no longer
|
|
// answers, hanging it until the 8s fallback. `wallet-fake` carries the same lesson at its
|
|
// own scale ("a broker never mints a repo id twice - an id is a public key - so neither
|
|
// does this"); this is that lesson one scope wider.
|
|
const doc_create = mock(async () => `did:ng:o:usq${++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[]) => {
|
|
// The broker cannot answer for this repo right now - an outage, a repo this session
|
|
// has not synced, a rejected read. It REJECTS, as `resolve_target_for_sparql` does
|
|
// for a repo the verifier does not hold, and never answers 0 rows.
|
|
if (unreachable.has(a[3] as string)) throw new Error("RepoNotFound (emulated outage)");
|
|
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", "exposedReadCap"] 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, _unreachable: unreachable };
|
|
}
|
|
|
|
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 re-attempt re-asks the network only for the class a re-ask can repair", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
// Two public documents, and what separates them is the BROKER, not the library: one
|
|
// answers (and, being alice's, exposes a cap bob will not be given), the other cannot
|
|
// be asked at all. The first is an ANSWER and is memoised; the second is a failure
|
|
// and deliberately is not (`public-store.ts`).
|
|
const answers = await createEntityDoc("alice", "protected");
|
|
const cannotBeAsked = await createEntityDoc("alice", "public");
|
|
|
|
setCurrentUser("bob");
|
|
ng._unreachable.add(cannotBeAsked);
|
|
const asks = (doc: string): number =>
|
|
ng.sparql_query.mock.calls.filter((c) => c[3] === doc).length;
|
|
|
|
const memoised = useShapeQuery(EventShape, { graphs: [answers] });
|
|
const unsubA = memoised.subscribe(() => {});
|
|
const outage = useShapeQuery(EventShape, { graphs: [cannotBeAsked] });
|
|
const unsubB = outage.subscribe(() => {});
|
|
for (let i = 0; i < 20 && !(memoised.getSnapshot().isError && outage.getSnapshot().isError); i++) {
|
|
await tick();
|
|
}
|
|
expect(memoised.getSnapshot().isError).toBe(true);
|
|
expect(outage.getSnapshot().isError).toBe(true);
|
|
const answeredAt = asks(answers);
|
|
const outageAt = asks(cannotBeAsked);
|
|
|
|
// Three unrelated arrivals. Both scopes re-attempt on each - there is deliberately no
|
|
// predicate deciding which is worth it, because every predicate tried so far left a
|
|
// class permanently stuck. The MEMO is what makes that affordable, and the two
|
|
// counts below are the proof, each the other's guard against vacuity: a re-ask that
|
|
// cannot repair anything costs nothing, and the one that can is actually made.
|
|
for (let round = 0; round < 3; round++) {
|
|
getCaps().learn(mintCap(`did:ng:o:unrelated-${round}`));
|
|
for (let i = 0; i < 6; i++) await tick();
|
|
}
|
|
|
|
expect(asks(answers)).toBe(answeredAt); // an absence was read, and is remembered
|
|
expect(asks(cannotBeAsked)).toBeGreaterThan(outageAt); // a failure is asked again
|
|
unsubA();
|
|
unsubB();
|
|
});
|
|
|
|
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);
|
|
// NOBODY is signed in, and that is what keeps the registry inert for the whole test.
|
|
// Signing in provisions an account and provisioning MINTS, so `setCurrentUser` arms
|
|
// the emulation a tick or two later: a fixture that asserts `isEnforcing() === false`
|
|
// and then subscribes is racing itself, and it won that race only until the refusal
|
|
// began being re-read on every publication - at which point a scope named by a holder
|
|
// of nothing started answering, correctly, `isError`. The inert case is worth pinning;
|
|
// a fixture that stops being inert halfway through pins the schedule instead.
|
|
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();
|
|
});
|
|
|
|
// -- the three the second adversarial pass found -------------------------
|
|
|
|
it("(v) a PUBLIC document whose FETCH failed is asked again when the keyring moves", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const pub = await createEntityDoc("alice", "public");
|
|
seedEvent(orm, pub, `${pub}:q:one`, "Public");
|
|
|
|
setCurrentUser("bob");
|
|
// Bob holds nothing, and the public store is the ONLY channel that can ever hand him
|
|
// this document's cap - which is exactly what the broker now cannot answer.
|
|
ng._unreachable.add(pub);
|
|
const obs = useShapeQuery(EventShape, { graphs: [pub] });
|
|
const unsub = obs.subscribe(() => {});
|
|
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
|
|
// The broker heals, and an ordinary cap arrival follows - a connection restoring a
|
|
// key, an inbox delivery for some other document. The first gate written for this
|
|
// asked "does the holder hold every named document's cap yet", and for a PUBLIC
|
|
// document only the failed fetch can ever make that true: the gate waited on an
|
|
// answer that only the action it gated could produce.
|
|
ng._unreachable.delete(pub);
|
|
getCaps().learn(mintCap("did:ng:o:unrelated-arrival"));
|
|
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Public"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(w) no snapshot claims success for a document the CURRENT holder cannot reach", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const hers = await createEntityDoc("alice", "protected");
|
|
seedEvent(orm, hers, `${hers}:q:one`, "Hers");
|
|
|
|
setCurrentUser("bob");
|
|
const obs = useShapeQuery(EventShape, { graphs: [hers] });
|
|
const unsub = obs.subscribe(() => {});
|
|
for (let i = 0; i < 20 && !obs.getSnapshot().isError; i++) await tick();
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
|
|
// The page becomes Alice, which a live page CAN do: `shared-wallet/access-gate.ts`
|
|
// adopts the identifier a revived barrier confirms. The re-attempt is deliberately
|
|
// not keyed on the holder - that rule left a refusal taken before the identity
|
|
// settled, and one taken before an identity change, both permanently stuck - so a
|
|
// query may well be opened while she is the one connected.
|
|
setCurrentUser("alice");
|
|
getCaps().learn(mintCap("did:ng:o:unrelated-arrival"));
|
|
for (let i = 0; i < 12; i++) await tick();
|
|
|
|
// ...and back under Bob the answer has to be the refusal. What guarantees it is that
|
|
// the named documents are re-checked on every publication rather than remembered
|
|
// from the barrier: whoever opened the query, this subscriber is told what IT may
|
|
// read. `isSuccess` holding nothing here would be the exact lie this task removes,
|
|
// arriving through the repair's own door.
|
|
setCurrentUser("bob");
|
|
getCaps().learn(mintCap("did:ng:o:unrelated-arrival-2"));
|
|
for (let i = 0; i < 12; i++) await tick();
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.isError).toBe(true);
|
|
// `data` is deliberately NOT asserted empty here. Under `isError` this surface keeps
|
|
// the last read that ANSWERED - an observable cannot un-emit - and what answered was
|
|
// the publication made while Alice was connected. That an observable follows whoever
|
|
// is connected rather than whoever subscribed is older than this work and untouched
|
|
// by it: at `5cbcabe`, before any of this, `readable()` already consulted the current
|
|
// holder and `caps.onChange` already re-published through it. Reported as a
|
|
// follow-up, not repaired here. What this test pins is the STATE, which is what an
|
|
// application branches on and what the refusal is about.
|
|
|
|
// And Alice, connected again, is not refused her own document - the check is about
|
|
// the holder of the moment, not a grudge held from the first attempt.
|
|
setCurrentUser("alice");
|
|
getCaps().learn(mintCap("did:ng:o:unrelated-arrival-3"));
|
|
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["Hers"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(x) the failure reported is the FIRST named document that failed, in scope order", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const hers = await createEntityDoc("alice", "protected");
|
|
const alsoHers = await createEntityDoc("alice", "public");
|
|
|
|
setCurrentUser("bob");
|
|
// Two named documents failing for two DIFFERENT reasons. The first answers and holds
|
|
// no cap for Bob (a refusal); the second cannot be asked at all (a rejection, which
|
|
// arrives first because it does not wait for the sync barrier). Running the opens
|
|
// together and letting the first rejection win would report the SECOND document.
|
|
ng._unreachable.add(alsoHers);
|
|
const obs = useShapeQuery(EventShape, { graphs: [hers, alsoHers] });
|
|
const unsub = obs.subscribe(() => {});
|
|
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
|
|
|
const message = String((obs.getSnapshot().error as Error).message);
|
|
expect(message).toContain(hers);
|
|
expect(message).not.toContain(alsoHers);
|
|
unsub();
|
|
});
|
|
|
|
// -- the edge that is MISSED, which is the shape of the whole problem -----
|
|
//
|
|
// Recovery had been built twice as "record that we refused, then react to a later
|
|
// event". Both times the record was the fault: once gated on a condition that could
|
|
// not become true, once set too late to catch the event that caused the refusal. An
|
|
// edge that is missed is not recoverable by construction, so these pin the races
|
|
// rather than the flags.
|
|
|
|
it("(y) a refusal caused BY the last keyring event still converges - the missed edge", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const pub = await createEntityDoc("alice", "public");
|
|
seedEvent(orm, pub, `${pub}:q:one`, "Public");
|
|
|
|
// A fresh page over the same broker: the document and its exposed cap are on the
|
|
// wire, this registry holds nothing, and nothing is enforcing yet.
|
|
setCurrentUser("bob");
|
|
resetCaps();
|
|
expect(getCaps().isEnforcing()).toBe(false);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [pub] });
|
|
const unsub = obs.subscribe(() => {});
|
|
// The barrier is IN FLIGHT: inert, so it skipped the public-store ask entirely and is
|
|
// waiting on the sync barrier. The connection burst files this holder's first cap
|
|
// right now, which flips the registry to enforcing - and it is that very event that
|
|
// makes the walk refuse a moment later. There is no keyring event after it.
|
|
getCaps().mint("did:ng:o:the-first-cap-of-the-session");
|
|
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
|
|
|
|
// `refetch()` would answer at once, so the error was for nothing: the state was
|
|
// recoverable the whole time and only the RECOVERY was missing.
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Public"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(z) a refusal taken while nobody is connected converges once the identity settles", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const hers = await createEntityDoc("alice", "protected");
|
|
seedEvent(orm, hers, `${hers}:q:one`, "Hers");
|
|
|
|
// Refused with nobody connected - the state a page is in before its identity has
|
|
// settled, which is where components mount. The holder is the anonymous ring.
|
|
setCurrentUser(null);
|
|
const obs = useShapeQuery(EventShape, { graphs: [hers] });
|
|
const unsub = obs.subscribe(() => {});
|
|
for (let i = 0; i < 20 && !obs.getSnapshot().isError; i++) await tick();
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
|
|
// ...and the identity settles, holding the cap. This is the ORDINARY sign-in path, not
|
|
// an identity swap: `access-gate` adopts the settled identifier and the connection
|
|
// work files what this holder holds. A recovery keyed on "the same holder as the one
|
|
// refused" never fires here, because the refusal was recorded for nobody.
|
|
setCurrentUser("alice");
|
|
getCaps().learn(mintCap(hers));
|
|
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(titles(snap.data)).toEqual(["Hers"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(aa) a standing refusal that has not changed does not re-publish a new snapshot", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const pub = await createEntityDoc("alice", "public");
|
|
seedEvent(orm, pub, `${pub}:q:one`, "Public");
|
|
|
|
setCurrentUser("bob");
|
|
ng._unreachable.add(pub); // the fetch FAILS, which is the class that is re-asked
|
|
const obs = useShapeQuery(EventShape, { graphs: [pub] });
|
|
let notes = 0;
|
|
const unsub = obs.subscribe(() => {
|
|
notes += 1;
|
|
});
|
|
for (let i = 0; i < 20 && !obs.getSnapshot().isError; i++) await tick();
|
|
const refusedSnapshot = obs.getSnapshot();
|
|
const notesAfterRefusal = notes;
|
|
|
|
// Eight unrelated arrivals, the broker still down. Each re-attempt genuinely re-asks
|
|
// - that is the repair for this class and it is not a cost to avoid - but the ANSWER
|
|
// has not moved, and this surface documents its snapshot as stable until it actually
|
|
// changes. Handing subscribers eight fresh objects carrying eight fresh `Error`s for
|
|
// an unchanged state breaks that, and makes every bound view re-render for nothing.
|
|
for (let round = 0; round < 8; round++) {
|
|
getCaps().learn(mintCap(`did:ng:o:unrelated-${round}`));
|
|
for (let i = 0; i < 4; i++) await tick();
|
|
}
|
|
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
expect(obs.getSnapshot()).toBe(refusedSnapshot); // the SAME object, not an equal one
|
|
expect(notes).toBe(notesAfterRefusal);
|
|
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
|
|
});
|
|
});
|