/** * 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 * 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 ------- * 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 * `.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 = { 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 * scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`. * * -- 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, one anchored read over * each of them, and a filter of the result 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. * * 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. * * -- 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. * * -- 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. * * -- `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. * * 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. * * -- 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 * 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, 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"; import type { BaseType, ShapeType } from "@ng-org/shex-orm"; /** * A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array * (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; `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 { /** 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 until the shape query's initial payload has arrived (`readyPromise`). * Mutually exclusive with `isSuccess`. */ isPending: boolean; /** 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 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 { /** 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; /** Register a change listener; returns an unsubscribe. The last listener's * unsubscribe closes the underlying shape query. */ subscribe(onChange: () => void): () => void; /** 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; } /** * Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one * `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( shapeType: ShapeType, scope: Scope, ): ShapeObservable { // The current, STABLE snapshot (same reference until a transition rebuilds it). let snapshot: ShapeQuery = { data: [], isPending: true, isSuccess: false, isError: false, error: undefined, }; const listeners = new Set<() => void>(); let started = false; /** 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; /** * 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) { try { l(); } catch (error) { console.error("[use-shape-query] listener threw", error); } } } function setSnapshot(next: ShapeQuery): void { snapshot = next; emit(); } /** * 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 * 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 * initial value, published under `isError`, never under `isSuccess`. */ function setUnknown(error: unknown): void { setSnapshot({ data: snapshot.data, isPending: false, isSuccess: false, isError: true, error, }); } /** * The readable items of the live subscription, as a plain array. * * 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[]; } /** 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: readable(handle), isPending: false, isSuccess: true, isError: false, error: undefined, }); } /** * The SDK's shape query, or the error that says it was never injected. * * 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 { handle.close(); } catch { /* ignore */ } } /** * 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 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 (query !== null) publish(query, generation); }); open(); } return { getSnapshot(): ShapeQuery { return snapshot; }, subscribe(onChange: () => void): () => void { listeners.add(onChange); start(); return () => { listeners.delete(onChange); if (listeners.size === 0) { // 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; } started = false; } }; }, refetch(): void { if (!started) { start(); return; } open(); }, }; }