Wait for the ORM subscription before seeding into it

The placeholder useShapeSet hands back while a subscription loads refuses
writes, and bootstrapWallet writes its users straight into that set — so the
seed threw on its very first add and the wallet reported nothing persisted.
The refusal is right; issuing the write that early was not.

The previous orm version handed back a writable set immediately for a defined
scope, so a caller never had to wait. Since alpha.21 the set does not exist
until the subscription resolves, so loadTestData now awaits the ORM's own
promise and seeds into the set it resolves with — re-reading ngSet after the
await would hand over the placeholder this closure captured. The dev auto-seed
gates on the same readiness.
This commit is contained in:
Sylvain Duchesne
2026-09-21 13:53:19 +02:00
parent 753282c7b1
commit 46abbf2e64
3 changed files with 80 additions and 23 deletions
+27 -9
View File
@@ -404,9 +404,13 @@ function useNgData(): FestipodDataContextValue {
// → skip), so this is safe even if shapes finish hydrating after the timer.
// Gated on NODE_ENV so production users see their own (possibly empty) wallet.
//
// `eventsRead` is the guard that the ORM sets used to give for free: an empty
// Refine list means nothing until its query has actually succeeded, and seeding
// on an in-flight read would write the fixtures a second time.
// TWO readiness guards, and both are load-bearing. `eventsRead` says the Refine
// list has actually resolved — an empty list means nothing while the query is in
// flight, and seeding on it would write the fixtures a second time. `seedWritable`
// says the ORM subscriptions the seed WRITES INTO exist: since @ng-org/orm
// alpha.21 the set is `undefined` until the subscription resolves, so an add
// issued before then has nowhere to land and throws.
const seedWritable = usersShape.ready && participationsShape.ready;
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
@@ -414,8 +418,8 @@ function useNgData(): FestipodDataContextValue {
if (hasTriedAutoSeed.current) return;
if (!privateNuri) return;
const t = setTimeout(() => {
if (!eventsRead) {
console.log('[FestipodData] Dev auto-seed: events not read yet — skip');
if (!eventsRead || !seedWritable) {
console.log('[FestipodData] Dev auto-seed: subscriptions not ready yet — skip');
return;
}
hasTriedAutoSeed.current = true;
@@ -432,7 +436,7 @@ function useNgData(): FestipodDataContextValue {
}
}, 3000);
return () => clearTimeout(t);
}, [privateNuri, eventsRead, eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]);
}, [privateNuri, eventsRead, seedWritable, eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]);
// --- Derived ---
// Resolve current user from the chosen account username (the perceived login);
@@ -549,13 +553,27 @@ function useNgData(): FestipodDataContextValue {
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (NG)');
// The seed WRITES into the ORM subscriptions, which since @ng-org/orm
// alpha.21 do not exist until they have loaded. Await the ORM's own
// readiness signal first — a `bootstrapWallet` started too early throws on
// its very first `add`, and callers see a wallet that seeded nothing.
//
// The RESOLVED VALUE is the set. Re-reading `usersShape.ngSet` after the
// await would hand over the placeholder captured by this closure, since the
// subscription resolving does not retroactively change the render that built
// this callback. `?? ngSet` covers the no-scope case, where there is no
// promise and the placeholder is the honest answer.
const [loadedUsers, loadedParticipations] = await Promise.all([
usersShape.loaded,
participationsShape.loaded,
]);
return bootstrapWallet({
eventCount: eventRows.length,
seedEvent: createEventDoc,
ngUsers: usersShape.ngSet as any,
ngParticipations: participationsShape.ngSet as any,
ngUsers: (loadedUsers ?? usersShape.ngSet) as any,
ngParticipations: (loadedParticipations ?? participationsShape.ngSet) as any,
});
}, [eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]);
}, [eventRows.length, createEventDoc, usersShape, participationsShape]);
return {
currentUserId, currentUser,
+41 -10
View File
@@ -1,5 +1,6 @@
/**
* useShapeSet — `useShape` narrowed back down to "just the set".
* useShapeSet — `useShape` narrowed back down to "just the set", plus the one
* fact the new contract forces callers to know: whether it has loaded.
*
* WHY THIS EXISTS. `@ng-org/orm` changed `useShape`'s contract between alpha.18
* and alpha.21: it used to RETURN the `DeepSignalSet`, and now returns a result
@@ -13,11 +14,17 @@
* the name `OrmSubscription` and only alpha.21 exports `RdfOrmSubscription`. So
* using the providers requires alpha.21, which requires absorbing this break.
*
* WHILE LOADING, `data` is `undefined`. Callers get an empty set instead — and a
* set that REFUSES writes, which is what alpha.18 handed back for an undefined
* scope: *"an empty set is returned which errors if you try to make modifications
* on it"*. Accepting the write instead would drop it on the floor, silently, in
* exactly the window where a caller is most likely to try.
* WHAT CHANGES FOR WRITERS. Under alpha.18 a defined scope handed back a WRITABLE
* set straight away, so a caller could `add()` the moment it had the reference.
* Under alpha.21 `data` is `undefined` until the subscription resolves — there is
* genuinely no set to write to yet. So a writer must WAIT, and `ready` is what
* lets it: `useShapeWithDefaults` surfaces it, and the seeding path gates on it.
* (Only a scope of `undefined` never becomes ready — that is the "no scope"
* case, which alpha.18 also refused writes on.)
*
* The placeholder returned meanwhile refuses writes rather than accepting them
* into a detached `Set`, because a silently dropped seed is far harder to notice
* than a thrown one — it looks exactly like a wallet that persisted nothing.
*/
import { useMemo } from 'react';
@@ -32,26 +39,50 @@ import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
*/
export type ShapeScope = string | { graphs: string[] } | undefined;
export interface ShapeSubscription<NgT extends BaseType> {
/** The live set, or a write-refusing placeholder until `ready`. */
set: DeepSignalSet<NgT>;
/** `true` once the subscription has produced its set — a write may land. */
ready: boolean;
/**
* Resolves when the subscription has loaded — the ORM's own readiness signal,
* so a writer AWAITS it instead of re-reading in a loop (`rule_no-broker-polling`).
* `undefined` when the scope is `undefined`: there is nothing to wait for.
*/
promise: Promise<unknown> | undefined;
}
/** An empty set that names the problem instead of swallowing a write. */
function notLoadedSet<T>(): DeepSignalSet<T> {
const refuse = (): never => {
throw new Error(
'useShapeSet: the shape subscription has not loaded yet — read it, do not write to it',
'useShapeSet: the shape subscription has not loaded yet — await `ready` before writing',
);
};
return Object.assign(new Set<T>(), { add: refuse, delete: refuse }) as unknown as DeepSignalSet<T>;
}
export function useShapeSet<NgT extends BaseType>(
/** The set plus its readiness — for callers that WRITE. */
export function useShapeSubscription<NgT extends BaseType>(
shapeType: ShapeType<NgT>,
scope: ShapeScope,
): DeepSignalSet<NgT> {
): ShapeSubscription<NgT> {
// Memoised so the placeholder keeps ONE identity for this hook instance: it
// lands in `useCallback` dependency arrays, and a fresh set on every render
// would rebuild every mutation callback for as long as the load takes.
const placeholder = useMemo(() => notLoadedSet<NgT>(), []);
const result = useShape(shapeType as never, scope as never) as unknown as {
data?: DeepSignalSet<NgT>;
promise?: Promise<unknown>;
};
return result?.data ?? placeholder;
const data = result?.data;
return { set: data ?? placeholder, ready: data !== undefined, promise: result?.promise };
}
/** The set alone — for callers that only READ. */
export function useShapeSet<NgT extends BaseType>(
shapeType: ShapeType<NgT>,
scope: ShapeScope,
): DeepSignalSet<NgT> {
return useShapeSubscription(shapeType, scope).set;
}
+12 -4
View File
@@ -10,13 +10,21 @@
import type { ShapeType, BaseType } from '@ng-org/shex-orm';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import { useShapeSet, type ShapeScope } from './useShapeSet';
import { useShapeSubscription, type ShapeScope } from './useShapeSet';
export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
/** Mapped items from NG store */
items: AppT[];
/** Raw NG signal set for mutations */
ngSet: DeepSignalSet<NgT>;
/**
* `true` once the subscription holds a real set. A WRITER must check this:
* since @ng-org/orm alpha.21 the set does not exist until the subscription
* resolves, so an `ngSet.add(…)` issued before then has nowhere to land.
*/
ready: boolean;
/** Resolves once the subscription has loaded — await it before writing. */
loaded: Promise<unknown> | undefined;
}
// `ShapeScope` now belongs to useShapeSet (the module that talks to the ORM);
@@ -32,10 +40,10 @@ export function useShapeWithDefaults<NgT extends BaseType, AppT>(
): ShapeWithDefaults<NgT, AppT> {
// Mono-store: a single store NURI opens the repo in the verifier (enables
// writes). Multi-document: a { graphs } scope subscribes to several docs.
// `useShapeSet` is what unwraps the ORM's result object — see that module.
const ngSet = useShapeSet(shapeType, storeNuri);
// `useShapeSubscription` is what unwraps the ORM's result object — see that module.
const { set: ngSet, ready, promise: loaded } = useShapeSubscription(shapeType, storeNuri);
const usingDefaults = !shapesReady;
const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT));
return { items, ngSet };
return { items, ngSet, ready, loaded };
}