0a2b8b1fbf
Le contrat publie et l'api-contract etaient traites avec le retrait lui-meme ; restaient les documents qui decrivaient readUnion comme la primitive de lecture du paquet, ou qui nommaient le module disparu. Ce que les lecteurs apprennent maintenant : lire un document, c'est docs.sparqlQuery ancre dessus, et lire plusieurs documents, c'est cet appel une fois par document -- avec la decision sur une lecture qui echoue ecrite au point d'appel. Deux consequences sont dites plutot que decouvertes : une lecture refusee leve au lieu de rendre une liste vide, et les sujets sous le prefixe reserve urn:ng-eventually: reviennent dans le resultat, a l'appelant de les ignorer -- sans aucun gain de droits, les deux registres qu'un document ordinaire porte etant l'adresse de sa boite et, pour un document public, la cle que son magasin sert deja a qui la demande. Les briefs dates ne sont pas touches : ils decrivent un etat de juillet, et le reecrire falsifierait ce qui avait ete decide alors.
372 lines
18 KiB
TypeScript
372 lines
18 KiB
TypeScript
/**
|
|
* 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<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
|
|
* 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<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 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<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 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<T extends BaseType = BaseType>(
|
|
shapeType: ShapeType<T>,
|
|
scope: Scope,
|
|
): ShapeObservable<T> {
|
|
// The current, STABLE snapshot (same reference until a transition rebuilds it).
|
|
let snapshot: ShapeQuery<T> = {
|
|
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<T>): 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<T> {
|
|
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();
|
|
},
|
|
};
|
|
}
|