feat!: useShapeQuery lit par la requete de forme du SDK
This commit is contained in:
@@ -32,7 +32,7 @@ import {
|
||||
configure,
|
||||
} from "@ng-eventually/polyfill";
|
||||
|
||||
configure({ ng: realNg, useShape: realUseShape, getSession, sharedWallet });
|
||||
configure({ ng: realNg, useShape: realUseShape, ormSubscription, initNg, sharedWallet });
|
||||
await ensureIdentity(); // who I am (returned), connection work awaited
|
||||
// A store is named by its reference — `doc_create` takes one, and the session is where
|
||||
// upstream gets it. Omit it and the document goes to the private store, as upstream does.
|
||||
@@ -41,6 +41,9 @@ await docs.sparqlUpdate(sid, `INSERT DATA { … }`, doc);
|
||||
// Reading is by SHAPE, over the SDK's own scope. `did:ng:i` is the whole local dataset;
|
||||
// naming documents in `graphs` reads exactly those. There is no "list my documents" call
|
||||
// and no way to scope a read by store — neither exists upstream.
|
||||
// It reads through the SDK's own shape query (`RdfOrmSubscription.getOrCreate`, passed to
|
||||
// `configure` as `ormSubscription`), and hands back the shape's own objects: `@graph`,
|
||||
// `@id` and one field per predicate the shape DECLARES.
|
||||
const query = useShapeQuery(NoteShape, { graphs: "did:ng:i" });
|
||||
```
|
||||
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
/**
|
||||
* The page for the LIVE ORM measurement — the one thing `e2e/polyfill-entry.ts` cannot do.
|
||||
* The page for the LIVE ORM measurement - what `e2e/run.ts` still does not measure.
|
||||
*
|
||||
* That harness injects a hand-written Set-like into `configure({ useShape })`, because the
|
||||
* read filter is pure and does not need the real ORM to be exercised. What it therefore
|
||||
* never touches is the only hop that reaches the broker: `ng.orm_start_graph`, the streamed
|
||||
* method `OrmSubscription` opens and the one `bug_useshape-live-read-needs-a-newer-broker`
|
||||
* is about. So this page injects the REAL `@ng-org/orm` — nothing else about the bootstrap
|
||||
* differs — and the published `useShape` runs over a live subscription.
|
||||
*
|
||||
* ── The dependency, stated because it is not installed by default ──────────
|
||||
* `@ng-org/orm` is an OPTIONAL PEER of this package (`package.json`,
|
||||
* `peerDependenciesMeta`), so `bun install` does not fetch it and `bun.lock` records it
|
||||
* under `optionalPeers` with no resolution. This page is the only thing in the repository
|
||||
* that imports it: `e2e/run.ts` and its harness do not, so a checkout without it still
|
||||
* builds and runs the 42-check suite. Here the bundle simply will not build, and the
|
||||
* runner says so rather than reporting a broker verdict it did not measure.
|
||||
* The 42-check harness injects the REAL `RdfOrmSubscription.getOrCreate` since
|
||||
* `1.0.0-dev.6` (that is what `useShapeQuery` reads through), so a live ORM read is no
|
||||
* longer unreachable from there. What stays here is the PUBLISHED `useShape` half: the
|
||||
* bare `DeepSignalSet` an application gets from the ORM hook, and a `.delete()` on it
|
||||
* reaching the document. That is a different surface from the snapshot `useShapeQuery`
|
||||
* publishes, and it has no other measurement.
|
||||
*
|
||||
* ── What the framework adapter contributes, and why it is not here ─────────
|
||||
* Upstream's `useShape` is React's (`sdk/js/orm/src/frontendAdapters/react/useShape.ts`):
|
||||
@@ -247,51 +239,6 @@ async function seededTripleCount(doc: Nuri, subject: string): Promise<number> {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* CHARACTERISATION of the deployed broker's `orm_start_graph` ARITY.
|
||||
*
|
||||
* The failure the runner reports named a parameter - `config` - that the client of
|
||||
* the day did not send. Two upstream arrangements could put one there, and they differ
|
||||
* by POSITION:
|
||||
* - config LAST, after the callback (upstream 2026-04-12 to 2026-04-21) — an
|
||||
* unsupplied `config`, never a Function;
|
||||
* - config at index 4, BEFORE the callback: the 5-argument call of the client of
|
||||
* the day then lands its callback exactly there, which is what the error described.
|
||||
*
|
||||
* THE UPDATE ANSWERED THAT QUESTION. `@ng-org/web@0.1.2-alpha.14` declares six
|
||||
* arguments with `config` at index 4 and routes the stream callback from index 5
|
||||
* (`dist/ngweb.js`: `orm_start_graph: 5`), and `@ng-org/orm@0.1.2-alpha.21` sends six
|
||||
* to match (`dist/connector/RdfOrmSubscription.js:159`). So the second arrangement was
|
||||
* the right reading, and the ordinary `liveStart` path now exercises it.
|
||||
*
|
||||
* This is therefore kept as a FALLBACK, not as the live question: the runner only asks
|
||||
* it when `liveStart` did not become ready, to say whether the engine still refuses on
|
||||
* `config` when the client no longer can be blamed. It calls the SDK member directly
|
||||
* rather than through the ORM. The app-side proxy takes index 5 as the stream callback,
|
||||
* so the `{}` at index 4 is read as a config and nothing usable comes back; what is
|
||||
* being read is only whether the start is refused.
|
||||
*/
|
||||
async liveStartWithConfig() {
|
||||
const s = await sessionReady;
|
||||
const doc = (await docs.docCreate(
|
||||
s.session_id,
|
||||
"Graph",
|
||||
"data:graph",
|
||||
"store",
|
||||
undefined,
|
||||
)) as Nuri;
|
||||
// Called against the PUBLISHED declaration, which now has these six arguments, so the
|
||||
// widening this used to need is gone: the arity is checked here rather than asserted.
|
||||
// What remains unknowable at compile time is the DEPLOYED engine's arity, which is
|
||||
// the question this asks.
|
||||
try {
|
||||
await realNg.orm_start_graph([doc], [], LIVE_SHAPE, s.session_id, {}, () => {});
|
||||
return { doc, error: null };
|
||||
} catch (e) {
|
||||
return { doc, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
},
|
||||
|
||||
/** The document's own answer: how many triples the seeded subject still has. */
|
||||
async liveSeededTriples() {
|
||||
if (!probe) return -1;
|
||||
|
||||
@@ -2,43 +2,33 @@
|
||||
* Does a live `useShape` read reach the DEPLOYED broker, and does deleting from the set it
|
||||
* returns reach the document?
|
||||
*
|
||||
* Standalone, and deliberately outside `e2e/run.ts`: this is the one measurement that needs
|
||||
* `@ng-org/orm`, an OPTIONAL PEER this package does not install (`bun.lock`,
|
||||
* `optionalPeers`). Folding it into the 42-check suite would make that suite unbuildable on
|
||||
* a checkout without it. Here, a missing ORM fails the build of this runner alone, with the
|
||||
* reason said out loud — which is the honest outcome, because a measurement that did not
|
||||
* run must not be reported as a verdict either way.
|
||||
* Standalone, and deliberately outside `e2e/run.ts`: what it measures is the PUBLISHED
|
||||
* `useShape` - the bare `DeepSignalSet` the ORM hook hands an application, and a
|
||||
* `.delete()` on it reaching the document. `e2e/run.ts` exercises the other shape read,
|
||||
* `useShapeQuery`, which since `1.0.0-dev.6` reads through the SDK's shape query too. Two
|
||||
* surfaces, two runs.
|
||||
*
|
||||
* Run: `bun run e2e/orm-live-read.ts` from `packages/polyfill`.
|
||||
*
|
||||
* ── What it decides ────────────────────────────────────────────────────────
|
||||
* -- What it decides ----------------------------------------
|
||||
* The verdict is readable in one number: does `readyPromise` settle. It does on one path
|
||||
* only — the arrival of an initial payload from the engine
|
||||
* (`GraphOrmSubscription.ts:handleInitialResponse`) — so a refused start leaves it pending
|
||||
* for ever, which is exactly why the failure is silent and why a bound, not an await, is
|
||||
* what turns it into a report. Nothing here infers a verdict from a version string.
|
||||
*
|
||||
* ── What it found, 2026-09-16, and why the last row exists ─────────────────
|
||||
* The read does NOT start, and the reason is not the one
|
||||
* `bug_useshape-live-read-needs-a-newer-broker` names. That leaf's mechanism — a broker
|
||||
* built before `orm_start` was renamed `orm_start_graph` — is REFUTED here: the method
|
||||
* resolves, and the call fails INSIDE it, on an argument. The engine answers
|
||||
* `Deserialization error of config Error: invalid type: JsValue(Function), expected any
|
||||
* valid JSON value`.
|
||||
* -- What it found, and the row that went with the answer ----------------
|
||||
* On 2026-09-16 the read did not start: the engine answered `Deserialization error of
|
||||
* config`, and a sixth row of this run established WHERE that `config` sits - position 4,
|
||||
* before the callback, where the five-argument client of the day landed its callback. The
|
||||
* deployment was AHEAD of the pinned client packages, not behind them.
|
||||
*
|
||||
* The last row establishes where that `config` sits, because the position is the whole
|
||||
* diagnosis: calling the SDK member directly with `{}` in position 4 is ACCEPTED, and the
|
||||
* engine then streams back. So the deployed `orm_start_graph` is
|
||||
* `(graph_scope, subject_scope, shapeType, session_id, config, callback)`, and the
|
||||
* five-argument call the published `@ng-org/orm` makes lands its callback in `config`.
|
||||
* That arity is in no commit of `nextgraph-rs` at the read revision — the one window that
|
||||
* had a `config` at all (2026-04-12 to 2026-04-21) put it LAST, after the callback. The
|
||||
* deployment is therefore AHEAD of the client packages this repository pins, not behind
|
||||
* them, which inverts what the leaf says to do about it.
|
||||
*
|
||||
* The run is EXPECTED TO EXIT NON-ZERO while that holds: it is a measurement of a
|
||||
* deployment, not a gate on this package. Green again means the two ends agree, and the
|
||||
* bug leaf can close.
|
||||
* Updating them answered it: `@ng-org/web@0.1.2-alpha.14` declares six arguments with
|
||||
* `config` at index 4 and `@ng-org/orm@0.1.2-alpha.21` sends six to match, and the run
|
||||
* measured `6 passed, 0 failed` with the read ready in 16ms and the delete reaching the
|
||||
* document. The characterisation row was retired with the question it answered: there is
|
||||
* nothing left for it to find out, and a row that always prints the same sentence teaches
|
||||
* whoever reads this report to skip it.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
@@ -107,7 +97,6 @@ const CHECKS: readonly string[] = [
|
||||
"the initial payload carries the seeded object, with its `@id` and `@graph`",
|
||||
"`.delete()` on that set is accepted",
|
||||
"the deletion reached the DOCUMENT: the seeded subject's triples are gone",
|
||||
"[finding] the ARITY the deployed `orm_start_graph` expects",
|
||||
];
|
||||
|
||||
type Check = { name: string; ok: boolean; detail?: string };
|
||||
@@ -116,17 +105,6 @@ function check(name: string, ok: boolean, detail?: string): void {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
/**
|
||||
* A row that REPORTS rather than judges — `e2e/run.ts`'s convention for characterising the
|
||||
* broker rather than asserting about this package. The last row is one: whichever arity the
|
||||
* deployment expects, it is upstream's to change, so failing on it would only teach whoever
|
||||
* reads the report to ignore this file.
|
||||
*/
|
||||
function record(name: string, detail: string): void {
|
||||
results.push({ name, ok: true, detail });
|
||||
console.log(` [INFO] ${name} — ${detail}`);
|
||||
}
|
||||
|
||||
/** The bridge as the browser side of `frame.evaluate` sees it — no `any`, and no import
|
||||
* the serialized callback could not carry across. */
|
||||
type Bridge = Record<string, (...a: unknown[]) => unknown>;
|
||||
@@ -261,27 +239,6 @@ async function main(): Promise<void> {
|
||||
}
|
||||
check(CHECKS[4]!, remaining === 0, `triples left for the seeded subject: ${remaining}`);
|
||||
|
||||
// Only worth asking when the published call did NOT start: it is the diagnosis of
|
||||
// that failure, and a deployment that answers the published arity has nothing to
|
||||
// characterise. Since `@ng-org/web@0.1.2-alpha.14` the published call is itself the
|
||||
// six-argument one, so this asks whether the engine refuses a start the client got
|
||||
// right - no longer whether the client was sending the wrong number.
|
||||
if (readyMs === null) {
|
||||
const withConfig = await sdk<{ doc: string; error: string | null }>(
|
||||
frame,
|
||||
"liveStartWithConfig",
|
||||
);
|
||||
record(
|
||||
CHECKS[5]!,
|
||||
withConfig.error === null
|
||||
? "six arguments with `{}` in position 4 are ACCEPTED — the deployed " +
|
||||
"`orm_start_graph` takes a `config` BEFORE the callback, which is what the " +
|
||||
"published client now sends, so the refusal is not an arity mismatch"
|
||||
: `six arguments were refused too: ${withConfig.error}`,
|
||||
);
|
||||
} else {
|
||||
record(CHECKS[5]!, "not asked — the published six-argument call started");
|
||||
}
|
||||
} finally {
|
||||
if (page) await closeQuietly("the ORM live-read page", () => page!.close());
|
||||
if (ctx) await closeContext("orm-live", ctx);
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
*/
|
||||
|
||||
import { ng as realNg, init as realInit } from "@ng-org/web";
|
||||
// The REAL ORM. `useShapeQuery` reads through the SDK's own shape query since
|
||||
// `1.0.0-dev.6`, so the harness has to hand it one: a fake Set-like is enough for the
|
||||
// read FILTER (which is pure) and says nothing about a read that reaches the broker.
|
||||
// `initNg` is what resolves the ORM's own session promise; nothing starts before it.
|
||||
import { initNg as ormInitNg, RdfOrmSubscription } from "@ng-org/orm";
|
||||
import {
|
||||
configure,
|
||||
docs,
|
||||
@@ -26,6 +31,7 @@ import {
|
||||
storeRegistry,
|
||||
useShape as libUseShape,
|
||||
useShapeQuery,
|
||||
initNg,
|
||||
} from "@ng-eventually/polyfill";
|
||||
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
|
||||
// application must not — but through the internal path, never the published entry.
|
||||
@@ -60,13 +66,50 @@ import { isNuri } from "../src/model/nuri";
|
||||
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
|
||||
const EVERYTHING = { graphs: "did:ng:i" };
|
||||
|
||||
/**
|
||||
* The shape the two `useShapeQuery` journeys read - built here rather than inline, since
|
||||
* both of them need the same one and it is now handed to the SDK.
|
||||
*
|
||||
* Two predicates, both `minCardinality: 1`, because that is what
|
||||
* `engine/verifier/src/orm/graph/query.rs:schema_shape_to_sparql` turns into REQUIRED
|
||||
* triple patterns: a shape constraining nothing would match every subject in the dataset.
|
||||
* The title is the second one, and it is what proves the real data landed rather than an
|
||||
* empty object of the right type.
|
||||
*/
|
||||
function wsShape(cls: string) {
|
||||
return {
|
||||
shape: "urn:e2e:ws:Shape",
|
||||
schema: {
|
||||
"urn:e2e:ws:Shape": {
|
||||
iri: "urn:e2e:ws:Shape",
|
||||
predicates: [
|
||||
{
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "type",
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||
},
|
||||
{
|
||||
iri: "urn:e2e:ws:title",
|
||||
readablePredicate: "title",
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
dataTypes: [{ valType: "string" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ShapeType<BaseType>;
|
||||
}
|
||||
|
||||
const storeOfType = (t: "public" | "protected" | "private"): Promise<Nuri> =>
|
||||
t === "public"
|
||||
? storeRegistry.publicStore()
|
||||
: t === "protected"
|
||||
? storeRegistry.protectedStore()
|
||||
: storeRegistry.privateStore();
|
||||
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/polyfill";
|
||||
import type { BaseType, Nuri, ShapeObservable, ShapeQuery, ShapeType } from "@ng-eventually/polyfill";
|
||||
|
||||
const { IdentityStore } = virtualUsers;
|
||||
|
||||
@@ -120,7 +163,12 @@ function fakeUseShape(_shape: unknown, _scope: unknown): any {
|
||||
configure({
|
||||
ng: realNg,
|
||||
useShape: fakeUseShape,
|
||||
// The SDK's own shape query, passed as it stands - no wrapper, no cast. This is what
|
||||
// `useShapeQuery` reads through, so the journeys below run against the REAL broker
|
||||
// rather than against anything this page composed.
|
||||
ormSubscription: RdfOrmSubscription.getOrCreate,
|
||||
init: realInit,
|
||||
initNg: ormInitNg,
|
||||
// The harness already holds an open wallet (the suite imports it itself), so it never
|
||||
// needs the gate's assisted import. These values exist only so the gate has something
|
||||
// to render when the access-gate steps exercise it — they are never used to import.
|
||||
@@ -794,9 +842,9 @@ const identity = new IdentityStore(
|
||||
},
|
||||
|
||||
// ── useShapeQuery (reactive useQuery-shaped read) ────────────────────────
|
||||
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. useShapeQuery
|
||||
// reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals`
|
||||
// to know which @type to keep — generic, no application domain.
|
||||
// The shape is `wsShape(cls)` above: a hand-written `ShapeType`, two required
|
||||
// predicates, generic and with no application domain. The read goes through the
|
||||
// SDK's own shape query now, so these two journeys measure the REAL broker.
|
||||
_wsProbes: {} as Record<
|
||||
string,
|
||||
{ obs: ShapeObservable; initial: ShapeQuery; unsub: () => void }
|
||||
@@ -828,23 +876,7 @@ const identity = new IdentityStore(
|
||||
if (listed.includes(doc)) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
const shape = {
|
||||
shape: "urn:e2e:ws:Shape",
|
||||
schema: {
|
||||
"urn:e2e:ws:Shape": {
|
||||
iri: "urn:e2e:ws:Shape",
|
||||
predicates: [
|
||||
{
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "type",
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const shape = wsShape(cls);
|
||||
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
|
||||
const unsub = obs.subscribe(() => {});
|
||||
// First snapshot immediately after subscribe — the barrier is not yet crossed
|
||||
@@ -866,8 +898,10 @@ const identity = new IdentityStore(
|
||||
isSuccess: snap.isSuccess,
|
||||
isError: snap.isError,
|
||||
dataLen: snap.data.length,
|
||||
// The seeded title, if the datum is present (proves the real data landed).
|
||||
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []),
|
||||
// The seeded title, if the datum is present (proves the real data landed). It is the
|
||||
// shape's own readable predicate now, not a property bag: the objects come from the
|
||||
// ORM.
|
||||
titles: snap.data.map((d) => d.title).filter((v) => typeof v === "string"),
|
||||
};
|
||||
},
|
||||
useShapeQueryStop(handle: string) {
|
||||
@@ -884,23 +918,7 @@ const identity = new IdentityStore(
|
||||
registryInternals.resetRegistryCache();
|
||||
const id = "@ws-empty-" + handle;
|
||||
setCurrentUser(id);
|
||||
const shape = {
|
||||
shape: "urn:e2e:ws:Shape",
|
||||
schema: {
|
||||
"urn:e2e:ws:Shape": {
|
||||
iri: "urn:e2e:ws:Shape",
|
||||
predicates: [
|
||||
{
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "type",
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const shape = wsShape(cls);
|
||||
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
|
||||
const unsub = obs.subscribe(() => {});
|
||||
const initial = obs.getSnapshot();
|
||||
@@ -1105,6 +1123,11 @@ const identity = new IdentityStore(
|
||||
(event: { session: BrokerSession }) => {
|
||||
session = event.session;
|
||||
state.status = "connected";
|
||||
// The ORM reaches the SDK through a module-level promise of its own, resolved
|
||||
// once. Nothing it opens starts before this, so it goes here rather than beside
|
||||
// the first read - through the PACKAGE's `initNg`, which is the call an
|
||||
// application makes.
|
||||
initNg(realNg, session);
|
||||
sessionResolve(session);
|
||||
},
|
||||
true,
|
||||
|
||||
@@ -387,16 +387,18 @@ async function main(): Promise<void> {
|
||||
});
|
||||
|
||||
// ── useShapeQuery (reactive useQuery-shaped read) ───────────────────────
|
||||
// The real cycle: first subscription reads isPending (barrier not yet crossed),
|
||||
// then after the broker sync it reaches isSuccess with the seeded datum present.
|
||||
// A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the
|
||||
// distinction the announced `useShapeQuery` makes native, surfaced here from getSyncState).
|
||||
// The real cycle, through the SDK's own shape query since `1.0.0-dev.6`: the first
|
||||
// subscription reads isPending (the ORM's initial payload has not arrived), then the
|
||||
// broker answers and it reaches isSuccess with the seeded datum present. A separate
|
||||
// identity with nothing of that shape reaches isSuccess with data:[] - which over
|
||||
// `did:ng:i` is the CAPABILITY FILTER doing the bounding, since the engine adds nothing
|
||||
// to narrow the whole dataset by what the reader may open.
|
||||
console.log("\n── useShapeQuery (reactive useQuery-shaped read) ──");
|
||||
await step("useShapeQuery: first subscribe isPending → isSuccess with data present", async () => {
|
||||
const h = "cyc" + Date.now();
|
||||
const CLS = "urn:e2e:ws:Event";
|
||||
const seed = await sdk<any>(frame, "useShapeQuerySeedAndSubscribe", h, CLS);
|
||||
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
|
||||
check("first snapshot after subscribe is isPending (no initial payload yet)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
|
||||
// Event-driven: wait for the barrier to cross + the datum to land.
|
||||
await frame.waitForFunction(
|
||||
(hh) => {
|
||||
@@ -423,7 +425,7 @@ async function main(): Promise<void> {
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const snap = await sdkGet<any>(frame, "useShapeQuerySnapshot", h);
|
||||
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
|
||||
check("nothing of that shape: isSuccess, data:[] (answered-and-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
|
||||
await sdk(frame, "useShapeQueryStop", h);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ng-eventually/polyfill",
|
||||
"version": "1.0.0-dev.5",
|
||||
"version": "1.0.0-dev.6",
|
||||
"type": "module",
|
||||
"description": "Polyfill of the NextGraph JS SDK over @ng-org/web + @ng-org/orm, with emulated capabilities and inbox. Drop-in; remove at migration.",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
* code. Making it async would push the wait onto the app, which is exactly the
|
||||
* obligation this removes. So the work runs in the background and announces itself
|
||||
* through the registry's change signal (`CapRegistry.onChange`), which is what
|
||||
* `watchShape` already listens to — a view that was empty for want of a cap
|
||||
* `useShapeQuery` already listens to - a view that was empty for want of a cap
|
||||
* re-reads when the cap lands. {@link connectedUser} is there for a caller that
|
||||
* genuinely needs to await it (tests, an app that wants a deterministic start).
|
||||
*
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
* - the REGISTER — an inbox opened mid-session appends an `AddInboxCap` record to the
|
||||
* User branch of the private store (`branch-registers.openDocumentInbox`), so a push on
|
||||
* that document re-enumerates `myInboxes()`;
|
||||
* - WHAT THIS IDENTITY HOLDS (`CapRegistry.onChange`) — the same signal `watchShape`
|
||||
* - WHAT THIS IDENTITY HOLDS (`CapRegistry.onChange`) - the same signal `useShapeQuery`
|
||||
* re-reads on. It answers the case the register cannot: an identity that connects
|
||||
* before it has an account at all. Provisioning is lazy here, so a person's FIRST visit
|
||||
* connects with no stores, no private store to follow and no inbox to watch; the
|
||||
|
||||
@@ -137,8 +137,8 @@ export function resetOpenedRepos(): void {
|
||||
syncState.clear();
|
||||
boundSessionId = null;
|
||||
OPEN_TIMEOUT_MS = 8000;
|
||||
// Whatever ELSE was still subscribed to a document — a `watchShape` following a scope,
|
||||
// an inbox observation — belongs to the session that just went away too. One real
|
||||
// Whatever ELSE was still subscribed to a document - an inbox observation, an
|
||||
// application's own `subscribeDoc` - belongs to the session that just went away too. One real
|
||||
// subscription now serves all of them (`surface/subscribe.ts`), so dropping the bootstrap
|
||||
// ones above no longer closes those channels; they are re-opened against the new session,
|
||||
// with their barrier forgotten. Without this a bootstrap open would join a surviving
|
||||
|
||||
@@ -16,12 +16,15 @@
|
||||
* ── What this filter cannot do, and where that shows ──────────────────────
|
||||
* It is SYNCHRONOUS and decides from what the holder holds at that instant. A document
|
||||
* in a PUBLIC store hands its cap to whoever asks (`public-store.ts`), but asking is a
|
||||
* round-trip — so this view drops such a document until some read path has asked.
|
||||
* Every path this library owns does ask (`readUnion`, `docs.sparqlQuery`,
|
||||
* `ensureRepoOpen`, `documentInboxAddress`), which covers `watchShape`; what it does
|
||||
* not cover is `useShape`, whose signature is the real ORM's and has no await to
|
||||
* spend. An application reaching a public document through `useShape` alone, having
|
||||
* read it nowhere first, sees nothing. A polyfill-era limit, removed with the module.
|
||||
* round-trip - so this filter drops such a document until some read path has asked.
|
||||
* The paths that DO ask are the ones this library still reads with (`readUnion`,
|
||||
* `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`). The two SHAPE reads
|
||||
* ask nothing: `useShape` because its signature is the real ORM's and has no await to
|
||||
* spend, and `useShapeQuery` since `1.0.0-dev.6`, because it reads through the SDK's
|
||||
* own shape query and no longer opens a document of its own. An application reaching a
|
||||
* public document through either of them alone, having read it nowhere first, sees
|
||||
* nothing until it does - and a cap that lands later makes both re-publish, since each
|
||||
* follows `CapRegistry.onChange`. A polyfill-era limit, removed with the module.
|
||||
*
|
||||
* Note there is no `user` parameter anywhere below, and that is the point: reading
|
||||
* is key possession, so the only question askable is "do I hold this document's
|
||||
|
||||
@@ -84,6 +84,12 @@
|
||||
// PrincipalId `ensureIdentity`, `inbox.Deposit`/`PostOptions`, `EventuallyConfig`
|
||||
// NgLike `EventuallyConfig.ng`
|
||||
// UseShapeLike `EventuallyConfig.useShape`
|
||||
// OrmSubscriptionLike `EventuallyConfig.ormSubscription`
|
||||
//
|
||||
// `OrmSubscriptionHandle` - what that last one RETURNS - is deliberately NOT here. An
|
||||
// application hands the shape query in and reads the snapshot back; it never holds a
|
||||
// subscription, and publishing the handle would offer a path around the capability filter
|
||||
// and the shared-wallet identity boundary. It stays defined in `model/types.ts`.
|
||||
//
|
||||
// `Scope` left this list in `1.0.0-dev.5`, and it is the only one that left because the
|
||||
// TYPE was wrong rather than unreachable. It was `"public" | "protected" | "private"` -
|
||||
@@ -93,7 +99,7 @@
|
||||
// takes the SDK's `Scope` now (re-exported below, from the SDK itself), and a store is
|
||||
// named by its reference. The three strings stay DEFINED in `model/types.ts` as
|
||||
// `StoreType`, where the library's own modules still tell the three apart.
|
||||
export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike } from "./model/types";
|
||||
export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike, OrmSubscriptionLike } from "./model/types";
|
||||
export { useShape } from "./surface/use-shape";
|
||||
export { useShapeQuery } from "./surface/use-shape-query";
|
||||
export type { ShapeQuery, ShapeObservable } from "./surface/use-shape-query";
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
* Generic, NextGraph-shaped types. ZERO application domain.
|
||||
*/
|
||||
|
||||
// The SDK's own types, imported as TYPES (erased at build, so this module stays free of
|
||||
// any runtime `@ng-org` import). They describe what `configure` RECEIVES; nothing here
|
||||
// re-exports them.
|
||||
import type { Scope } from "@ng-org/orm";
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
|
||||
/**
|
||||
* A NextGraph URI (document / store / inbox) in its **cap-less** form — it NAMES
|
||||
* and locates, it does not grant the right to read: `did:ng:o:{doc}:v:{overlay}`.
|
||||
@@ -72,6 +78,54 @@ export type NgLike = Record<string, any>;
|
||||
// @provenance UseShapeLike kind=invention level=none ref=none — same, for the injected `useShape`
|
||||
export type UseShapeLike = (...args: any[]) => any;
|
||||
|
||||
/**
|
||||
* The shape query in force, as `surface/use-shape-query.ts` reads it - the four members
|
||||
* of `RdfOrmSubscription` this package touches, and not one more.
|
||||
*
|
||||
* NOT published: an application never holds one. It hands {@link OrmSubscriptionLike}
|
||||
* to `configure` and reads the snapshot back; what the query itself offers is the SDK's
|
||||
* business, and re-publishing it here would hand a caller a path around the capability
|
||||
* filter and the shared-wallet identity boundary.
|
||||
*
|
||||
* `signalObject` is typed `Iterable<unknown>` rather than by the shape: upstream it is a
|
||||
* `DeepSignalSet<T>` whose items are `DeepSignal<T>`, and narrowing it here would make
|
||||
* the real `getOrCreate` unassignable to the injection for a difference no reader of
|
||||
* this package can act on. The items are typed where the shape says what they are.
|
||||
*/
|
||||
// @provenance OrmSubscriptionHandle kind=aligned level=3 ref=sdk/js/orm/src/connector/GraphOrmSubscription.ts:OrmSubscription - the members this package reads: the readiness promise, the signal object, the change listeners and `close`; the published `@ng-org/orm@0.1.2-alpha.21` renames the class `RdfOrmSubscription`, which the cited revision has not yet done
|
||||
export interface OrmSubscriptionHandle {
|
||||
/** Resolves when the initial payload has arrived, and on no other path - which is
|
||||
* what tells "not ready yet" from "ready and empty". Never rejects upstream today. */
|
||||
readonly readyPromise: Promise<unknown>;
|
||||
/** The live objects matching the shape and scope. A set (or an ordered array) that
|
||||
* the engine's patches keep up to date. */
|
||||
readonly signalObject: Iterable<unknown>;
|
||||
/** Notified when root objects are added, updated or removed. */
|
||||
addChangeListener(
|
||||
listener: (changes: { adds: unknown[]; updates: unknown[]; removes: unknown[] }) => void,
|
||||
): void;
|
||||
removeChangeListener(
|
||||
listener: (changes: { adds: unknown[]; updates: unknown[]; removes: unknown[] }) => void,
|
||||
): void;
|
||||
/** Stop the subscription. Pooled upstream, so re-opening it shortly after is cheap. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loose shape of `@ng-org/orm`'s `RdfOrmSubscription.getOrCreate` - the SDK's shape
|
||||
* query, reached WITHOUT a framework adapter.
|
||||
*
|
||||
* An application injects the real one (`ormSubscription: RdfOrmSubscription.getOrCreate`);
|
||||
* it is assignable to this as it stands, with no wrapper and no cast. `useShape` is not a
|
||||
* substitute: it is React's hook, it returns a bare `DeepSignalSet`, and a set cannot
|
||||
* carry the request state `ShapeQuery` publishes.
|
||||
*/
|
||||
// @provenance OrmSubscriptionLike kind=invention level=none ref=none - nothing upstream is injected; the type exists only to describe what `configure` receives, here for the ORM's own `getOrCreate`
|
||||
export type OrmSubscriptionLike = <T extends BaseType>(
|
||||
shapeType: ShapeType<T>,
|
||||
scope: Scope,
|
||||
) => OrmSubscriptionHandle;
|
||||
|
||||
/**
|
||||
* The stores that can carry an inbox. NOT `StoreType`: upstream only the public and
|
||||
* protected store repos get one — `new_store_default` attaches an inbox solely
|
||||
|
||||
@@ -404,8 +404,8 @@ function recordFromRows(
|
||||
// The ONE place the `""`-for-corrupt case is absorbed. `VirtualUserRecord` promises
|
||||
// real NURIs; a shim missing a scope document yields `""` here, exactly as it
|
||||
// always has, and the cast records that this is a KNOWN gap rather than a proven
|
||||
// invariant. Callers already test for the empty value (e.g. `watchShape` skips a
|
||||
// falsy container). Rejecting such a record outright would be the right fix and is
|
||||
// invariant. Callers already test for the empty value (`listMyEntityDocs` reads
|
||||
// nothing out of a falsy container). Rejecting such a record outright would be the right fix and is
|
||||
// a behaviour change — its own lot, not this one.
|
||||
return {
|
||||
id: id || fallbackId,
|
||||
@@ -703,8 +703,8 @@ async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise<void>
|
||||
*
|
||||
* ── Why this is load-bearing (concurrent-provision de-dup) ──────────────────
|
||||
* On a fresh page over the persistent wallet, SEVERAL independent callers hit
|
||||
* `ensureAccount(A)` near-simultaneously (watchShape public/protected, the container
|
||||
* subscriptions, the app's owned-events effect). With the barrier-authoritative
|
||||
* `ensureAccount(A)` near-simultaneously (a listing per store, an inbox enumeration, the
|
||||
* app's own effects). With the barrier-authoritative
|
||||
* resolveAccount a fresh page NO LONGER mistakes sync-lag for absence — but if the
|
||||
* account is GENUINELY new, N concurrent callers would still each see 0 and each
|
||||
* provision a set of scope docs (an in-session fork). De-duping concurrent provisions
|
||||
@@ -1263,20 +1263,13 @@ export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope-INDEX document NURI of ONE account (`id`) for `scope` — the
|
||||
* store-container document that LISTS the account's per-entity document NURIs
|
||||
* (what {@link listMyEntityDocs} reads). Exposed so a reactive reader
|
||||
* ({@link watchShape}) can SUBSCRIBE to this index document and re-resolve the
|
||||
* entity-doc set when the index changes (a new entity created appends a NURI
|
||||
* here). Idempotent via `ensureAccount`'s cache. At migration this becomes the
|
||||
* user's real per-scope store NURI (the container the store itself provides).
|
||||
*/
|
||||
// @provenance userStoreDoc kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — the per-scope index document stands in for the store the session names directly
|
||||
export async function userStoreDoc(id: string, scope: StoreType): Promise<Nuri> {
|
||||
const record = await ensureAccount(id);
|
||||
return storeOf(record, scope);
|
||||
}
|
||||
// `userStoreDoc(id, scope)` was REMOVED in `1.0.0-dev.6`. It handed out the scope-INDEX
|
||||
// document so that a reactive reader could SUBSCRIBE to it and re-resolve its document set
|
||||
// when the index changed - and the reactive reader was the only thing that ever wanted it.
|
||||
// `useShapeQuery` now reads through the SDK's own shape query, which follows the data
|
||||
// rather than a container this package maintains, so the last caller went with the
|
||||
// pipeline. `storeOf(await ensureAccount(id), scope)` is the two-line body, for whoever
|
||||
// needs the value again.
|
||||
|
||||
/**
|
||||
* The entity-document NURIs of `scope` belonging to ONE account (`id`) —
|
||||
|
||||
@@ -15,7 +15,15 @@
|
||||
* The entry now only re-exports; the internals import this module instead.
|
||||
*/
|
||||
|
||||
import type { NgLike, UseShapeLike, Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
|
||||
import type {
|
||||
NgLike,
|
||||
UseShapeLike,
|
||||
OrmSubscriptionLike,
|
||||
Nuri,
|
||||
NuriLike,
|
||||
PrincipalId,
|
||||
ReadCap,
|
||||
} from "../model/types";
|
||||
import type { SharedWalletConfig } from "./access-gate";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import type { RegistrySession } from "./account-registry";
|
||||
@@ -113,6 +121,19 @@ export interface EventuallyConfig {
|
||||
ng: NgLike;
|
||||
/** The REAL `@ng-org/orm` `useShape`. */
|
||||
useShape: UseShapeLike;
|
||||
/**
|
||||
* The REAL `@ng-org/orm` `RdfOrmSubscription.getOrCreate` - the SDK's shape query,
|
||||
* which is what {@link ../surface/use-shape-query}.useShapeQuery reads through since
|
||||
* `1.0.0-dev.6`. Pass it as it stands: `ormSubscription: RdfOrmSubscription.getOrCreate`.
|
||||
*
|
||||
* OPTIONAL, and that is a scope decision rather than a soft one: an application that
|
||||
* never calls `useShapeQuery` has nothing to inject here, and every suite of this
|
||||
* package that exercises another surface would otherwise carry a stub for a call it
|
||||
* does not make. What is NOT optional is the consequence - `useShapeQuery` without it
|
||||
* publishes `isError` naming this field, never an empty set, because "nobody answered"
|
||||
* reported as "there is nothing" is the one failure that surface exists to prevent.
|
||||
*/
|
||||
ormSubscription?: OrmSubscriptionLike;
|
||||
/**
|
||||
* POINTER micro-guard budget — see {@link StoreRegistryDeps.pointerGuard}. Left unset
|
||||
* → a single read, which keeps the synchronous unit fakes fast.
|
||||
@@ -414,7 +435,7 @@ export function hasCap(nuri: NuriLike): boolean {
|
||||
*/
|
||||
export function resetCaps(): void {
|
||||
// Clear IN PLACE rather than rebuilding: whoever subscribed to the registry's
|
||||
// change signal (`watchShape`) stays subscribed to the live instance instead of
|
||||
// change signal (`useShapeQuery`) stays subscribed to the live instance instead of
|
||||
// silently holding a listener on an orphaned one.
|
||||
caps.clear();
|
||||
// …and forget which documents were already asked about, or the emulated public-store
|
||||
|
||||
@@ -817,7 +817,7 @@ async function processForOwner(targetInbox: Nuri): Promise<void> {
|
||||
// still refused by `readForHolder` below.
|
||||
//
|
||||
// ONE visible consequence, and it is deliberate: filing a cap fires the registry's change
|
||||
// signal, which is global rather than per-holder, so a `watchShape` open in the connected
|
||||
// signal, which is global rather than per-holder, so a `useShapeQuery` open in the connected
|
||||
// session re-reads. It re-reads the SAME data — the connected identity gains nothing —
|
||||
// and the signal cannot be suppressed for the other case this same path serves: an owner
|
||||
// draining an inbox on their own document, where re-reading is exactly the contract
|
||||
|
||||
@@ -46,10 +46,11 @@
|
||||
* nothing logs, the stale unsubscribe still "works".
|
||||
*
|
||||
* That made every internal subscriber a hazard to every other one. `ensureRepoOpen`
|
||||
* holds a bootstrap subscription per document for the whole session, `watchShape`
|
||||
* subscribes to the documents of a scope, `inbox.watch` to an inbox — so opening a
|
||||
* repo killed the watch on it, and the app-visible result was a view that never
|
||||
* re-read and an inbox that never notified. Both were reported as "the layer does not
|
||||
* holds a bootstrap subscription per document for the whole session, the reactive shape
|
||||
* read of the day subscribed to the documents of a scope (a pipeline retired in
|
||||
* `1.0.0-dev.6`), `inbox.watch` to an inbox - so opening a repo killed the watch on it,
|
||||
* and the app-visible result was a view that never re-read and an inbox that never
|
||||
* notified. Both were reported as "the layer does not
|
||||
* notify of its own actions", which is NOT what happens: a same-session write DOES
|
||||
* push (verified the same day — `Patch@69ms` on the writer's own `sparqlUpdate`,
|
||||
* `e2e/reactivity-doc-subscribe.ts`). The push arrived; there was no longer anybody
|
||||
@@ -453,7 +454,7 @@ export function resetDocSubscriptions(): void {
|
||||
* synced. Zero rows, "synced", no error: the silent staleness this module exists to remove.
|
||||
*
|
||||
* So the barrier is forgotten and the channel re-opened, rather than the listeners dropped:
|
||||
* a `watchShape` that was following a document keeps following it across the change.
|
||||
* an `inbox.watch` that was following a document keeps following it across the change.
|
||||
*/
|
||||
// @provenance resubscribeDocs kind=invention level=none ref=none — per-session bookkeeping, like `resetOpenedRepos`; upstream a session's subscriptions die with it and nothing carries them over
|
||||
export function resubscribeDocs(): void {
|
||||
|
||||
@@ -1,280 +1,155 @@
|
||||
/**
|
||||
* use-shape-query — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
|
||||
* use-shape-query - a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
|
||||
* one SDK `Scope`. This is the surface the consuming app binds with
|
||||
* `useSyncExternalStore` — the polyfill deliberately exposes an OBSERVABLE, never a
|
||||
* `useSyncExternalStore` - the polyfill deliberately exposes an OBSERVABLE, never a
|
||||
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
|
||||
*
|
||||
* ── The name, and the one difference from the SDK that is deliberate ───────
|
||||
* -- The name, and the one difference from the SDK that is deliberate -------
|
||||
* It was `watchShape` until `1.0.0-dev.5`. That name has ZERO occurrences upstream, and
|
||||
* it spent `watch`, a verb the ORM has already spent on signal observation
|
||||
* (`watch as watchDeepSignal`). The target's own name is `useShapeQuery`, and what it
|
||||
* returns is the snapshot below rather than the bare `DeepSignalSet` today's `useShape`
|
||||
* hands back — announced by NextGraph's developers and on record in
|
||||
* hands back - announced by NextGraph's developers and on record in
|
||||
* `.project/rules/nextgraph/surface-mirrors-the-sdk.md`, which is what the annotation on
|
||||
* the function cites. Deviating TOWARD an announced target is alignment; the rest of the
|
||||
* surface is what will catch up:
|
||||
* `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`.
|
||||
* `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads
|
||||
* `{ data: [], isPending: false, isSuccess: true }` — the key distinction — while a
|
||||
* `{ data: [], isPending: false, isSuccess: true }` - the key distinction - while a
|
||||
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
|
||||
*
|
||||
* ── The scope is the SDK's, and translating it is EXPLICITLY TEMPORARY ─────
|
||||
* `scope` is `@ng-org/orm`'s own `Scope` — `{ graphs: string[] | string; subjects?:
|
||||
* string[] }` — not the three strings this module took before. A `graphs` entry names
|
||||
* ONE document's graph, `did:ng:i` (or `""`) discards the rest and means the whole local
|
||||
* dataset, and `[]` or `undefined` returns no objects at all: all three are upstream's
|
||||
* rules, reproduced by `normalizeScope` below.
|
||||
* -- What it reads THROUGH, since `1.0.0-dev.6` ----------------------------
|
||||
* The SDK's own shape query - `RdfOrmSubscription.getOrCreate(shapeType, scope)` - and
|
||||
* nothing of this library's own. Four steps went with the change, and they were one
|
||||
* mechanism: a logical scope resolved to a document set (`listMyEntityDocs`), those
|
||||
* documents opened through `ensureReposOpen` for a sync barrier, `readUnion` over them,
|
||||
* and a filter of the union by the shape's `@type` IRI. All four existed because the
|
||||
* parameter had to be TRANSLATED; once the parameter is upstream's own `Scope`, the
|
||||
* question goes straight to the thing that answers it. `graphs`, `subjects` and the
|
||||
* whole-dataset marker `did:ng:i` are now read by the engine, literally, and this module
|
||||
* does not interpret them at all - so the three readings are upstream's, not ours.
|
||||
*
|
||||
* `graphs` is REQUIRED as of `@ng-org/orm@0.1.2-alpha.21`; it used to be optional. Only
|
||||
* the spelling changed, never the meaning: a caller who wants "everything" still has to
|
||||
* say `did:ng:i`, because on this axis an empty list is the EMPTY set and not the absent
|
||||
* filter. `subjects` keeps the opposite convention - there `[]` means no filtering - so
|
||||
* the two axes read `[]` in opposite directions and `normalizeScope` keeps them apart.
|
||||
* It does NOT delegate to the SDK's `useShape`: that is React's hook, it returns a bare
|
||||
* `DeepSignalSet`, and a set cannot carry the request state this surface exists to
|
||||
* publish. What is taken from the SDK is the QUERY; the observable, the snapshot and the
|
||||
* state tracking stay here.
|
||||
*
|
||||
* Only the whole-dataset reading still has to be TRANSLATED, and the translation is the
|
||||
* tell that the mechanism under it is about to stop being ours: it is served by
|
||||
* enumerating this identity's own documents across its three stores
|
||||
* (`account-registry.listMyEntityDocs`, no longer published), because this pipeline has
|
||||
* no other way to say "everything". When the read queries the store itself, the
|
||||
* enumeration and this branch go together. A named `graphs` list needs no translation at
|
||||
* all — it is already the doc set.
|
||||
* -- What stays ours, and why ----------------------------------------------
|
||||
* 1. **The three states.** `readyPromise` is the ORM's own answer to "has the initial
|
||||
* payload arrived" - it resolves on that path and on no other - so it is what tells
|
||||
* `isPending` from a genuinely empty `isSuccess`. The React adapter one floor up
|
||||
* drops that distinction (an empty set "if still loading"); this does not.
|
||||
* 2. **The capability filter.** `emulated-verifier/read-filter.ts` keeps only items
|
||||
* whose document (`@graph`) the current holder holds a cap for - the emulation of
|
||||
* what the broker will bound at delivery, applied per document on what comes back,
|
||||
* exactly where `surface/use-shape.ts` applies it. Before the first cap is issued
|
||||
* the registry is not enforcing and everything passes through.
|
||||
* 3. **The keyring signal.** A cap that arrives asynchronously (an inbox delivery
|
||||
* absorbed by `inbox.watch`) makes items readable that were not, so
|
||||
* `CapRegistry.onChange` re-publishes. Without it a view stays stale until an
|
||||
* unrelated change happens to fire.
|
||||
*
|
||||
* ── What the observable OWNS (the whole read pipeline) ─────────────────────
|
||||
* 1. Resolve the scope → the doc set: the graphs named, or — for the whole-dataset
|
||||
* reading — the CURRENT wallet's own per-entity documents, and nothing else. There
|
||||
* is no "everything public" to fold in — **you cannot discover, you can only follow
|
||||
* links** (see docs/readcap-and-nuri-model.md §4ter-bis), and a link reaches you
|
||||
* through an inbox or through a document you already hold. A document whose cap you
|
||||
* were given is read by NAMING it, not by turning up in a scope you never put it in.
|
||||
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
|
||||
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
|
||||
* fallback). `isPending` holds until the barrier is reached for the current
|
||||
* doc set AND the first read has rendered.
|
||||
* 3. `readUnion(docs)` — the read-model (cap filter already applied inside; we do
|
||||
* NOT double-filter), then FILTER the union by the requested shape's `@type` and by
|
||||
* the scope's `subjects` axis (a union spans multiple types; each `useShapeQuery`
|
||||
* yields only the subjects of its shape). Non-domain: the type IRI is read from the
|
||||
* SHEX ShapeType, not from any application concept.
|
||||
* -- Reactivity WITHOUT polling (no `setInterval`) --------------------------
|
||||
* Two push sources, both of them signals: the subscription's own
|
||||
* `addChangeListener` (root objects added, updated or removed - the engine's patches
|
||||
* reaching the signal object), and the keyring above. No document subscription of this
|
||||
* library's is opened here any more: the ORM holds the one that matters.
|
||||
*
|
||||
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
|
||||
* Reactivity is push-only (rule no-broker-polling). It has TWO sources: document
|
||||
* pushes, and the KEYRING — a cap that arrives asynchronously (an inbox delivery
|
||||
* absorbed by the consumer's `inbox.watch`) makes documents readable that were not,
|
||||
* so `CapRegistry.onChange` re-reads. Without that, a view stays stale until an
|
||||
* unrelated push happens to fire. On the document side, `subscribeDoc` on every doc
|
||||
* in the current set re-runs `readUnion` on any push. Under the whole-dataset reading the
|
||||
* set is DYNAMIC (creating an entity appends a NURI to its store's index doc), so we ALSO
|
||||
* subscribe to those index documents: a push there re-RESOLVES and re-keys the subscribed
|
||||
* set. A named `graphs` list has no container to follow — the caller fixed the set.
|
||||
* Subscriptions are idempotent — an already-followed doc is not re-subscribed. Everything
|
||||
* reuses `subscribe.ts` / `open-repo.ts`; no parallel channel.
|
||||
* -- `isError` is wired to what CAN fail, and says so ----------------------
|
||||
* One thing on this path throws, and it throws at the call: opening the shape query -
|
||||
* a shape the ORM cannot canonicalise, or a `configure()` that was never given the
|
||||
* query to open. That is what `isError` carries.
|
||||
*
|
||||
* ── timed-out → isSuccess (best-effort), NOT isError ───────────────────────
|
||||
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
|
||||
* (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error.
|
||||
* `isError` fires ONLY on a real thrown exception in the pipeline.
|
||||
* What it does NOT carry, recorded rather than assumed: a start the ENGINE refuses.
|
||||
* `RdfOrmSubscription` awaits its session and calls `orm_start_graph` inside a
|
||||
* `try { ... } catch (e) { console.error(e) }`, and its readiness promise is built with
|
||||
* `resolve` alone - nothing rejects it, and `resolveReady` runs on one path only, the
|
||||
* arrival of initial data. So upstream's *"I could not find out"* IS its *"still
|
||||
* pending"*, and a refused read reads here as `isPending` for ever, never as
|
||||
* `isSuccess` with `data: []`. The distinction this surface exists for therefore holds
|
||||
* - an application is never told "there is nothing" for "nobody answered" - but the
|
||||
* third state is narrower than it was while the pipeline was ours. `isError` stays
|
||||
* because this package's job is to ENRICH the SDK in its announced direction, not to be
|
||||
* bounded by what it exposes today; the day the ORM surfaces a failed start, this is
|
||||
* where it lands.
|
||||
*
|
||||
* ── A scope that did not answer is NOT an empty scope ──────────────────────
|
||||
* The pipeline's first step ASKS a question — which documents are mine — and until
|
||||
* 2026-08-17 a failure to answer it was logged and treated as "none": the empty set
|
||||
* flowed on, the barrier over zero docs is trivially reached, and the surface published
|
||||
* `{ data: [], isSuccess: true }`. That snapshot is BYTE-FOR-BYTE the synced-but-empty
|
||||
* one this module exists to distinguish, so the one distinction it publishes was the one
|
||||
* it destroyed — an application rendered "you have nothing" for "the store did not
|
||||
* answer". `listMyEntityDocs` stopped handing out that reading one layer down
|
||||
* (`90712e0`); catching it here put it straight back.
|
||||
*
|
||||
* A resolution failure now travels the LOAD-STATE channel, which is where "this is
|
||||
* not an answer" already lives on this surface: `isError` with the thrown `error`,
|
||||
* and never `isSuccess`. The channel is the honest place because it is the one an
|
||||
* application already has to read to tell pending from empty — the same three-way
|
||||
* question, and the third state costs it no new vocabulary.
|
||||
*
|
||||
* ── An observable cannot un-emit: `data` survives an error ─────────────────
|
||||
* -- An observable cannot un-emit: `data` survives an error -----------------
|
||||
* A one-shot call rejects and is done. An observable has already handed a list to a
|
||||
* subscriber that rendered it, so its failure snapshot has to say something about
|
||||
* that list — and collapsing it to `[]` would hand out, in `data`, exactly the empty
|
||||
* that list - and collapsing it to `[]` would hand out, in `data`, exactly the empty
|
||||
* answer this fix removes. So `data` KEEPS the last read that actually answered, and
|
||||
* `isError` says the current answer is unknown. `isSuccess` is false throughout: the
|
||||
* array is a memory, not a reply to the question just asked.
|
||||
*/
|
||||
|
||||
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import { ensureReposOpen, getSyncState } from "../emulated-verifier/open-repo";
|
||||
import { readUnion, type UnionSubject } from "./read-model";
|
||||
import { subscribeDoc, type Unsubscribe } from "./subscribe";
|
||||
import { listMyEntityDocs, userStoreDoc } from "../shared-wallet/account-registry";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import type { Nuri, StoreType } from "../model/types";
|
||||
// The SDK's own scope type, imported as a TYPE (erased at build, so no runtime `@ng-org`
|
||||
// import is added here). Its provenance is recorded where it is PUBLISHED, in `index.ts`.
|
||||
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
|
||||
import { filterReadable } from "../emulated-verifier/read-filter";
|
||||
import type { OrmSubscriptionHandle, OrmSubscriptionLike } from "../model/types";
|
||||
// The SDK's own types, imported as TYPES (erased at build, so no runtime `@ng-org`
|
||||
// import is added here). The provenance of what is PUBLISHED is recorded in `index.ts`.
|
||||
import type { Scope } from "@ng-org/orm";
|
||||
|
||||
/** Upstream's whole-dataset marker. `normalizeScope` maps `""` onto it, and
|
||||
* `orm_start_graph` lets an entry equal to it DISCARD every other entry. */
|
||||
const ENTIRE_DATASET = "did:ng:i";
|
||||
|
||||
/** The three stores an identity's own documents can sit in. */
|
||||
const STORE_TYPES: readonly StoreType[] = ["private", "protected", "public"];
|
||||
|
||||
/** What `normalizeScope` produces: two lists, `""` already mapped onto `did:ng:i`. */
|
||||
type NormalizedScope = { graphs: string[]; subjects: string[] };
|
||||
|
||||
/**
|
||||
* `normalizeScope`, reproduced rather than imported: this package never hard-imports the
|
||||
* SDK at RUNTIME (see the module header of `use-shape.ts` and `configure`'s `NgLike`), and
|
||||
* a type-only import is erased. Same three rules as upstream - a bare string is one graph,
|
||||
* `""` becomes `did:ng:i`, an absent list is empty.
|
||||
*/
|
||||
// @provenance normalizeScope kind=aligned level=3 ref=sdk/js/orm/src/types.ts:normalizeScope — the same three rules; reproduced because the SDK is injected, never imported at runtime
|
||||
function normalizeScope(scope: Scope | string | undefined): NormalizedScope {
|
||||
if (typeof scope === "string") {
|
||||
return { graphs: [scope === "" ? ENTIRE_DATASET : scope], subjects: [] };
|
||||
}
|
||||
const graphs = (!scope?.graphs ? [] : [scope.graphs]).flat().map((g) => (g === "" ? ENTIRE_DATASET : g));
|
||||
return { graphs, subjects: scope?.subjects ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The RDF `type` predicate IRI. A SHEX shape pins its class via a triple
|
||||
* constraint on this predicate; we filter the read union by it.
|
||||
*/
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
|
||||
/**
|
||||
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
|
||||
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `useShapeQuery` yields
|
||||
* the generic per-subject property bags of the read-model (NO application domain);
|
||||
* the app maps them to its own entity types in phase B.
|
||||
* (never `undefined`), and its items are the SHAPE's own objects - what the ORM
|
||||
* returns, carrying `@id`, `@graph` and the shape's readable predicates.
|
||||
*/
|
||||
// @provenance ShapeQuery kind=invention level=none ref=none — no upstream type carries load state; `OrmSubscription.readyPromise` expresses pending-vs-ready and nothing else (built with `resolve` alone, and `orm_start_graph` failing is only logged — a failed read is an eternal pending), so `isError` has no counterpart at any level
|
||||
export interface ShapeQuery<T = UnionSubject> {
|
||||
/** The subjects of the requested shape/scope. Empty array when none (never undefined).
|
||||
* When `isError`, this is the last read that ANSWERED — kept, not cleared, because an
|
||||
// @provenance ShapeQuery kind=invention level=none ref=none - no upstream type carries load state; `RdfOrmSubscription.readyPromise` expresses pending-vs-ready and nothing else (built with `resolve` alone, and a refused `orm_start_graph` is only `console.error`'d - a failed read is an eternal pending), so `isError` has no counterpart at any level
|
||||
export interface ShapeQuery<T extends BaseType = BaseType> {
|
||||
/** The objects of the requested shape/scope. Empty array when none (never undefined).
|
||||
* When `isError`, this is the last read that ANSWERED - kept, not cleared, because an
|
||||
* observable cannot un-emit and `[]` here would be the false "you have nothing" the
|
||||
* error state exists to prevent. Read it as a memory then, not as a reply. */
|
||||
data: T[];
|
||||
/** True while the sync barrier for the current doc set is not yet reached OR the
|
||||
* first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */
|
||||
/** True until the shape query's initial payload has arrived (`readyPromise`).
|
||||
* Mutually exclusive with `isSuccess`. */
|
||||
isPending: boolean;
|
||||
/** True once the barrier is reached (all docs `synced` OR `timed-out`) AND the
|
||||
* first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with
|
||||
* `data: []` — the distinction this surface exists for. */
|
||||
/** True once the initial payload has arrived. A ready-but-EMPTY scope is `isSuccess`
|
||||
* with `data: []` - the distinction this surface exists for. */
|
||||
isSuccess: boolean;
|
||||
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`):
|
||||
* the scope could not be resolved, or the union could not be read. It means the
|
||||
* current answer is UNKNOWN — never that the scope is empty. Mutually exclusive with
|
||||
* both `isPending` and `isSuccess`. */
|
||||
/** True ONLY when opening the shape query threw: the shape could not be subscribed, or
|
||||
* `configure()` was never given the query to open it with. It means the current answer
|
||||
* is UNKNOWN - never that the scope is empty. Mutually exclusive with both `isPending`
|
||||
* and `isSuccess`. See the module header for the failure it does NOT cover. */
|
||||
isError: boolean;
|
||||
/** The caught error when `isError`, else `undefined`. */
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
/** The observable a caller binds with `useSyncExternalStore` (phase B). */
|
||||
// @provenance ShapeObservable kind=invention level=none ref=none — no upstream observable exists; the ORM exposes a hook returning a DeepSignalSet and nothing else
|
||||
export interface ShapeObservable<T = UnionSubject> {
|
||||
// @provenance ShapeObservable kind=invention level=none ref=none - no upstream observable exists; the ORM exposes a hook returning a DeepSignalSet and nothing else
|
||||
export interface ShapeObservable<T extends BaseType = BaseType> {
|
||||
/** The current snapshot. STABLE across calls until it actually changes (so
|
||||
* `useSyncExternalStore` does not loop): the same reference is returned until a
|
||||
* state transition produces a new one. */
|
||||
getSnapshot(): ShapeQuery<T>;
|
||||
/** Register a change listener; returns an unsubscribe. The last listener's
|
||||
* unsubscribe tears down the underlying doc subscriptions. */
|
||||
* unsubscribe closes the underlying shape query. */
|
||||
subscribe(onChange: () => void): () => void;
|
||||
/** Force a re-resolve + re-read now (e.g. an imperative refresh). Idempotent
|
||||
* w.r.t. subscriptions; never polls. */
|
||||
/** Ask again, now: close the shape query in force and open a fresh one. Idempotent
|
||||
* w.r.t. subscribers, and it never polls - the ORM pools a subscription it has just
|
||||
* closed, so asking again over an answering broker costs nothing and answers at
|
||||
* once. The snapshot is NOT reset: whatever was rendered stays until the new
|
||||
* subscription answers or fails. */
|
||||
refetch(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the class IRI(s) a SHEX {@link ShapeType} pins on `rdf:type`, if any.
|
||||
* A generated shape constrains its subject's type via a triple constraint on the
|
||||
* `rdf:type` predicate whose `literals` carry the class IRI(s). Returns the set of
|
||||
* those IRIs, or `null` when the shape pins NO type (then no type-filter is applied
|
||||
* and every subject in the doc set flows through). Purely structural — reads only
|
||||
* the SHEX schema, no application domain.
|
||||
*/
|
||||
function shapeTypeIris(shapeType: unknown): Set<string> | null {
|
||||
try {
|
||||
const st = shapeType as {
|
||||
shape?: string;
|
||||
schema?: Record<string, { predicates?: Array<{ iri?: string; dataTypes?: Array<{ literals?: unknown[] }> }> }>;
|
||||
};
|
||||
const shape = st?.shape && st.schema ? st.schema[st.shape] : undefined;
|
||||
const preds = shape?.predicates ?? [];
|
||||
const iris = new Set<string>();
|
||||
for (const p of preds) {
|
||||
if (p?.iri !== RDF_TYPE) continue;
|
||||
for (const dt of p.dataTypes ?? []) {
|
||||
for (const lit of dt.literals ?? []) {
|
||||
if (typeof lit === "string") iris.add(lit);
|
||||
}
|
||||
}
|
||||
}
|
||||
return iris.size > 0 ? iris : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a subject satisfies the shape's `@type` constraint (or the shape pins none). */
|
||||
function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): boolean {
|
||||
if (!typeIris) return true; // shape pins no rdf:type → accept every subject
|
||||
const types = subject.props[RDF_TYPE] ?? [];
|
||||
return types.some((t) => typeIris.has(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a subject passes the scope's `subjects` axis. An empty list is upstream's "no
|
||||
* filtering" (`Scope.subjects`: *"Set to `[]` or leave it `undefined` for no filtering"*),
|
||||
* and the filter is literal - a subject IRI is in the list or it is not, exactly as
|
||||
* `FILTER(?s IN (…))` is emitted upstream.
|
||||
*/
|
||||
function matchesSubjects(subject: UnionSubject, wanted: string[]): boolean {
|
||||
return wanted.length === 0 || wanted.includes(subject.subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the sync BARRIER is reached for the whole doc set. Called only AFTER
|
||||
* `ensureReposOpen(docs)` has resolved, so each doc has been requested; the only
|
||||
* state that still holds the barrier open is `syncing` (subscribed, first `State`
|
||||
* not yet received). `synced` and `timed-out` both count as reached (`timed-out` is
|
||||
* best-effort, not an error). `unknown` means the injected `ng` has no
|
||||
* `doc_subscribe` (the fake/no-op open path, which has NO barrier semantics) — after
|
||||
* a completed open it can only mean that path, so it counts as reached (opened,
|
||||
* nothing to wait on). An EMPTY doc set is trivially past the barrier.
|
||||
*/
|
||||
function barrierReached(docs: Nuri[]): boolean {
|
||||
for (const d of docs) {
|
||||
if (getSyncState(d) === "syncing") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
|
||||
* `scope` - the SDK's own `{ graphs, subjects }`. See the module header for the full
|
||||
* pipeline and for what the `graphs` axis costs while the read path is still this
|
||||
* library's. The returned observable is inert until its first
|
||||
* {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is
|
||||
* what kicks off resolution, opening and the first read; before then `getSnapshot`
|
||||
* reports the initial pending snapshot.
|
||||
* `scope` - the SDK's own `{ graphs, subjects }`, passed through untouched. See the
|
||||
* module header for what is read underneath and what each state means. The returned
|
||||
* observable is inert until its first {@link ShapeObservable.subscribe} (or
|
||||
* {@link ShapeObservable.refetch}) - that is what opens the shape query; before then
|
||||
* `getSnapshot` reports the initial pending snapshot.
|
||||
*/
|
||||
// @provenance useShapeQuery kind=announced-not-written level=3 ref=.project/rules/nextgraph/surface-mirrors-the-sdk.md:useShapeQuery announced-by=nextgraph-developers@2026-09-16 — the SDK ships `useShape` returning a bare `DeepSignalSet`; the announced direction is a `useShapeQuery` returning a TanStack-shaped snapshot
|
||||
export function useShapeQuery<T = UnionSubject>(
|
||||
shapeType: unknown,
|
||||
// @provenance useShapeQuery kind=announced-not-written level=3 ref=.project/rules/nextgraph/surface-mirrors-the-sdk.md:useShapeQuery announced-by=nextgraph-developers@2026-09-16 - the SDK ships `useShape` returning a bare `DeepSignalSet`; the announced direction is a `useShapeQuery` returning a TanStack-shaped snapshot
|
||||
export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
shapeType: ShapeType<T>,
|
||||
scope: Scope,
|
||||
): ShapeObservable<T> {
|
||||
const typeIris = shapeTypeIris(shapeType);
|
||||
const asked = normalizeScope(scope);
|
||||
// `did:ng:i` (or `""`) discards every other entry upstream and means the whole local
|
||||
// dataset. TEMPORARY: until the read queries the store itself, that is served
|
||||
// by enumerating this identity's own documents across its three stores - the widest
|
||||
// set this pipeline can reach. Every other `graphs` list names documents and is used
|
||||
// as given, which is what the axis will mean once the enumeration is gone.
|
||||
const wholeDataset = asked.graphs.includes(ENTIRE_DATASET);
|
||||
|
||||
// The current, STABLE snapshot (same reference until a transition rebuilds it).
|
||||
let snapshot: ShapeQuery<UnionSubject> = {
|
||||
let snapshot: ShapeQuery<T> = {
|
||||
data: [],
|
||||
isPending: true,
|
||||
isSuccess: false,
|
||||
@@ -284,21 +159,21 @@ export function useShapeQuery<T = UnionSubject>(
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let started = false;
|
||||
// The docs currently subscribed for change signals, keyed by NURI → unsubscribe.
|
||||
// Idempotent: a doc already here is not re-subscribed. Excludes the container
|
||||
// (scope-index / discovery-index) subscriptions, held separately.
|
||||
const docSubs = new Map<Nuri, Unsubscribe>();
|
||||
// Container subscriptions (scope-index doc; discovery-index doc for `public`) —
|
||||
// a push here means the doc SET may have changed → re-resolve.
|
||||
const containerSubs = new Map<Nuri, Unsubscribe>();
|
||||
// Unsubscribe from the held-caps change signal (see the subscription in `start`).
|
||||
/** The shape query in force, or `null` between a teardown and the next open. */
|
||||
let query: OrmSubscriptionHandle | null = null;
|
||||
/** The change listener registered on {@link query}, so it can be taken off again. */
|
||||
let onQueryChange: (() => void) | null = null;
|
||||
/** Unsubscribe from the held-caps change signal (see the subscription in `start`). */
|
||||
let capsUnsub: (() => void) | null = null;
|
||||
// True while `resolveDocs` runs. Folding a repo link files a cap, which fires the
|
||||
// held-caps signal; the resolution in progress already accounts for it, so the
|
||||
// signal is ignored during that window instead of restarting the cycle.
|
||||
let resolving = false;
|
||||
// Monotonic token so a slow in-flight refresh cannot clobber a newer one.
|
||||
let refreshToken = 0;
|
||||
/**
|
||||
* Which open owns the question. A `refetch` supersedes the previous subscription, and
|
||||
* a teardown supersedes all of them - so every callback checks it is still the current
|
||||
* one before publishing. A stale answer is dropped, never merged.
|
||||
*/
|
||||
let generation = 0;
|
||||
/** Has the CURRENT generation's initial payload arrived? Gates every publication:
|
||||
* a change signal that beats `readyPromise` must not be read as an answer. */
|
||||
let ready = false;
|
||||
|
||||
function emit(): void {
|
||||
for (const l of listeners) {
|
||||
@@ -310,22 +185,22 @@ export function useShapeQuery<T = UnionSubject>(
|
||||
}
|
||||
}
|
||||
|
||||
function setSnapshot(next: ShapeQuery<UnionSubject>): void {
|
||||
function setSnapshot(next: ShapeQuery<T>): void {
|
||||
snapshot = next;
|
||||
emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish "the current answer is UNKNOWN" — the one snapshot every failure in the
|
||||
* pipeline produces, wherever it was thrown.
|
||||
* Publish "the current answer is UNKNOWN" - the one snapshot every failure on this
|
||||
* path produces.
|
||||
*
|
||||
* `data` carries the LAST READ THAT ANSWERED rather than `[]`. A subscriber has
|
||||
* already rendered that list and an observable cannot un-emit it; clearing it would
|
||||
* put the empty answer back — in the field an application actually renders, and for
|
||||
* put the empty answer back - in the field an application actually renders, and for
|
||||
* the very case that must never read as empty. `isSuccess` stays false, so the array
|
||||
* is never offered as a reply to the question that just failed, and a subscriber that
|
||||
* gates on `isSuccess` shows nothing new while one that renders `data` keeps what it
|
||||
* had. Before the first answer there is nothing to keep and `data` is `[]` — the
|
||||
* had. Before the first answer there is nothing to keep and `data` is `[]` - the
|
||||
* initial value, published under `isError`, never under `isSuccess`.
|
||||
*/
|
||||
function setUnknown(error: unknown): void {
|
||||
@@ -338,169 +213,134 @@ export function useShapeQuery<T = UnionSubject>(
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve the logical scope → the current doc set: the CURRENT wallet's own
|
||||
* entity documents for that scope, and nothing else.
|
||||
/**
|
||||
* The readable items of the live subscription, as a plain array.
|
||||
*
|
||||
* It PROPAGATES, and that is the whole point: the set it returns is what the rest
|
||||
* of the pipeline calls "the scope", so a swallowed failure here does not degrade
|
||||
* the answer, it INVENTS one — zero documents, a barrier trivially reached over
|
||||
* them, and `isSuccess` published over a store nobody read. Nothing downstream can
|
||||
* tell that set apart from a scope that is genuinely empty, because it IS the same
|
||||
* set. The caller's error state exists for this.
|
||||
*
|
||||
* There is no "everything public" to fold in. You cannot discover; you can only
|
||||
* follow links, and a link reaches you through an inbox or through a document
|
||||
* you already hold — never through a shared index. A document someone gave you
|
||||
* the cap for is read by naming it (`readUnion`), not by appearing in
|
||||
* a scope you did not put it in. */
|
||||
async function resolveDocs(): Promise<Nuri[]> {
|
||||
if (!wholeDataset) {
|
||||
// The caller named the documents. `[]` or an absent `graphs` is upstream's "no
|
||||
// objects are returned", and it reaches that answer here the same way: an empty
|
||||
// doc set, a barrier trivially past it, and `isSuccess` over nothing.
|
||||
return [...new Set(asked.graphs.filter(Boolean))].map((g) => toNuri(g, "useShapeQuery"));
|
||||
}
|
||||
const user = getCurrentUser();
|
||||
const set = new Set<Nuri>();
|
||||
resolving = true;
|
||||
try {
|
||||
if (user) {
|
||||
for (const type of STORE_TYPES) {
|
||||
for (const d of await listMyEntityDocs(user, type)) set.add(d);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resolving = false;
|
||||
}
|
||||
return [...set];
|
||||
* The ONE cast in this module, and it sits where the injected SDK meets the shape's
|
||||
* promise: the subscription is typed by what `configure` received (`unknown` items,
|
||||
* because the injection is a loose shape), while `shapeType` is what says they are
|
||||
* `T`. Filtering happens first and on the raw items - the filter reads `@graph` and
|
||||
* nothing else, so it needs no type at all.
|
||||
*/
|
||||
function readable(handle: OrmSubscriptionHandle): T[] {
|
||||
const caps = getCaps();
|
||||
const items = [...handle.signalObject];
|
||||
// No cap issued yet -> the emulation is not in force and the set passes through,
|
||||
// exactly as `surface/use-shape.ts` does. See `emulated-verifier/read-filter.ts`.
|
||||
const kept = caps.isEnforcing() ? filterReadable(items, caps) : items;
|
||||
return kept as T[];
|
||||
}
|
||||
|
||||
/** Subscribe to the CONTAINER document (the scope index) so a change to the doc
|
||||
* SET re-resolves. Idempotent per NURI.
|
||||
*
|
||||
* This one SWALLOWS, deliberately and unlike `resolveDocs` next door. It resolves
|
||||
* no data and publishes no snapshot — it wires a change signal — so its failure
|
||||
* cannot dress an unread store up as an empty one. And the answer is not lost with
|
||||
* it: `resolveDocs` runs immediately after over the SAME account, so a store that
|
||||
* cannot be reached surfaces there, as an error, one line later. What is lost is
|
||||
* reactivity to a later change of the SET, which is a different (and much quieter)
|
||||
* problem than the one this module was fixed for. */
|
||||
async function ensureContainerSubs(): Promise<void> {
|
||||
// A named `graphs` list IS the set; nothing a store records can change it, so there
|
||||
// is no container to follow. Only the whole-dataset reading has one - three, in fact,
|
||||
// one per store this identity places documents in.
|
||||
if (!wholeDataset) return;
|
||||
const containers: Nuri[] = [];
|
||||
const user = getCurrentUser();
|
||||
if (user) {
|
||||
for (const type of STORE_TYPES) {
|
||||
try {
|
||||
containers.push(await userStoreDoc(user, type));
|
||||
} catch (error) {
|
||||
console.error("[use-shape-query] userStoreDoc failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const c of containers) {
|
||||
if (!c || containerSubs.has(c)) continue;
|
||||
// A push on a container doc means the set may have changed → full re-resolve.
|
||||
containerSubs.set(c, subscribeDoc(c, () => void refresh()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-key the per-doc change subscriptions to exactly `docs` (idempotent adds,
|
||||
* prune removed). A push on any of these re-reads (data-only, no re-resolve). */
|
||||
function syncDocSubs(docs: Nuri[]): void {
|
||||
const wanted = new Set(docs.filter(Boolean));
|
||||
for (const [nuri, unsub] of docSubs) {
|
||||
if (!wanted.has(nuri)) {
|
||||
try {
|
||||
unsub();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
docSubs.delete(nuri);
|
||||
}
|
||||
}
|
||||
for (const nuri of wanted) {
|
||||
if (docSubs.has(nuri)) continue;
|
||||
docSubs.set(nuri, subscribeDoc(nuri, () => void reread()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Read (union + shape filter) the CURRENT doc set and publish a snapshot.
|
||||
* Derives isPending/isSuccess from the barrier + whether the read rendered. */
|
||||
async function readAndPublish(docs: Nuri[], token: number): Promise<void> {
|
||||
let subjects: UnionSubject[];
|
||||
try {
|
||||
subjects = await readUnion(docs);
|
||||
} catch (error) {
|
||||
if (token !== refreshToken) return;
|
||||
setUnknown(error);
|
||||
return;
|
||||
}
|
||||
if (token !== refreshToken) return; // superseded by a newer refresh/reread
|
||||
const data = subjects.filter((s) => matchesShape(s, typeIris) && matchesSubjects(s, asked.subjects));
|
||||
const past = barrierReached(docs);
|
||||
/** Publish what the subscription holds right now. Only ever called on a READY
|
||||
* generation, so this snapshot is always an answer. */
|
||||
function publish(handle: OrmSubscriptionHandle, gen: number): void {
|
||||
if (gen !== generation || !ready) return;
|
||||
setSnapshot({
|
||||
data,
|
||||
isPending: !past,
|
||||
isSuccess: past,
|
||||
data: readable(handle),
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Full cycle: resolve the scope, (re)establish container subs, open the docs
|
||||
* (await the barrier), sync per-doc subs, then read + publish.
|
||||
/**
|
||||
* The SDK's shape query, or the error that says it was never injected.
|
||||
*
|
||||
* This catch is the surface's ONE answer to "the pipeline could not answer" —
|
||||
* scope resolution included, since `resolveDocs` propagates. It is reached only
|
||||
* when the cycle is still the current one; a superseded cycle's failure is
|
||||
* dropped, because a newer cycle owns the question by then. */
|
||||
async function refresh(): Promise<void> {
|
||||
const token = ++refreshToken;
|
||||
* Upstream an application imports the ORM; here it hands it to `configure`, like the
|
||||
* `ng` and the `useShape` beside it. Absent, there is nothing to read through - and
|
||||
* that has to be said out loud rather than answered with an empty set, which is the
|
||||
* one reading this surface must never produce.
|
||||
*/
|
||||
function shapeQuery(): OrmSubscriptionLike {
|
||||
const injected = getConfig().ormSubscription;
|
||||
if (!injected) {
|
||||
throw new Error(
|
||||
"[ng-eventually] useShapeQuery: no shape query was injected. Pass " +
|
||||
"`ormSubscription: RdfOrmSubscription.getOrCreate` (from `@ng-org/orm`) to " +
|
||||
"configure() - this package reads through the SDK's own shape query and has " +
|
||||
"no read of its own to fall back on.",
|
||||
);
|
||||
}
|
||||
return injected;
|
||||
}
|
||||
|
||||
/** Take the current subscription down: stop listening, close it, forget it. Leaves
|
||||
* the snapshot and the subscribers alone - this is not an answer, it is plumbing. */
|
||||
function closeQuery(): void {
|
||||
if (query === null) return;
|
||||
const handle = query;
|
||||
query = null;
|
||||
ready = false;
|
||||
if (onQueryChange) {
|
||||
try {
|
||||
handle.removeChangeListener(onQueryChange);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
onQueryChange = null;
|
||||
}
|
||||
try {
|
||||
await ensureContainerSubs();
|
||||
const docs = await resolveDocs();
|
||||
if (token !== refreshToken) return;
|
||||
syncDocSubs(docs);
|
||||
// Open/await the barrier (first State per doc, or timed-out). No-op once open.
|
||||
await ensureReposOpen(docs);
|
||||
if (token !== refreshToken) return;
|
||||
await readAndPublish(docs, token);
|
||||
} catch (error) {
|
||||
if (token !== refreshToken) return;
|
||||
setUnknown(error);
|
||||
handle.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** A push on an already-open doc: re-read the CURRENT set only (no re-resolve,
|
||||
* the set is unchanged). Reuses the docs we are subscribed to. */
|
||||
async function reread(): Promise<void> {
|
||||
const token = ++refreshToken;
|
||||
const docs = [...docSubs.keys()];
|
||||
await readAndPublish(docs, token);
|
||||
/**
|
||||
* Open a shape query for `(shapeType, scope)` and wire its two signals.
|
||||
*
|
||||
* Supersedes whatever was open: the previous generation's callbacks are dropped on
|
||||
* arrival rather than raced, so a slow answer can never clobber a newer question.
|
||||
*/
|
||||
function open(): void {
|
||||
closeQuery();
|
||||
const gen = ++generation;
|
||||
let handle: OrmSubscriptionHandle;
|
||||
try {
|
||||
handle = shapeQuery()(shapeType, scope);
|
||||
} catch (error) {
|
||||
setUnknown(error);
|
||||
return;
|
||||
}
|
||||
query = handle;
|
||||
const onChange = (): void => publish(handle, gen);
|
||||
onQueryChange = onChange;
|
||||
handle.addChangeListener(onChange);
|
||||
handle.readyPromise.then(
|
||||
() => {
|
||||
if (gen !== generation) return;
|
||||
ready = true;
|
||||
publish(handle, gen);
|
||||
},
|
||||
(error: unknown) => {
|
||||
// Nothing upstream rejects this promise today (see the module header); the
|
||||
// rejection path is wired all the same, because a state nobody handles is how
|
||||
// a failure becomes silence the day the ORM starts reporting one.
|
||||
if (gen !== generation) return;
|
||||
setUnknown(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
// A cap that arrives ASYNCHRONOUSLY (an inbox deposit absorbed by the
|
||||
// consumer's `inbox.watch`) makes documents readable that were not. Without
|
||||
// this the view would stay stale until some unrelated push happened to fire —
|
||||
// so re-read whenever they change. This is the delivery channel key
|
||||
// consumer's `inbox.watch`) makes items readable that were not. Without
|
||||
// this the view would stay stale until some unrelated push happened to fire -
|
||||
// so re-publish whenever the keyring changes. This is the delivery channel key
|
||||
// ROTATION uses too, which is why keeping an access needs no subscription
|
||||
// obligation on the consumer's side.
|
||||
capsUnsub = getCaps().onChange(() => {
|
||||
if (!resolving) void refresh();
|
||||
if (query !== null) publish(query, generation);
|
||||
});
|
||||
void refresh();
|
||||
open();
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot(): ShapeQuery<T> {
|
||||
return snapshot as unknown as ShapeQuery<T>;
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(onChange: () => void): () => void {
|
||||
listeners.add(onChange);
|
||||
@@ -508,24 +348,10 @@ export function useShapeQuery<T = UnionSubject>(
|
||||
return () => {
|
||||
listeners.delete(onChange);
|
||||
if (listeners.size === 0) {
|
||||
// Last listener gone → tear down the underlying subscriptions. A later
|
||||
// subscribe restarts a fresh cycle.
|
||||
for (const u of docSubs.values()) {
|
||||
try {
|
||||
u();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const u of containerSubs.values()) {
|
||||
try {
|
||||
u();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
docSubs.clear();
|
||||
containerSubs.clear();
|
||||
// Last listener gone -> close the shape query. A later subscribe opens a
|
||||
// fresh one; the ORM pools what it has just closed, so that is cheap.
|
||||
generation += 1;
|
||||
closeQuery();
|
||||
if (capsUnsub) {
|
||||
capsUnsub();
|
||||
capsUnsub = null;
|
||||
@@ -535,8 +361,11 @@ export function useShapeQuery<T = UnionSubject>(
|
||||
};
|
||||
},
|
||||
refetch(): void {
|
||||
start();
|
||||
void refresh();
|
||||
if (!started) {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
open();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* orm-fake - a stand-in for the SDK's shape query, `RdfOrmSubscription.getOrCreate`.
|
||||
*
|
||||
* Not a `*.test.ts`, so `bun test` does not pick it up: it is the injection two suites
|
||||
* share, and it is the seam the package is built on - an application hands `configure`
|
||||
* the real ORM, and a suite hands it this.
|
||||
*
|
||||
* -- Why a small ENGINE and not a canned answer ----------------------------
|
||||
* `useShapeQuery` no longer interprets a scope: `graphs`, `subjects` and the
|
||||
* whole-dataset marker go to the engine verbatim. A fake that ignored them and returned
|
||||
* a fixed list would make every scope test green while proving nothing - the failure
|
||||
* mode `test-the-case-space` is about. So this answers the way
|
||||
* `schema_shape_to_sparql` / `query_quads_for_shape` do, and the three readings of the
|
||||
* axes are real here:
|
||||
*
|
||||
* - `graphs: []` (or absent) -> NO objects. `graphs` carrying `did:ng:i` or `""` ->
|
||||
* every other entry is discarded and the graph filter drops entirely. Otherwise the
|
||||
* filter is LITERAL - a graph is in the list or it is not, no prefix matching.
|
||||
* - `subjects: []` -> no filtering; otherwise literal, over ROOT subjects.
|
||||
* - the shape -> every predicate of `schema[shape]` must be present on the subject,
|
||||
* and a predicate carrying `literals` must match one of them. The object is
|
||||
* projected under each predicate's `readablePredicate`, plus `@id` and `@graph`.
|
||||
*
|
||||
* -- What it does NOT invent -----------------------------------------------
|
||||
* Readiness resolves on ONE path - a delivery - exactly as upstream's `resolveReady`
|
||||
* runs only on the arrival of initial data. Nothing here ever REJECTS `readyPromise`,
|
||||
* because nothing upstream does: `orm_start_graph` failing is caught and logged, and a
|
||||
* refused read is an eternal pending. A suite that wants the pending-for-ever state
|
||||
* asks for it by not delivering, which is the state the real thing produces.
|
||||
*
|
||||
* {@link FakeOrm.refuse} is the other real failure: `getOrCreate` THROWS at the call.
|
||||
*/
|
||||
|
||||
import type { OrmSubscriptionHandle, OrmSubscriptionLike } from "../src/model/types";
|
||||
import type { Scope } from "@ng-org/orm";
|
||||
import type { BaseType, Predicate, ShapeType } from "@ng-org/shex-orm";
|
||||
|
||||
/** Upstream's whole-dataset marker; `""` means the same thing. */
|
||||
const ENTIRE_DATASET = "did:ng:i";
|
||||
|
||||
/** One triple in the dataset this fake engine answers from. */
|
||||
export interface OrmQuad {
|
||||
graph: string;
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
type Listener = (changes: { adds: unknown[]; updates: unknown[]; removes: unknown[] }) => void;
|
||||
|
||||
interface OpenQuery {
|
||||
shape: ShapeType<BaseType>;
|
||||
scope: Scope;
|
||||
listeners: Set<Listener>;
|
||||
items: BaseType[];
|
||||
delivered: boolean;
|
||||
closed: boolean;
|
||||
resolveReady: () => void;
|
||||
}
|
||||
|
||||
export interface FakeOrm {
|
||||
/** What goes into `configure({ ormSubscription })`. */
|
||||
query: OrmSubscriptionLike;
|
||||
/** Add a triple to the dataset. After a delivery this also pushes, as an engine patch
|
||||
* reaching the signal object does. */
|
||||
seed(graph: string, subject: string, predicate: string, object: string): void;
|
||||
/** Deliver the initial payload to every query still waiting for one - what resolves
|
||||
* readiness upstream, and the only thing that does. */
|
||||
deliver(): void;
|
||||
/** Make every SUBSEQUENT `getOrCreate` throw. The one failure this path really has. */
|
||||
refuse(message: string): void;
|
||||
/** Stop refusing. */
|
||||
answer(): void;
|
||||
/** The scopes this fake was asked for, in order - so a suite can assert the scope
|
||||
* reached the SDK exactly as the caller wrote it. */
|
||||
asked: Array<{ shape: string; scope: Scope }>;
|
||||
/** How many queries are open right now (`close()` decrements). */
|
||||
openCount(): number;
|
||||
}
|
||||
|
||||
/** The literal values a predicate constrains its object to, if any. */
|
||||
function literalsOf(p: Predicate): string[] {
|
||||
const out: string[] = [];
|
||||
for (const dt of p.dataTypes ?? []) {
|
||||
for (const lit of dt.literals ?? []) if (typeof lit === "string") out.push(lit);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Does `subject` in `graph` satisfy every predicate of the shape? If so, the object. */
|
||||
function project(
|
||||
quads: OrmQuad[],
|
||||
graph: string,
|
||||
subject: string,
|
||||
predicates: Predicate[],
|
||||
): BaseType | null {
|
||||
const own = quads.filter((q) => q.graph === graph && q.subject === subject);
|
||||
const out: Record<string, unknown> = { "@id": subject, "@graph": graph };
|
||||
for (const p of predicates) {
|
||||
const wanted = literalsOf(p);
|
||||
const values = own
|
||||
.filter((q) => q.predicate === p.iri)
|
||||
.map((q) => q.object)
|
||||
.filter((o) => wanted.length === 0 || wanted.includes(o));
|
||||
if (values.length === 0) return null; // a required triple pattern with no match
|
||||
out[p.readablePredicate] = values.length === 1 ? values[0] : values;
|
||||
}
|
||||
return out as BaseType;
|
||||
}
|
||||
|
||||
/** The objects the engine would return for one shape in one scope. */
|
||||
function answerFor(quads: OrmQuad[], shape: ShapeType<BaseType>, scope: Scope): BaseType[] {
|
||||
const graphs = (typeof scope.graphs === "string" ? [scope.graphs] : (scope.graphs ?? [])).map(
|
||||
(g) => (g === "" ? ENTIRE_DATASET : g),
|
||||
);
|
||||
if (graphs.length === 0) return []; // upstream: no objects are returned
|
||||
const everywhere = graphs.includes(ENTIRE_DATASET);
|
||||
const subjects = scope.subjects ?? [];
|
||||
const predicates = shape.schema[shape.shape]?.predicates ?? [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: BaseType[] = [];
|
||||
for (const q of quads) {
|
||||
if (!everywhere && !graphs.includes(q.graph)) continue;
|
||||
if (subjects.length > 0 && !subjects.includes(q.subject)) continue;
|
||||
const key = q.graph + "/" + q.subject;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const obj = project(quads, q.graph, q.subject, predicates);
|
||||
if (obj !== null) out.push(obj);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake shape query over an in-memory dataset.
|
||||
*
|
||||
* `autoDeliver` (the default) hands every query its initial payload on the next tick,
|
||||
* which is the convenient mode for suites that are not about readiness. Turn it off and
|
||||
* call {@link FakeOrm.deliver} to hold the pending state open.
|
||||
*/
|
||||
export function makeOrm(
|
||||
options: { autoDeliver?: boolean; source?: () => OrmQuad[] } = {},
|
||||
): FakeOrm {
|
||||
const quads: OrmQuad[] = [];
|
||||
/** Where the engine reads from - its own dataset, or one a suite already holds (the
|
||||
* fake wallet's, so the objects read back are the ones the surface really wrote). */
|
||||
const dataset = options.source ?? (() => quads);
|
||||
const open = new Set<OpenQuery>();
|
||||
const asked: Array<{ shape: string; scope: Scope }> = [];
|
||||
const autoDeliver = options.autoDeliver !== false;
|
||||
let refusal: string | null = null;
|
||||
|
||||
function recompute(q: OpenQuery): void {
|
||||
if (!q.delivered || q.closed) return;
|
||||
q.items = answerFor(dataset(), q.shape, q.scope);
|
||||
for (const l of q.listeners) l({ adds: q.items, updates: [], removes: [] });
|
||||
}
|
||||
|
||||
function deliverOne(q: OpenQuery): void {
|
||||
if (q.delivered || q.closed) return;
|
||||
q.delivered = true;
|
||||
q.items = answerFor(dataset(), q.shape, q.scope);
|
||||
q.resolveReady();
|
||||
for (const l of q.listeners) l({ adds: q.items, updates: [], removes: [] });
|
||||
}
|
||||
|
||||
const query = (<T extends BaseType>(
|
||||
shapeType: ShapeType<T>,
|
||||
scope: Scope,
|
||||
): OrmSubscriptionHandle => {
|
||||
if (refusal !== null) throw new Error(refusal);
|
||||
const shape = shapeType as unknown as ShapeType<BaseType>;
|
||||
asked.push({ shape: shape.shape, scope });
|
||||
let resolveReady!: () => void;
|
||||
const readyPromise = new Promise<void>((r) => (resolveReady = r));
|
||||
const record: OpenQuery = {
|
||||
shape,
|
||||
scope,
|
||||
listeners: new Set(),
|
||||
items: [],
|
||||
delivered: false,
|
||||
closed: false,
|
||||
resolveReady,
|
||||
};
|
||||
open.add(record);
|
||||
if (autoDeliver) setTimeout(() => deliverOne(record), 0);
|
||||
return {
|
||||
readyPromise,
|
||||
get signalObject(): Iterable<unknown> {
|
||||
return record.items;
|
||||
},
|
||||
addChangeListener(listener: Listener): void {
|
||||
record.listeners.add(listener);
|
||||
},
|
||||
removeChangeListener(listener: Listener): void {
|
||||
record.listeners.delete(listener);
|
||||
},
|
||||
close(): void {
|
||||
record.closed = true;
|
||||
record.listeners.clear();
|
||||
open.delete(record);
|
||||
},
|
||||
};
|
||||
}) as OrmSubscriptionLike;
|
||||
|
||||
return {
|
||||
query,
|
||||
seed(graph, subject, predicate, object): void {
|
||||
quads.push({ graph, subject, predicate, object });
|
||||
for (const q of open) recompute(q);
|
||||
},
|
||||
deliver(): void {
|
||||
for (const q of [...open]) deliverOne(q);
|
||||
},
|
||||
refuse(message): void {
|
||||
refusal = message;
|
||||
},
|
||||
answer(): void {
|
||||
refusal = null;
|
||||
},
|
||||
asked,
|
||||
openCount: () => open.size,
|
||||
};
|
||||
}
|
||||
@@ -1,113 +1,112 @@
|
||||
/**
|
||||
* `useShapeQuery` says "there is nothing" or "I could not find out" — never the first for the second.
|
||||
* `useShapeQuery` says "there is nothing" or "I could not find out" - never the first for the second.
|
||||
*
|
||||
* The whole reason this surface exists is one distinction: an application must be able to
|
||||
* tell a scope that is EMPTY from a scope it has not heard back about. `useShape` upstream
|
||||
* cannot — it returns "an empty set, if still loading" — and `useShapeQuery` publishes
|
||||
* cannot - it returns "an empty set, if still loading" - and `useShapeQuery` publishes
|
||||
* `isPending` / `isSuccess` / `isError` precisely to answer the question three ways.
|
||||
*
|
||||
* Until 2026-08-17 it destroyed that distinction on the one input that mattered. Step 1 of
|
||||
* its pipeline asks `listMyEntityDocs` which documents are mine; the failure
|
||||
* was caught, logged, and the empty set flowed on. A barrier over zero documents is
|
||||
* trivially reached, so the surface published `{ data: [], isPending: false, isSuccess:
|
||||
* true }` — the exact snapshot a genuinely empty scope produces, byte for byte. An
|
||||
* application rendered "you have created nothing" for "the store did not answer", which is
|
||||
* the outage this library already shipped twice (`8c8ade7`, `f6d1734`) and closed one layer
|
||||
* down the day before (`90712e0`, which made that very call throw). Catching it here put it
|
||||
* straight back one floor up.
|
||||
* -- What changed under it in `1.0.0-dev.6`, and what did not ---------------
|
||||
* Until then the read was this package's own pipeline, and the failure it could not be
|
||||
* allowed to mistake was its first step: `listMyEntityDocs` not answering, caught, logged,
|
||||
* and the empty set flowing on. A barrier over zero documents is trivially reached, so the
|
||||
* surface published `{ data: [], isPending: false, isSuccess: true }` - the exact snapshot
|
||||
* a genuinely empty scope produces, byte for byte. An application rendered "you have
|
||||
* created nothing" for "the store did not answer", which is the outage this library shipped
|
||||
* twice (`8c8ade7`, `f6d1734`).
|
||||
*
|
||||
* ── The two questions, and why the second needs its own test ──────────────
|
||||
* The read now goes through the SDK's shape query, and that step is gone with the pipeline.
|
||||
* The DISTINCTION is not: it is the thing under test here, and the two ways it can now be
|
||||
* broken are both below. The shape query REFUSES TO OPEN - it throws at the call - and the
|
||||
* surface must say `isError`. The shape query opens and the engine never answers - upstream
|
||||
* catches that failure and logs it, so readiness simply never settles - and the surface
|
||||
* must stay `isPending` for ever rather than fall through to the empty answer.
|
||||
*
|
||||
* -- The two questions, and why the second needs its own test --------------
|
||||
* A one-shot call rejects and is done. An observable has already handed a list to a
|
||||
* subscriber that RENDERED it, so a failure that arrives afterwards has to say something
|
||||
* about that list — and `data: []` would be the empty answer again, in the one field an
|
||||
* about that list - and `data: []` would be the empty answer again, in the one field an
|
||||
* application actually paints. So there are two cases here, not one: the failure BEFORE any
|
||||
* answer, and the failure AFTER one.
|
||||
*
|
||||
* ── The fault is the broker's, and it is one a real one produces ──────────
|
||||
* Nothing here plants a state or reaches in to make a library function throw. The documents
|
||||
* were CREATED through the surface, over the same wallet, in the session before; then the
|
||||
* connection dies — the broker answers normally and stops answering, and every query after
|
||||
* that rejects the way the wasm binding rejects a transport error. Same montage as
|
||||
* `listing-surfaces-failure.test.ts`, one layer up.
|
||||
* -- The fault is the SDK's, and it is one the real one produces -----------
|
||||
* Nothing here plants a state or reaches into the library to make one of its functions
|
||||
* throw. The documents were CREATED through the surface, over the same wallet, in the
|
||||
* session before; then the injected shape query stops opening, which is what the real
|
||||
* `getOrCreate` does when it cannot subscribe the shape it was handed, and what this
|
||||
* package itself does when `configure` was never given one.
|
||||
*/
|
||||
|
||||
import { test, expect, describe, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
|
||||
import { configure, useShapeQuery, storeRegistry } from "../src/index";
|
||||
import type { ShapeObservable, ShapeQuery } from "../src/surface/use-shape-query";
|
||||
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { sparqlUpdate } from "../src/surface/docs";
|
||||
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
|
||||
import type { NgLike, StoreType, UseShapeLike } from "../src/model/types";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
import { makeOrm, type FakeOrm, type OrmQuad } from "./orm-fake";
|
||||
import { SESSION, forgetEverything, makeWallet, signIn, storeOfType, type Quad } from "./wallet-fake";
|
||||
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
/** The Main-branch read of a store — "which documents are in here". Step 1 of the pipeline. */
|
||||
const LISTING_READ = `<${SHIM}:contains>`;
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
const EVENT = "http://festipod.org/Event";
|
||||
const TITLE = "http://festipod.org/title";
|
||||
|
||||
/** A minimal SHEX ShapeType pinning `rdf:type` to {@link EVENT}. */
|
||||
/** A minimal SHEX ShapeType pinning `rdf:type` to {@link EVENT} and requiring a title. */
|
||||
const EventShape = {
|
||||
shape: "http://festipod.org/EventShape",
|
||||
schema: {
|
||||
"http://festipod.org/EventShape": {
|
||||
iri: "http://festipod.org/EventShape",
|
||||
predicates: [{ iri: RDF_TYPE, dataTypes: [{ literals: [EVENT], valType: "iri" }] }],
|
||||
predicates: [
|
||||
{
|
||||
iri: RDF_TYPE,
|
||||
readablePredicate: "@type",
|
||||
dataTypes: [{ literals: [EVENT], valType: "iri" }],
|
||||
minCardinality: 1,
|
||||
maxCardinality: 1,
|
||||
},
|
||||
{
|
||||
iri: TITLE,
|
||||
readablePredicate: "title",
|
||||
dataTypes: [{ valType: "string" }],
|
||||
minCardinality: 1,
|
||||
maxCardinality: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
} satisfies ShapeType<BaseType>;
|
||||
|
||||
/**
|
||||
* Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query
|
||||
* `lostAt` accepts — that one included, and every one after it, for the rest of the page.
|
||||
* Wire the library onto `quads`, with the shape query answering over THE SAME wallet.
|
||||
*
|
||||
* A dropped connection is not selective, so neither is this: the switch decides *when* the
|
||||
* link dies, never *which* call is unlucky. Passing `() => false` is a broker that stays up.
|
||||
*
|
||||
* `doc_subscribe` is carried here rather than left off (`wallet-fake` has none, and the
|
||||
* library then takes its documented no-op open path): with no subscription there is no
|
||||
* `State`, so the sync BARRIER would be skipped instead of crossed, and `isPending` would
|
||||
* fall away for a reason the real platform never gives. It pushes what the platform pushes
|
||||
* — `TabInfo` then `State`, in that order — and a link that is down pushes NOTHING, which
|
||||
* is what a link that is down does.
|
||||
* That shared dataset is the point: the objects the read hands back are the ones the
|
||||
* surface really wrote, in the session before, rather than a list this file composed.
|
||||
*/
|
||||
function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void {
|
||||
function bootPage(quads: Quad[]): FakeOrm {
|
||||
const wallet = makeWallet(quads);
|
||||
let lost = false;
|
||||
const ng = {
|
||||
doc_create: wallet.doc_create,
|
||||
sparql_update: wallet.sparql_update,
|
||||
sparql_query: mock(async (...a: unknown[]) => {
|
||||
if (lost || lostAt(a[1] as string)) {
|
||||
lost = true;
|
||||
throw new Error("BrokerError: connection lost");
|
||||
}
|
||||
return wallet.sparql_query(...a);
|
||||
}),
|
||||
doc_subscribe: mock(async (_nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
|
||||
if (lost) throw new Error("BrokerError: connection lost");
|
||||
setTimeout(() => {
|
||||
if (lost) return;
|
||||
cb({ V0: { TabInfo: {} } });
|
||||
cb({ V0: { State: {} } });
|
||||
}, 0);
|
||||
return () => {};
|
||||
}),
|
||||
};
|
||||
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
|
||||
const asOrmQuads = (): OrmQuad[] =>
|
||||
quads.map((q) => ({ graph: q.g, subject: q.s, predicate: q.p, object: q.o }));
|
||||
const orm = makeOrm({ source: asOrmQuads });
|
||||
configure({
|
||||
ng: wallet as unknown as NgLike,
|
||||
useShape: (() => {}) as unknown as UseShapeLike,
|
||||
ormSubscription: orm.query,
|
||||
});
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
||||
return orm;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session before: alice signs in and creates one document carrying one Event, over a
|
||||
* broker that works. Returns the quads the wallet now holds — the durable half that
|
||||
* broker that works. Returns the quads the wallet now holds - the durable half that
|
||||
* outlives the page.
|
||||
*/
|
||||
async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad[]; note: Nuri }> {
|
||||
const quads: Quad[] = [];
|
||||
forgetEverything();
|
||||
bootPage(quads, () => false);
|
||||
bootPage(quads);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
|
||||
await sparqlUpdate(
|
||||
@@ -118,17 +117,17 @@ async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad
|
||||
return { quads, note };
|
||||
}
|
||||
|
||||
/** A first session where alice signs in and creates NOTHING — a genuinely empty scope. */
|
||||
/** A first session where alice signs in and creates NOTHING - a genuinely empty scope. */
|
||||
async function aFirstSessionThatCreatedNothing(): Promise<Quad[]> {
|
||||
const quads: Quad[] = [];
|
||||
forgetEverything();
|
||||
bootPage(quads, () => false);
|
||||
bootPage(quads);
|
||||
await signIn("alice");
|
||||
return quads;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the APPLICATION reads off a snapshot — the whole point of the surface, written the
|
||||
* What the APPLICATION reads off a snapshot - the whole point of the surface, written the
|
||||
* way a view would branch on it. Four readings, and the two this file is about are
|
||||
* `"nothing"` and `"unknown"`: they must never be the same one.
|
||||
*/
|
||||
@@ -160,10 +159,15 @@ async function subscribeAndSettle(
|
||||
return { renders: () => renders, stop };
|
||||
}
|
||||
|
||||
/** The titles a snapshot carries - what a view would render. */
|
||||
function titles(snap: ShapeQuery): string[] {
|
||||
return snap.data.map((d) => String(d.title)).sort();
|
||||
}
|
||||
|
||||
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
|
||||
|
||||
/** The whole local dataset - upstream's own marker, and the reading that still has to
|
||||
* enumerate this identity's documents, which is the step these tests make fail. */
|
||||
/** The whole local dataset - upstream's own marker, and the one reading an application
|
||||
* reaches for when it wants everything it holds. */
|
||||
const EVERYTHING = { graphs: "did:ng:i" };
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -171,7 +175,7 @@ beforeEach(() => {
|
||||
});
|
||||
afterAll(() => {
|
||||
// The caps registry, the config and the registry caches are PROCESS-WIDE, and `bun test`
|
||||
// runs every file in ONE process — so this teardown is not housekeeping. Caps left behind
|
||||
// runs every file in ONE process - so this teardown is not housekeeping. Caps left behind
|
||||
// put the possession gate in force for a suite that declares none, and `subscribe.test.ts`
|
||||
// is then refused at the door for documents it legitimately owns; a config left behind
|
||||
// hands `access-log.test.ts` this file's `ng`. Both failed exactly that way before this
|
||||
@@ -182,14 +186,14 @@ afterAll(() => {
|
||||
describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", () => {
|
||||
// The NORMAL cases first, and they are not a formality: a surface that reported `isError`
|
||||
// unconditionally would pass every failure case below. These two are what those are a
|
||||
// departure from — and the first of them is the reading that must never be reused for a
|
||||
// departure from - and the first of them is the reading that must never be reused for a
|
||||
// failure, so it has to be pinned before anything can be said to differ from it.
|
||||
for (const scope of ALL_SCOPES) {
|
||||
test(`[${scope}] a scope this account never wrote to reads as NOTHING`, async () => {
|
||||
const quads = await aFirstSessionThatCreatedNothing();
|
||||
|
||||
forgetEverything();
|
||||
bootPage(quads, () => false);
|
||||
bootPage(quads);
|
||||
await signIn("alice");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
@@ -209,7 +213,7 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
|
||||
const { quads } = await aFirstSessionThatCreated(scope);
|
||||
|
||||
forgetEverything();
|
||||
bootPage(quads, () => false);
|
||||
bootPage(quads);
|
||||
await signIn("alice");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
@@ -217,24 +221,24 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(whatTheAppSees(snap)).toBe("some");
|
||||
expect(snap.data.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
|
||||
expect(titles(snap)).toEqual(["the note"]);
|
||||
stop();
|
||||
});
|
||||
}
|
||||
|
||||
// The defect. Alice HAS a document; the store cannot be read; the surface used to answer
|
||||
// with the empty-scope snapshot above — same `data`, same `isSuccess`, nothing to tell
|
||||
// them apart. It is the more dangerous of the two failure values, because "you created
|
||||
// nothing" is a sentence an application renders without hesitating.
|
||||
// The defect, in the form the new read path can take it. Alice HAS a document; the shape
|
||||
// query cannot be opened; the surface must not answer with the empty-scope snapshot
|
||||
// above - same `data`, same `isSuccess`, nothing to tell them apart. It is the more
|
||||
// dangerous of the two failure values, because "you created nothing" is a sentence an
|
||||
// application renders without hesitating.
|
||||
for (const scope of ALL_SCOPES) {
|
||||
test(`[${scope}] a store that does not answer reads as UNKNOWN, not as nothing`, async () => {
|
||||
test(`[${scope}] a shape query that cannot be opened reads as UNKNOWN, not as nothing`, async () => {
|
||||
const { quads } = await aFirstSessionThatCreated(scope);
|
||||
|
||||
let armed = false;
|
||||
forgetEverything();
|
||||
bootPage(quads, (query) => armed && query.includes(LISTING_READ));
|
||||
const orm = bootPage(quads);
|
||||
await signIn("alice");
|
||||
armed = true;
|
||||
orm.refuse("OrmError: the shape could not be subscribed");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
const { stop } = await subscribeAndSettle(obs);
|
||||
@@ -244,7 +248,7 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
|
||||
expect(snap.isError).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(String((snap.error as Error)?.message)).toMatch(/BrokerError/);
|
||||
expect(String((snap.error as Error)?.message)).toMatch(/OrmError/);
|
||||
// Said as the difference itself, since one reading standing in for the other IS the
|
||||
// defect: this is not the snapshot the empty scope publishes.
|
||||
expect(whatTheAppSees(snap)).not.toBe(whatTheAppSees({
|
||||
@@ -254,17 +258,58 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
|
||||
});
|
||||
}
|
||||
|
||||
// The other way the read can fail now, and the one upstream actually produces: the query
|
||||
// OPENS and the engine never answers. `orm_start_graph` failing is caught and logged
|
||||
// there, and the readiness promise is built with `resolve` alone, so nothing settles it.
|
||||
// What matters here is the reading an application gets: "still asking", for ever if need
|
||||
// be - and never "there is nothing", which is the sentence it would render.
|
||||
for (const scope of ALL_SCOPES) {
|
||||
test(`[${scope}] a shape query that never answers stays STILL-ASKING, not nothing`, async () => {
|
||||
const { quads } = await aFirstSessionThatCreated(scope);
|
||||
|
||||
forgetEverything();
|
||||
const wallet = makeWallet(quads);
|
||||
const orm = makeOrm({
|
||||
autoDeliver: false,
|
||||
source: () => quads.map((q) => ({ graph: q.g, subject: q.s, predicate: q.p, object: q.o })),
|
||||
});
|
||||
configure({
|
||||
ng: wallet as unknown as NgLike,
|
||||
useShape: (() => {}) as unknown as UseShapeLike,
|
||||
ormSubscription: orm.query,
|
||||
});
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
||||
await signIn("alice");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
const stop = obs.subscribe(() => {});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(whatTheAppSees(snap)).toBe("still-asking");
|
||||
expect(snap.isPending).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(snap.data).toEqual([]);
|
||||
|
||||
// And the moment the engine does answer, it is the document that comes back - so the
|
||||
// pending state was a wait, not a dead end.
|
||||
orm.deliver();
|
||||
await until("the answer", () => obs.getSnapshot().isSuccess);
|
||||
expect(titles(obs.getSnapshot())).toEqual(["the note"]);
|
||||
stop();
|
||||
});
|
||||
}
|
||||
|
||||
// The half a one-shot call never has to answer: the subscriber has ALREADY rendered a
|
||||
// list when the link dies. The observable cannot un-emit it, so the failure snapshot must
|
||||
// not put `[]` in the field the view paints — that would be the empty answer again,
|
||||
// arriving later and to a screen that has something on it.
|
||||
// list when the read stops working. The observable cannot un-emit it, so the failure
|
||||
// snapshot must not put `[]` in the field the view paints - that would be the empty
|
||||
// answer again, arriving later and to a screen that has something on it.
|
||||
for (const scope of ALL_SCOPES) {
|
||||
test(`[${scope}] a list already rendered is not replaced by an empty one`, async () => {
|
||||
const { quads } = await aFirstSessionThatCreated(scope);
|
||||
|
||||
let armed = false;
|
||||
forgetEverything();
|
||||
bootPage(quads, (query) => armed && query.includes(LISTING_READ));
|
||||
const orm = bootPage(quads);
|
||||
await signIn("alice");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
@@ -272,17 +317,17 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
|
||||
expect(whatTheAppSees(obs.getSnapshot())).toBe("some");
|
||||
const rendersBefore = renders();
|
||||
|
||||
// The link dies, and the application asks again — the imperative refresh the surface
|
||||
// publishes for exactly this.
|
||||
armed = true;
|
||||
// The shape query stops opening, and the application asks again - the imperative
|
||||
// refresh the surface publishes for exactly this.
|
||||
orm.refuse("OrmError: the shape could not be subscribed");
|
||||
obs.refetch();
|
||||
await until("the failure to reach the subscriber", () => obs.getSnapshot().isError);
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(whatTheAppSees(snap)).toBe("unknown");
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
// The list it had is still there — a memory, never offered as a fresh answer.
|
||||
expect(snap.data.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
|
||||
// The list it had is still there - a memory, never offered as a fresh answer.
|
||||
expect(titles(snap)).toEqual(["the note"]);
|
||||
// And the subscriber was TOLD: a view that renders `data` and never re-reads the
|
||||
// load state would otherwise keep showing the list as though it were current.
|
||||
expect(renders()).toBeGreaterThan(rendersBefore);
|
||||
|
||||
@@ -1,38 +1,36 @@
|
||||
/**
|
||||
* use-shape-query.test.ts — behavioural tests for `useShapeQuery`
|
||||
* (src/surface/use-shape-query.ts),
|
||||
* against a STATEFUL fake `ng` with a CONTROLLABLE `doc_subscribe`.
|
||||
* use-shape-query.test.ts - behavioural tests for `useShapeQuery`
|
||||
* (src/surface/use-shape-query.ts), against the SDK's shape query stood in for by
|
||||
* `orm-fake.ts` and a stateful fake `ng` for everything else the page does.
|
||||
*
|
||||
* The fake emulates just enough of the broker:
|
||||
* - `doc_create` mints monotonic doc NURIs.
|
||||
* - `sparql_update` parses the shim account writes + the per-entity index
|
||||
* `contains` append + arbitrary anchored triple writes into an in-memory quad
|
||||
* store (same tolerant parser shape as store-registry.test / read-model.test).
|
||||
* - `sparql_query` answers the shim account SELECT, the scope-index `contains`
|
||||
* SELECT, and the anchored per-doc `?s ?p ?o` read (readUnion) — each scoped to
|
||||
* the anchor graph.
|
||||
* - `doc_subscribe` models the platform push order TabInfo→State: on subscribe it
|
||||
* records the callback and fires a `TabInfo` immediately, but the sync BARRIER
|
||||
* `State` is fired only when the TEST releases it (`fireState`) — so we can
|
||||
* assert isPending BEFORE the barrier and isSuccess AFTER. A later write to a
|
||||
* subscribed doc fires a `Patch` push (reactivity).
|
||||
* -- The montage, and why it takes TWO fakes -------------------------------
|
||||
* The read goes through the SDK now, so the thing that answers it is the injected
|
||||
* shape query (`test/orm-fake.ts`, a small engine that honours the scope axes the way
|
||||
* `schema_shape_to_sparql` does). Everything AROUND the read is still this package's:
|
||||
* signing in, creating a document, filing its capability. That is the fake `ng` - and
|
||||
* keeping it is what makes the documents here real ones, obtained from
|
||||
* `createEntityDoc` rather than named by the test.
|
||||
*
|
||||
* These prove the four distinctions the surface exists for:
|
||||
* (a) isPending at first, isSuccess after the first State (barrier);
|
||||
* (b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction);
|
||||
* (c) a write then push → data updates (reactivity, no polling);
|
||||
* (d) timed-out → isSuccess (best-effort), NOT isError;
|
||||
* plus the three readings of the SDK `Scope` the surface now takes: the whole dataset,
|
||||
* a named `graphs` list, and the empty list that upstream answers with no objects.
|
||||
* These prove the distinctions the surface exists for:
|
||||
* (a) isPending until the initial payload arrives, isSuccess once it has;
|
||||
* (b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction);
|
||||
* (c) an engine patch updates data (reactivity, no polling);
|
||||
* (d) a query that never delivers stays isPending - it never becomes "nothing";
|
||||
* plus the three readings of the SDK `Scope`, which this module no longer interprets
|
||||
* and therefore has to hand over VERBATIM, and the capability filter over what comes
|
||||
* back.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
||||
import { useShapeQuery } from "../src/surface/use-shape-query";
|
||||
import { configure } from "../src/index";
|
||||
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||
import { configureStoreRegistry, setCurrentUser, getCaps } from "../src/shared-wallet/bootstrap";
|
||||
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
|
||||
import { resetOpenedRepos, setOpenTimeoutForTests, getSyncState } from "../src/emulated-verifier/open-repo";
|
||||
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
||||
import { makeOrm, type FakeOrm } from "./orm-fake";
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
import type { NgLike, UseShapeLike } from "../src/model/types";
|
||||
|
||||
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
|
||||
@@ -57,26 +55,13 @@ function unescapeLiteral(s: string): string {
|
||||
interface SubRec { nuri: string; cb: (r: unknown) => void }
|
||||
|
||||
/**
|
||||
* The stateful fake with a controllable doc_subscribe. `holdState: true` means a
|
||||
* fresh subscription does NOT auto-fire its `State` — the test fires it via
|
||||
* `fireState(nuri)`. `holdState: false` (default) auto-fires `State` on subscribe
|
||||
* (synced immediately), which is the convenient mode for the reactivity/empty cases.
|
||||
* The stateful fake `ng` - the shim account, the per-scope index and the document
|
||||
* creation the page does around the read. It is NOT what answers the read any more.
|
||||
*/
|
||||
function makeFake(opts?: { holdState?: boolean }) {
|
||||
function makeFake() {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
const subs: SubRec[] = [];
|
||||
const hold = opts?.holdState ?? false;
|
||||
// Nuris whose barrier `State` has been released (auto-fire on future subscribe).
|
||||
const released = new Set<string>();
|
||||
// The shim ANCHOR (private-store-root) is ALWAYS loaded/synced on the real broker
|
||||
// (the store repo is bootstrapped at connect), so its barrier `State` is always
|
||||
// available. `resolveAccount`/`ensureAccount` now open it (the cold-start heal)
|
||||
// before touching the shim — pre-release it here so `holdState` (which gates the
|
||||
// per-ENTITY docs the tests control) never blocks the anchor open. This mirrors the
|
||||
// real invariant the production heal relies on.
|
||||
released.add(`did:ng:${SESSION.privateStoreId}`);
|
||||
let releaseEverything = false;
|
||||
|
||||
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||
|
||||
@@ -104,17 +89,7 @@ function makeFake(opts?: { holdState?: boolean }) {
|
||||
const p = m[1] ?? "urn:ng-eventually:shim:Account";
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g, s, p, o });
|
||||
// The doc-shim (named by the write-once pointer triple) is INFRASTRUCTURE, like
|
||||
// the store-root: `doc_create` bootstrapped it into the session, so its barrier
|
||||
// `State` is immediately available. Pre-release it so `holdState` (which gates the
|
||||
// per-ENTITY docs the tests control) never blocks the doc-shim open. The pointer is
|
||||
// published BEFORE the doc-shim barrier open (resolveShimDoc first-login order).
|
||||
if (p === "urn:ng-eventually:shim:shimDoc") {
|
||||
released.add(o);
|
||||
for (const sub of subs) if (sub.nuri === o) sub.cb({ V0: { State: {} } });
|
||||
}
|
||||
}
|
||||
// A write to a subscribed doc fires a Patch push (reactivity signal).
|
||||
for (const sub of subs) {
|
||||
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
|
||||
}
|
||||
@@ -132,9 +107,7 @@ function makeFake(opts?: { holdState?: boolean }) {
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||
const subjM = query.match(
|
||||
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
|
||||
);
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
@@ -157,70 +130,37 @@ function makeFake(opts?: { holdState?: boolean }) {
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:inboxCap>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:inboxCap")
|
||||
.map((q) => ({ c: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
for (const marker of ["inboxCap", "readCap", "contains"] as const) {
|
||||
if (query.includes(`<urn:ng-eventually:shim:${marker}>`)) {
|
||||
const key = marker === "contains" ? "e" : "c";
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `urn:ng-eventually:shim:${marker}`)
|
||||
.map((q) => ({ [key]: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:readCap>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:readCap")
|
||||
.map((q) => ({ c: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
if (query.includes("<urn:ng-eventually:shim:contains>")) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
||||
.map((q) => ({ e: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`).
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor)
|
||||
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
});
|
||||
|
||||
const doc_subscribe = mock(
|
||||
async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
||||
subs.push({ nuri, cb });
|
||||
// Platform pushes TabInfo FIRST (never the barrier).
|
||||
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
|
||||
// Fire the barrier State if this fake auto-syncs, or if this nuri was already
|
||||
// released (so a doc subscribed AFTER a release still crosses the barrier).
|
||||
if (!hold || releaseEverything || released.has(nuri)) {
|
||||
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
||||
subs.push({ nuri, cb });
|
||||
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
|
||||
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
/** Release the barrier for `nuri` (fire State now + auto-fire for future subs). */
|
||||
function fireState(nuri: string): void {
|
||||
released.add(nuri);
|
||||
for (const sub of subs) if (sub.nuri === nuri) sub.cb({ V0: { State: {} } });
|
||||
}
|
||||
/** Release the barrier for EVERY doc, present and future. */
|
||||
function releaseAll(): void {
|
||||
releaseEverything = true;
|
||||
for (const sub of subs) sub.cb({ V0: { State: {} } });
|
||||
}
|
||||
|
||||
return {
|
||||
doc_create,
|
||||
sparql_update,
|
||||
sparql_query,
|
||||
doc_subscribe,
|
||||
_quads: quads,
|
||||
fireState,
|
||||
releaseAll,
|
||||
subs,
|
||||
};
|
||||
return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads };
|
||||
}
|
||||
|
||||
function inject(ng: ReturnType<typeof makeFake>) {
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
function inject(ng: ReturnType<typeof makeFake>, orm: FakeOrm) {
|
||||
configure({
|
||||
ng: ng as unknown as NgLike,
|
||||
useShape: (() => {}) as unknown as UseShapeLike,
|
||||
ormSubscription: orm.query,
|
||||
});
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
@@ -230,36 +170,55 @@ function inject(ng: ReturnType<typeof makeFake>) {
|
||||
resetCaps();
|
||||
}
|
||||
|
||||
// Insert a triple straight into a doc's graph in the fake store (no push).
|
||||
function seed(ng: ReturnType<typeof makeFake>, doc: string, p: string, o: string): void {
|
||||
ng._quads.push({ g: doc, s: doc, p, o });
|
||||
}
|
||||
|
||||
// The same, under a subject of its own rather than the document's reference: one document
|
||||
// may hold several subjects, which is what the scope's `subjects` axis filters on.
|
||||
function seedSubject(
|
||||
ng: ReturnType<typeof makeFake>,
|
||||
doc: string,
|
||||
subject: string,
|
||||
p: string,
|
||||
o: string,
|
||||
): void {
|
||||
ng._quads.push({ g: doc, s: subject, p, o });
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
// A minimal SHEX ShapeType pinning rdf:type to `${FP}Event`.
|
||||
/**
|
||||
* A minimal SHEX ShapeType pinning `rdf:type` to `${FP}Event` and carrying a title.
|
||||
*
|
||||
* Both predicates are `minCardinality: 1`, because that is what
|
||||
* `schema_shape_to_sparql` turns into REQUIRED triple patterns: a shape with none of
|
||||
* them would match every subject in the dataset. `rdf-orm build` generates exactly this
|
||||
* object from a `.shex` file, and the generator is not what is under test here.
|
||||
*/
|
||||
const EventShape = {
|
||||
shape: `${FP}EventShape`,
|
||||
schema: {
|
||||
[`${FP}EventShape`]: {
|
||||
iri: `${FP}EventShape`,
|
||||
predicates: [{ iri: TYPE, dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }] }],
|
||||
predicates: [
|
||||
{
|
||||
iri: TYPE,
|
||||
readablePredicate: "@type",
|
||||
dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }],
|
||||
minCardinality: 1,
|
||||
maxCardinality: 1,
|
||||
},
|
||||
{
|
||||
iri: `${FP}title`,
|
||||
readablePredicate: "title",
|
||||
dataTypes: [{ valType: "string" }],
|
||||
minCardinality: 1,
|
||||
maxCardinality: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
} satisfies ShapeType<BaseType>;
|
||||
|
||||
/** The titles a snapshot carries, sorted - what a view would render. */
|
||||
function titles(data: BaseType[]): string[] {
|
||||
return data.map((d) => String(d.title)).sort();
|
||||
}
|
||||
|
||||
/** Seed one shape-matching object into a document's graph. */
|
||||
function seedEvent(orm: FakeOrm, doc: string, subject: string, title: string): void {
|
||||
orm.seed(doc, subject, TYPE, `${FP}Event`);
|
||||
orm.seed(doc, subject, `${FP}title`, title);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetRegistryCache();
|
||||
});
|
||||
afterEach(() => {
|
||||
setCurrentUser(null);
|
||||
});
|
||||
@@ -274,14 +233,13 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe("useShapeQuery", () => {
|
||||
it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => {
|
||||
const ng = makeFake({ holdState: true });
|
||||
inject(ng);
|
||||
it("(a) isPending until the initial payload arrives, then isSuccess with the data", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm({ autoDeliver: false });
|
||||
inject(ng, orm);
|
||||
setCurrentUser("alice");
|
||||
// One protected entity doc for alice, carrying an Event triple.
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "Alpha");
|
||||
seedEvent(orm, doc, `${doc}:q:one`, "Alpha");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
let notes = 0;
|
||||
@@ -289,33 +247,30 @@ describe("useShapeQuery", () => {
|
||||
notes += 1;
|
||||
});
|
||||
|
||||
// Before the barrier: pending, no data.
|
||||
// Before the engine answers: pending, no data.
|
||||
await tick();
|
||||
expect(obs.getSnapshot().isPending).toBe(true);
|
||||
expect(obs.getSnapshot().isSuccess).toBe(false);
|
||||
expect(obs.getSnapshot().data).toEqual([]);
|
||||
|
||||
// Release the barrier for every opened doc (present + future) → synced.
|
||||
ng.releaseAll();
|
||||
await tick();
|
||||
await tick();
|
||||
orm.deliver();
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.data.length).toBe(1);
|
||||
expect(snap.data[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
|
||||
expect(titles(snap.data)).toEqual(["Alpha"]);
|
||||
expect(notes).toBeGreaterThan(0);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction)", async () => {
|
||||
const ng = makeFake(); // auto-fires State → synced immediately
|
||||
inject(ng);
|
||||
it("(b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction)", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("bob");
|
||||
// bob has NO entity docs in this scope — the scope is genuinely empty.
|
||||
// bob has no document of this shape anywhere - the scope is genuinely empty.
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
@@ -324,139 +279,160 @@ describe("useShapeQuery", () => {
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true); // synced, NOT stuck pending
|
||||
expect(snap.isSuccess).toBe(true); // answered, NOT stuck pending
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.data).toEqual([]); // empty — distinguishable from "still syncing"
|
||||
expect(snap.data).toEqual([]); // empty - distinguishable from "still asking"
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(c) a write then push updates data (reactivity, no polling)", async () => {
|
||||
const ng = makeFake(); // synced immediately
|
||||
inject(ng);
|
||||
it("(c) an engine patch updates data (reactivity, no polling)", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("carol");
|
||||
const doc = await createEntityDoc("carol", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "One");
|
||||
seedEvent(orm, doc, `${doc}:q:one`, "One");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
expect(obs.getSnapshot().data.length).toBe(1);
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["One"]);
|
||||
|
||||
// Write a SECOND event doc + fire the push via a write to the ALREADY-subscribed
|
||||
// doc. Because a new doc must appear in the set, write into the scope-INDEX
|
||||
// (createEntityDoc appends to it, and the index is subscribed → re-resolve).
|
||||
// A second object lands in a second document - the engine pushes it into the live
|
||||
// subscription, and the snapshot follows. No document subscription of ours, no timer.
|
||||
const doc2 = await createEntityDoc("carol", "protected");
|
||||
seed(ng, doc2, TYPE, `${FP}Event`);
|
||||
seed(ng, doc2, `${FP}title`, "Two");
|
||||
// createEntityDoc's index append fired a Patch on the index doc → re-resolve.
|
||||
await tick();
|
||||
seedEvent(orm, doc2, `${doc2}:q:one`, "Two");
|
||||
await tick();
|
||||
|
||||
const titles = obs
|
||||
.getSnapshot()
|
||||
.data.flatMap((s) => s.props[`${FP}title`] ?? [])
|
||||
.sort();
|
||||
expect(titles).toEqual(["One", "Two"]);
|
||||
// No setInterval anywhere — reactivity was push-driven.
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["One", "Two"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(d) timed-out → isSuccess (best-effort), NOT isError", async () => {
|
||||
// A doc whose subscription NEVER pushes a `State`: open-repo's bounded fallback
|
||||
// fires and marks the nuri "timed-out" (NOT "synced"). We shrink the fallback to
|
||||
// a few ms so this is fast, and assert the barrier is genuinely reached via
|
||||
// timed-out (getSyncState === "timed-out") and that the snapshot maps that to
|
||||
// isSuccess, never isError.
|
||||
const ng = makeFake({ holdState: true }); // State is never released
|
||||
inject(ng);
|
||||
setOpenTimeoutForTests(20); // fallback fires quickly instead of after 8s
|
||||
it("(d) a query that never delivers stays isPending - never 'there is nothing'", async () => {
|
||||
// The state the real ORM produces when the engine refuses a start: `orm_start_graph`
|
||||
// is called inside a `try { ... } catch (e) { console.error(e) }` and the readiness
|
||||
// promise is built with `resolve` alone, so nothing ever settles it. The surface must
|
||||
// keep saying "still asking" - the one thing it must never do is publish the
|
||||
// empty-and-answered snapshot, which is what an application renders as "you have
|
||||
// created nothing".
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm({ autoDeliver: false });
|
||||
inject(ng, orm);
|
||||
setCurrentUser("dave");
|
||||
const doc = await createEntityDoc("dave", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "Timed");
|
||||
seedEvent(orm, doc, `${doc}:q:one`, "Never arrives");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
// Before the fallback fires: still pending (subscribed, no State).
|
||||
expect(obs.getSnapshot().isPending).toBe(true);
|
||||
|
||||
// Let the bounded fallback elapse → open-repo marks each opened doc timed-out.
|
||||
// Long enough for FOUR of them in a row: the whole-dataset reading opens the three
|
||||
// store index documents as well as the entity document, and with the `State` held
|
||||
// back each one waits out the full fallback before the next begins.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await tick();
|
||||
await tick();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// The entity doc's barrier resolved via timed-out (never a State).
|
||||
expect(getSyncState(doc)).toBe("timed-out");
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isPending).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true); // timed-out is best-effort success
|
||||
expect(snap.isPending).toBe(false);
|
||||
// The data still read (best-effort): the doc's triples resolved.
|
||||
expect(snap.data.length).toBe(1);
|
||||
expect(snap.data).toEqual([]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// ── the scope axis: the three readings upstream gives it ──────────────────
|
||||
it("(e) opening the shape query threw -> isError, and never an empty answer", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("erin");
|
||||
orm.refuse("the shape could not be subscribed");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(String((snap.error as Error).message)).toMatch(/could not be subscribed/);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(f) `configure` without a shape query says so, rather than answering empty", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
// ...and then a bootstrap that forgot the injection. The read has nothing to go
|
||||
// through, and an empty set would be the one lie this surface exists to prevent.
|
||||
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
|
||||
configureStoreRegistry({
|
||||
getSession: async () => SESSION,
|
||||
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||
});
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(String((snap.error as Error).message)).toMatch(/ormSubscription/);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- the scope axis: the three readings upstream gives it ------------------
|
||||
//
|
||||
// These are not variants of one case. `graphs` names ONE document's graph per entry;
|
||||
// `did:ng:i` (or `""`) discards the rest and means the whole dataset; `[]` or
|
||||
// `undefined` returns no objects at all. The first two answer differently over the SAME
|
||||
// wallet, and the third is the one a caller reaches by accident - so each is pinned.
|
||||
// `undefined` returns no objects at all. This module no longer interprets any of it -
|
||||
// the scope is handed to the SDK verbatim - so each case pins BOTH that the object
|
||||
// arrived unchanged and what the engine then answers.
|
||||
|
||||
it("(e) a named `graphs` list reads those documents and no others", async () => {
|
||||
it("(g) a named `graphs` list reaches the SDK verbatim and reads those documents only", async () => {
|
||||
const ng = makeFake();
|
||||
inject(ng);
|
||||
setCurrentUser("erin");
|
||||
const kept = await createEntityDoc("erin", "protected");
|
||||
seed(ng, kept, TYPE, `${FP}Event`);
|
||||
seed(ng, kept, `${FP}title`, "Named");
|
||||
const other = await createEntityDoc("erin", "protected");
|
||||
seed(ng, other, TYPE, `${FP}Event`);
|
||||
seed(ng, other, `${FP}title`, "Unnamed");
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("frank");
|
||||
const kept = await createEntityDoc("frank", "protected");
|
||||
seedEvent(orm, kept, `${kept}:q:one`, "Named");
|
||||
const other = await createEntityDoc("frank", "protected");
|
||||
seedEvent(orm, other, `${other}:q:one`, "Unnamed");
|
||||
|
||||
// The whole dataset sees both - that is the reading the named list departs from.
|
||||
const all = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsubAll = all.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
expect(all.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? []).sort()).toEqual([
|
||||
"Named",
|
||||
"Unnamed",
|
||||
]);
|
||||
expect(titles(all.getSnapshot().data)).toEqual(["Named", "Unnamed"]);
|
||||
unsubAll();
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: [kept] });
|
||||
const scope = { graphs: [kept] };
|
||||
const obs = useShapeQuery(EventShape, scope);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(snap.data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Named"]);
|
||||
expect(obs.getSnapshot().isSuccess).toBe(true);
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["Named"]);
|
||||
// The object the caller wrote is the object the SDK received: no translation, no
|
||||
// copy, nothing added. That is what removing the pipeline bought.
|
||||
expect(orm.asked.at(-1)!.scope).toBe(scope);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(f) an empty `graphs` returns no objects, and says so as SUCCESS", async () => {
|
||||
it("(h) an empty `graphs` returns no objects, and says so as SUCCESS", async () => {
|
||||
const ng = makeFake();
|
||||
inject(ng);
|
||||
setCurrentUser("frank");
|
||||
const doc = await createEntityDoc("frank", "protected");
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "Unreachable");
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("gina");
|
||||
const doc = await createEntityDoc("gina", "protected");
|
||||
seedEvent(orm, doc, `${doc}:q:one`, "Unreachable");
|
||||
|
||||
// Upstream: "Setting value to `[]` or leaving it `undefined`, no objects are returned."
|
||||
// It is an ANSWER, not a failure - and not the pending state either, which is what an
|
||||
// application would otherwise be left staring at.
|
||||
// `graphs` became REQUIRED in `@ng-org/orm@0.1.2-alpha.21`, so the "leaving it
|
||||
// `undefined`" half of that sentence is no longer reachable by omitting the key. `[]`
|
||||
// is the other half, and it is the one that still spells "empty" - `normalizeScope`
|
||||
// maps both onto the same empty list, so this asks the same question it always did.
|
||||
// is the other half, and it is the one that still spells "empty".
|
||||
const obs = useShapeQuery(EventShape, { graphs: [] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
@@ -470,26 +446,22 @@ describe("useShapeQuery", () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(g) `subjects` narrows to the subject IRIs named, literally", async () => {
|
||||
it("(i) `subjects` narrows to the subject IRIs named, literally", async () => {
|
||||
const ng = makeFake();
|
||||
inject(ng);
|
||||
setCurrentUser("gina");
|
||||
const doc = await createEntityDoc("gina", "protected");
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("hank");
|
||||
const doc = await createEntityDoc("hank", "protected");
|
||||
// Two subjects in ONE document: the `subjects` axis is what tells them apart, and
|
||||
// `graphs` cannot - both carry the same `@graph`.
|
||||
seedSubject(ng, doc, `${FP}one`, TYPE, `${FP}Event`);
|
||||
seedSubject(ng, doc, `${FP}one`, `${FP}title`, "First");
|
||||
seedSubject(ng, doc, `${FP}two`, TYPE, `${FP}Event`);
|
||||
seedSubject(ng, doc, `${FP}two`, `${FP}title`, "Second");
|
||||
seedEvent(orm, doc, `${FP}one`, "First");
|
||||
seedEvent(orm, doc, `${FP}two`, "Second");
|
||||
|
||||
const both = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsubBoth = both.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
expect(both.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? []).sort()).toEqual([
|
||||
"First",
|
||||
"Second",
|
||||
]);
|
||||
expect(titles(both.getSnapshot().data)).toEqual(["First", "Second"]);
|
||||
unsubBoth();
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] });
|
||||
@@ -497,8 +469,52 @@ describe("useShapeQuery", () => {
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
expect(obs.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Second"]);
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["Second"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- the capability filter, which this module still owns -------------------
|
||||
|
||||
it("(j) an object whose document the holder has no capability for does not come back", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("iris");
|
||||
// Hers: created through the surface, so its capability is filed as a by-product.
|
||||
const mine = await createEntityDoc("iris", "protected");
|
||||
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
||||
// Somebody else's, sitting in the same local dataset - which is exactly what
|
||||
// `did:ng:i` returns, since the engine adds nothing to narrow it by what the reader
|
||||
// may open. The capability filter is the only thing between it and the caller.
|
||||
seedEvent(orm, "did:ng:o:someone-elses", "did:ng:o:someone-elses:q:one", "Theirs");
|
||||
expect(getCaps().isEnforcing()).toBe(true);
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
expect(titles(obs.getSnapshot().data)).toEqual(["Mine"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- teardown ------------------------------------------------------------
|
||||
|
||||
it("(k) the last unsubscribe closes the shape query", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("jane");
|
||||
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const first = obs.subscribe(() => {});
|
||||
const second = obs.subscribe(() => {});
|
||||
await tick();
|
||||
expect(orm.openCount()).toBe(1);
|
||||
|
||||
first();
|
||||
expect(orm.openCount()).toBe(1); // still a subscriber
|
||||
second();
|
||||
expect(orm.openCount()).toBe(0); // the query is closed, not leaked
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,12 @@ const TARGET_WORDS = new Set([
|
||||
"publisher", "topic", "advert",
|
||||
// the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`)
|
||||
"observable", "deep", "signal", "set",
|
||||
// `subscription` is the ORM's own noun for the handle its shape query returns -
|
||||
// `OrmSubscription` and `DiscreteOrmSubscription` in
|
||||
// `sdk/js/orm/src/connector/`, and `branch_subscriptions` in the engine. Added when
|
||||
// `OrmSubscriptionLike` was published, which is the type of the one an application
|
||||
// injects; the verb `subscribe` was already here and the noun is the same thing.
|
||||
"subscription",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user