Files
ng-eventually/packages/polyfill/src/surface/use-shape-query.ts
T
Sylvain Duchesne 0a2b8b1fbf docs: dire partout que la lecture par lot n'existe plus
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.
2026-09-21 14:11:35 +02:00

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();
},
};
}