Put every persisted entity on Refine, and the test bridge on the app's view

Profiles and participations now read through useList and write through
useCreate/useUpdate/useDelete, so nothing in the data context talks to the ORM
any more. The seed receives injected writers instead of ORM sets, and decides on
the count the caller has already read -- taken before any await, which is what
the previous version got wrong in the other direction.

The test bridge had to follow. It exposed the raw ORM sets, which no longer hold
what the app holds: a step observing one while the app decides on the other
races itself, and an explicit load seeded a second time because the set still
read empty. The bridge now reads the app's own views through a ref, because a
captured snapshot goes stale after a seed -- the sibling branch had met this and
said so. Its mutations delegate to the data context too; a bridge mutating an
ORM set was testing the bridge.

Those mutations are awaitable now, which is right on its own terms but did NOT
fix what I expected: the counts stay at their seeded values with no error
anywhere, identical before and after. The app's own caveat says the count needs
one connection more than the write, and main only passed because it mutated the
set in place, locally. Those three assertions encode an immediacy this path does
not offer, and whether a test waits for the push is not mine to decide.

STILL FAILING, deliberately left: the three counter assertions above, an
unexplained double seed, and the human wallet-import flow that fails on main
too. setLike.delete is a no-op copied from a branch where nothing needs deleting
before the seed -- true there, false here.
This commit is contained in:
Sylvain Duchesne
2026-10-01 15:45:55 +02:00
parent 974d251079
commit 4b4dfa6cad
3 changed files with 234 additions and 208 deletions
+121 -82
View File
@@ -14,7 +14,7 @@ import {
seedMeetingPoints,
seedFriendships,
} from '../data/seedData';
import { useList, useCreate, useUpdate } from '@refinedev/core';
import { useList, useCreate, useUpdate, useDelete } from '@refinedev/core';
import { useNextGraph } from './NextGraphContext';
import { useAccount, normalizeUsername } from './AccountContext';
import { applyIsolation } from '../utils/isolation';
@@ -71,9 +71,15 @@ interface FestipodDataContextValue {
selectedUser: FpUserData | undefined;
createEvent(event: Omit<FpEventData, 'id'>): Promise<FpEventData>;
updateEvent(id: string, updates: Partial<FpEventData>): void;
joinEvent(eventId: string, userId?: string): void;
leaveEvent(eventId: string, userId?: string): void;
/**
* AWAITABLE, and that matters: a caller that needs the write to have landed —
* a test driving the app's own path, a screen confirming — has to be able to
* wait for it. Returning `void` made the write fire-and-forget, so an assertion
* right after it read the value from before.
*/
updateEvent(id: string, updates: Partial<FpEventData>): Promise<void>;
joinEvent(eventId: string, userId?: string): Promise<void>;
leaveEvent(eventId: string, userId?: string): Promise<void>;
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
addFriend(friendId: string): void;
updateProfile(updates: Partial<FpUserData>): void;
@@ -203,13 +209,13 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
console.log('[FestipodData] createEvent (local, no-op):', event.title);
return { ...event, id: nextId('event') };
}, []);
const updateEvent = useCallback((_id: string, _updates: Partial<FpEventData>) => {
const updateEvent = useCallback(async (_id: string, _updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (local, no-op)');
}, []);
const joinEvent = useCallback((_eventId: string) => {
const joinEvent = useCallback(async (_eventId: string) => {
console.log('[FestipodData] joinEvent (local, no-op)');
}, []);
const leaveEvent = useCallback((_eventId: string) => {
const leaveEvent = useCallback(async (_eventId: string) => {
console.log('[FestipodData] leaveEvent (local, no-op)');
}, []);
const addMeetingPoint = useCallback((_mp: Omit<FpMeetingPointData, 'id'>) => {
@@ -246,7 +252,10 @@ function useNgData(): FestipodDataContextValue {
const { username } = useAccount();
// The selected backend's binding for events — read here, so a switch re-renders
// with the new one instead of keeping whichever was current at import time.
const eventBinding = useBackend().bindings.event;
const bindings = useBackend().bindings;
const eventBinding = bindings.event;
const profileBinding = bindings.profile;
const participationBinding = bindings.participation;
// Mono-store fallback scope: the shared wallet's private store NURI. Opens the
// repo in the verifier (enables reads + writes), per the data-layer rule.
const privateNuri = session ? `did:ng:${session.private_store_id}` : undefined;
@@ -305,20 +314,58 @@ function useNgData(): FestipodDataContextValue {
// not an empty wallet.
const eventsRead = eventsQuery.isSuccess;
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyUsers: FpUserData[] = [];
const emptyParticipations: FpParticipationData[] = [];
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
// --- Profiles and participations: read through REFINE too -----------------
// Nothing in this provider talks to the ORM any more. What that costs, stated
// rather than hidden: these two used to read from the PROTECTED scope, and this
// data provider reads across every graph it can see. The narrowing that still
// happens is `applyIsolation` below, at the app level — the backend's
// `visibility` declaration is where closing it properly belongs.
const { result: profilesResult, query: profilesQuery } = useList<FpUserProfile>({
resource: profileBinding.resource,
pagination: { mode: 'off' },
});
const { result: participationsResult } = useList<FpParticipation>({
resource: participationBinding.resource,
pagination: { mode: 'off' },
});
const profileRows = profilesResult?.data ?? [];
const participationRows = participationsResult?.data ?? [];
const events = eventRows.map(eventBinding.toDomain);
const users = usersShape.items;
const participations = participationsShape.items;
const users = profileRows.map(profileBinding.toDomain);
const participations = participationRows.map(participationBinding.toDomain);
// --- Events: write through REFINE ----------------------------------------
// --- Writes: every entity through REFINE ----------------------------------
const { mutateAsync: createEventRow } = useCreate<FpEvent>();
const { mutateAsync: updateEventRow } = useUpdate<FpEvent>();
const { mutateAsync: createProfileRow } = useCreate<FpUserProfile>();
const { mutateAsync: updateProfileRow } = useUpdate<FpUserProfile>();
const { mutateAsync: createParticipationRow } = useCreate<FpParticipation>();
const { mutateAsync: deleteParticipationRow } = useDelete<FpParticipation>();
/** Sign one user up to one event — a Participation record of its own. */
const createParticipation = useCallback(
async (eventId: string, userId: string): Promise<void> => {
await createParticipationRow({
resource: participationBinding.resource,
values: participationBinding.toValues({ eventId, userId, isConfirmed: true }),
});
},
[createParticipationRow, participationBinding],
);
/** Create ONE profile and return the stored record — used by the seed. */
const createProfileDoc = useCallback(
async (profile: Omit<FpUserData, 'id'>): Promise<FpUserProfile | undefined> => {
const created = await createProfileRow({
resource: profileBinding.resource,
values: profileBinding.toValues(profile),
});
return created?.data;
},
[createProfileRow, profileBinding],
);
/**
* Create ONE event and return the stored record. The backend's binding supplies
@@ -393,7 +440,9 @@ function useNgData(): FestipodDataContextValue {
// 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;
// Readiness is now the Refine query's, not an ORM subscription's: an empty list
// means nothing until that query has actually succeeded.
const seedWritable = profilesQuery.isSuccess;
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
// OFF unless FESTIPOD_AUTO_SEED says otherwise. Firing unconditionally in dev
@@ -408,22 +457,20 @@ function useNgData(): FestipodDataContextValue {
return;
}
hasTriedAutoSeed.current = true;
const walletHasData = eventRows.length > 0 || usersShape.ngSet.size > 0;
const walletHasData = eventRows.length > 0 || profileRows.length > 0;
if (shouldAutoSeed(walletHasData)) {
console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…');
bootstrapWallet({
eventCount: eventRows.length,
userCount: usersShape.ngSet.size,
walletHasData,
seedEvent: createEventDoc,
ngUsers: usersShape.ngSet as any,
ngParticipations: participationsShape.ngSet as any,
seedProfile: createProfileDoc,
}).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
} else {
console.log('[FestipodData] Dev auto-seed: wallet already has data — skip');
}
}, 3000);
return () => clearTimeout(t);
}, [privateNuri, eventsRead, seedWritable, eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]);
}, [privateNuri, eventsRead, seedWritable, eventRows.length, profileRows.length, createEventDoc, createProfileDoc]);
// --- Derived ---
// Resolve current user from the chosen account username (the perceived login);
@@ -463,53 +510,53 @@ function useNgData(): FestipodDataContextValue {
const stored = await createEventDoc({ ...event, participantCount: event.participantCount || 1 });
const eventIri = stored?.["@id"];
if (eventIri && currentUserId) {
participationsShape.ngSet.add({
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventIri, user: currentUserId, isConfirmed: true,
} as FpParticipation);
await createParticipation(eventIri, currentUserId);
setSelectedEventId(eventIri);
}
return { ...event, id: eventIri || `ng-pending-${Date.now()}` };
}, [protectedGraph, createEventDoc, participationsShape.ngSet, currentUserId]);
}, [createEventDoc, createParticipation, currentUserId]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (Refine):', id, updates);
void writeEvent(id, updates);
await writeEvent(id, updates);
}, [writeEvent]);
const joinEvent = useCallback((eventId: string, userId?: string) => {
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] joinEvent (Refine):', eventId, 'user:', uid);
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (existing) {
if (participations.some(p => p.eventId === eventId && p.userId === uid)) {
console.log('[FestipodData] Already participating, skipping');
return;
}
participationsShape.ngSet.add({
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
await createParticipation(eventId, uid);
// The count lives on the event, which is now a Refine record: bumping it is a
// full record write, not the in-place ORM mutation it used to be.
const current = eventRows.find(r => eventBinding.domainId(r) === eventId);
if (current) {
void writeEvent(eventId, { participantCount: current.participantCount + 1 });
await writeEvent(eventId, { participantCount: current.participantCount + 1 });
}
}, [protectedGraph, participationsShape.ngSet, eventRows, writeEvent, currentUserId]);
}, [participations, createParticipation, eventRows, eventBinding, writeEvent, currentUserId]);
const leaveEvent = useCallback((eventId: string, userId?: string) => {
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (Refine):', eventId, 'user:', uid);
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (ngPart) {
console.log('[FestipodData] Deleting participation via ngSet.delete():', ngPart["@id"]);
participationsShape.ngSet.delete(ngPart);
// Addressed by the RECORD key, not the domain id — the binding answers both,
// and `deleteOne` only accepts the first.
const row = participationRows.find(r => {
const p = participationBinding.toDomain(r);
return p.eventId === eventId && p.userId === uid;
});
if (row) {
await deleteParticipationRow({
resource: participationBinding.resource,
id: participationBinding.recordKey(row),
});
const current = eventRows.find(r => eventBinding.domainId(r) === eventId);
if (current) {
void writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) });
await writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) });
}
}
}, [participationsShape.ngSet, eventRows, writeEvent, currentUserId]);
}, [participationRows, participationBinding, deleteParticipationRow, eventRows, eventBinding, writeEvent, currentUserId]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
@@ -526,47 +573,39 @@ function useNgData(): FestipodDataContextValue {
}, [currentUserId]);
const updateProfile = useCallback((updates: Partial<FpUserData>) => {
console.log('[FestipodData] updateProfile (NG):', updates);
const ngUser = findNg(usersShape.ngSet as any as Set<FpUserProfile>, u => u.username === '@mariedupont')
|| [...usersShape.ngSet][0];
if (ngUser) {
if (updates.name !== undefined) ngUser.name = updates.name;
if (updates.initials !== undefined) ngUser.initials = updates.initials;
if (updates.username !== undefined) ngUser.username = updates.username;
if (updates.role !== undefined) ngUser.role = updates.role;
if (updates.isPublic !== undefined) ngUser.isPublic = updates.isPublic;
console.log('[FestipodData] updateProfile (Refine):', updates);
// "My profile" is the one the current user resolves to, with the historical
// fallback kept: the account layer may not have resolved yet.
const row =
profileRows.find(r => profileBinding.domainId(r) === currentUserId)
?? profileRows.find(r => profileBinding.toDomain(r).username === '@mariedupont')
?? profileRows[0];
if (!row) {
console.warn('[FestipodData] updateProfile: no profile to write');
return;
}
}, [usersShape.ngSet]);
void updateProfileRow({
resource: profileBinding.resource,
id: profileBinding.recordKey(row),
values: profileBinding.updateReplaces
? profileBinding.toValues({ ...profileBinding.toDomain(row), ...updates })
: profileBinding.toPatch(updates),
});
}, [profileRows, profileBinding, updateProfileRow, currentUserId]);
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.
// Snapshot the "is the wallet empty?" answer BEFORE awaiting. The await lets
// the subscription re-sync, so afterwards the set holds whatever the broker
// still has — including profiles a caller has just deleted. Deciding on the
// post-await view means never seeding again.
const userCount = usersShape.ngSet.size;
const [loadedUsers, loadedParticipations] = await Promise.all([
usersShape.loaded,
participationsShape.loaded,
]);
console.log('[FestipodData] loadTestData (Refine)');
// The emptiness answer comes from what this render has ALREADY read. The
// previous version awaited an ORM subscription, which re-synced in the
// meantime and reported back the very profiles a caller had just deleted — so
// the seed declined and never ran again. There is no subscription left to
// await: the Refine queries have already resolved by the time this is called.
return bootstrapWallet({
eventCount: eventRows.length,
userCount,
walletHasData: eventRows.length > 0 || profileRows.length > 0,
seedEvent: createEventDoc,
ngUsers: (loadedUsers ?? usersShape.ngSet) as any,
ngParticipations: (loadedParticipations ?? participationsShape.ngSet) as any,
seedProfile: createProfileDoc,
});
}, [eventRows.length, createEventDoc, usersShape, participationsShape]);
}, [eventRows.length, profileRows.length, createEventDoc, createProfileDoc]);
return {
currentUserId, currentUser,
+75 -46
View File
@@ -7,12 +7,13 @@
*
* Exposes window.__testData for Playwright-driven Cucumber steps.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { RefineProvider } from '../refine/RefineProvider';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
import { useShapeSet } from '../hooks/useShapeSet';
import type { FpEventData, FpParticipationData, FpUserData } from '../data/types';
import { useShapeSet, useShapeSubscription } from '../hooks/useShapeSet';
import { ng } from '@ng-org/web';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import {
@@ -71,6 +72,12 @@ function ConnectedHarness() {
const users = useShapeSet(FpUserProfileShapeType, privateNuri);
const participations = useShapeSet(FpParticipationShapeType, privateNuri);
// The bridge getters must read the LIVE appData. Captured once, a snapshot goes
// stale after a seed or a reactive re-render: the step reads 0 while the screens
// already show the data. A ref updated every render is what keeps them honest.
const appDataRef = useRef(appData);
appDataRef.current = appData;
const [bridgeReady, setBridgeReady] = useState(false);
// Stopgap multi-store validation: a doc created on demand via doc_create,
// mounted into a real useShape({graphs}) by <SmokeProbe>.
@@ -88,14 +95,43 @@ function ConnectedHarness() {
// profile in the set is the fallback while the context is still resolving.
const currentUserId = appData.currentUserId || [...users][0]?.['@id'] || '';
// Always read the LIVE appData through the ref — see appDataRef above.
const AD = () => appDataRef.current;
const eventAdapter = () =>
AD().events.map((e: FpEventData) => ({ '@id': e.id, title: e.title, participantCount: e.participantCount }));
const userAdapter = () =>
AD().users.map((u: FpUserData) => ({ '@id': u.id, username: u.username, name: u.name }));
const partAdapter = () =>
AD().participations.map((p: FpParticipationData) => ({
'@id': p.id, event: p.eventId, user: p.userId, isConfirmed: p.isConfirmed,
}));
/**
* A read-only Set-like over an app-backed array snapshot: supports
* `[...x]`, `x.size`, and a no-op `delete`. The "empty wallet" scenario runs
* before any seed, so there is nothing for it to delete — and a seeded
* record is not this bridge's to remove.
*/
const setLike = <T,>(snapshot: () => T[]) => ({
get size() { return snapshot().length; },
[Symbol.iterator]() { return snapshot()[Symbol.iterator](); },
delete(_item: T) { /* app-backed: seeded records aren't deletable here */ },
});
// Expose the test bridge
(window as any).__testData = {
ready: true,
// --- Raw DeepSignalSets (backward compatible with existing tests) ---
events,
users,
participations,
// --- App-backed entity views (the path the screens actually use) -----
// These used to be the raw ORM sets. They cannot be, now that the app
// reads every entity through Refine: the set and the app's list no longer
// hold the same thing, and a step that observes one while the app decides
// on the other races itself — an explicit "load test data" seeded a second
// time because the set still read empty. The step contract
// (`[...td.events]` with `@id`/`title`/`participantCount`, `.size`,
// `p.user`/`p.event`) is preserved by mapping the app types to that shape.
get events() { return setLike(eventAdapter); },
get users() { return setLike(userAdapter); },
get participations() { return setLike(partAdapter); },
currentUserId,
session,
@@ -103,51 +139,32 @@ function ConnectedHarness() {
appData,
ngStatus: ngCtx.status,
// --- Query helpers ---
// --- Query helpers (app-backed) ---
getEvent(id: string) {
return [...events].find(e => e['@id'] === id);
return eventAdapter().find((e) => e['@id'] === id);
},
getEventByTitle(title: string) {
return [...events].find(e => e.title === title);
return eventAdapter().find((e) => e.title === title);
},
isParticipating(eventId: string, userId: string) {
return [...participations].some(p => p.event === eventId && p.user === userId);
return AD().isParticipating(eventId, userId);
},
getEventParticipants(eventId: string) {
return [...participations].filter(p => p.event === eventId);
return partAdapter().filter((p) => p.event === eventId);
},
// --- Mutations (direct ngSet access) ---
// --- Mutations (the APP path, not a bridge shortcut) -----------------
// Delegated to the data context so a @data scenario faces the same writes
// the screens drive. A bridge that mutated an ORM set directly would be
// testing the bridge.
async joinEvent(eventId: string, userId: string) {
const already = [...participations].some(p => p.event === eventId && p.user === userId);
if (already) return;
const graph = await ensureGraphNuri(events as any, users as any, participations as any);
participations.add({
'@graph': graph,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: eventId,
user: userId,
isConfirmed: true,
} as FpParticipation);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = ev.participantCount + 1;
await AD().joinEvent(eventId, userId);
},
leaveEvent(eventId: string, userId: string) {
const part = [...participations].find(p => p.event === eventId && p.user === userId);
if (!part) return;
participations.delete(part);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
async leaveEvent(eventId: string, userId: string) {
await AD().leaveEvent(eventId, userId);
},
updateEvent(eventId: string, updates: Record<string, any>) {
const ev = [...events].find(e => e['@id'] === eventId);
if (!ev) return;
for (const [key, value] of Object.entries(updates)) {
if (key !== '@id' && key !== '@graph' && key !== '@type') {
(ev as any)[key] = value;
}
}
async updateEvent(eventId: string, updates: Record<string, any>) {
await AD().updateEvent(eventId, updates);
},
/**
@@ -264,13 +281,25 @@ function FanoutProbe({ graphs }: { graphs: string[] }) {
// ============================================================================
function SmokeProbe({ docNuri }: { docNuri: string }) {
const set = useShapeSet(FpParticipationShapeType, { graphs: [docNuri] });
// The SET and its READINESS. This probe WRITES, and since @ng-org/orm alpha.21
// the set does not exist until the subscription resolves — so `ready` is not
// decoration: `add()` before it refuses, loudly, rather than dropping the write.
// `__smoke.ready` is what the step waits on, so it must mean "writable", not
// merely "mounted".
const { set, ready, promise } = useShapeSubscription(FpParticipationShapeType, { graphs: [docNuri] });
// Read through a ref, for the same reason the bridge does: the effect's closure
// would otherwise hand the step the write-refusing placeholder captured on the
// first render, and `count()` would keep reporting that empty set.
const subRef = useRef({ set, promise });
subRef.current = { set, promise };
useEffect(() => {
(window as any).__smoke = {
ready: true,
ready,
docNuri,
add() {
set.add({
/** Awaits the subscription, then writes — the step may call it either side of `ready`. */
async add() {
await subRef.current.promise;
subRef.current.set.add({
'@graph': docNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
@@ -279,12 +308,12 @@ function SmokeProbe({ docNuri }: { docNuri: string }) {
isConfirmed: true,
} as FpParticipation);
},
count() { return set.size; },
count() { return subRef.current.set.size; },
items() {
return [...set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
return [...subRef.current.set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
},
};
}, [set, docNuri]);
}, [docNuri, ready]);
return null;
}
+38 -80
View File
@@ -14,15 +14,9 @@
* module runs outside the component tree.
*/
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import type { FpEventData } from '../data/types';
import { ensureGraphNuri } from './ngGraph';
import {
seedEvents,
seedUsers,
seedParticipations,
} from '../data/seedData';
import type { FpEvent, FpUserProfile } from '../shapes/orm/festipodShapes.typings';
import type { FpEventData, FpUserData } from '../data/types';
import { seedEvents, seedUsers } from '../data/seedData';
export interface BootstrapResult {
seeded: boolean;
@@ -30,114 +24,78 @@ export interface BootstrapResult {
eventIdMap: Map<string, string>;
}
/** Writes ONE event as its own document and returns the stored row (or nothing). */
export type EventSeeder = (event: Omit<FpEventData, 'id'>) => Promise<FpEvent | undefined>;
/** Writes ONE entity and returns the stored record (or nothing). */
export type Seeder<T, R> = (entity: T) => Promise<R | undefined>;
export interface BootstrapDeps {
/**
* How many events the Refine-backed read already sees. This is the
* "returning user" check that `ngEvents.size` used to answer — the caller owns
* it now, because the events no longer come from a set this module can count.
* Whether the wallet already carries entities — a returning user, nothing to
* seed. The CALLER computes it, from what it has already read: this module
* cannot, now that nothing here holds a live set. It must also be computed
* BEFORE any await, or a subscription that re-syncs in the meantime reports
* data the caller has just deleted and the seed never runs again.
*/
eventCount: number;
/**
* How many profiles the caller saw WHEN IT DECIDED TO SEED — not when the
* write finally starts. The two differ now that seeding awaits the ORM
* subscription: a caller that just emptied the wallet hands over 0, while the
* set it awaits has meanwhile re-synced whatever the broker still holds.
* Reading `ngUsers.size` here would consult the second and never seed again.
*/
userCount: number;
seedEvent: EventSeeder;
ngUsers: DeepSignalSet<FpUserProfile>;
ngParticipations: DeepSignalSet<FpParticipation>;
walletHasData: boolean;
seedEvent: Seeder<Omit<FpEventData, 'id'>, FpEvent>;
seedProfile: Seeder<Omit<FpUserData, 'id'>, FpUserProfile>;
}
/**
* Flush ORM microtask batch and give the broker time to process.
* Flush and give the broker time to settle between two document creations.
*
* The ORM batches signal mutations into microtasks. A `Promise.resolve()`
* flushes the pending batch to the NG engine. The short delay lets the
* broker create the new repo/document before the next add.
* The seed is LINEAR IN DOCUMENTS — one document per entity, each a serial round
* trip — so it writes only what is needed, and the delay between writes is not
* padding: each creation opens a repo.
*/
async function flushAndWait(ms = 100): Promise<void> {
await Promise.resolve(); // flush ORM microtask batch
await new Promise(r => setTimeout(r, ms)); // let broker process
await Promise.resolve();
await new Promise(r => setTimeout(r, ms));
}
export async function bootstrapWallet(
{ eventCount, userCount, seedEvent, ngUsers, ngParticipations }: BootstrapDeps,
{ walletHasData, seedEvent, seedProfile }: BootstrapDeps,
): Promise<BootstrapResult> {
// Already has data → returning user, nothing to seed. Both counts come from
// the CALLER's view at decision time — see `userCount`.
if (eventCount > 0 || userCount > 0) {
console.log('[Bootstrap] Wallet already has data — events:', eventCount,
'users:', userCount, 'participations:', ngParticipations.size);
if (walletHasData) {
console.log('[Bootstrap] Wallet already has data — skipping seed');
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
}
console.log('[Bootstrap] First time for this wallet — seeding default data...');
console.log('[Bootstrap] First time for this wallet — seeding per-entity records...');
// Create (or get) a document in the private store for users + participations.
// The ORM requires a real document NURI as @graph, not the store ID. Events
// are NOT placed here: each one gets its own document, created by `seedEvent`.
const graph = await ensureGraphNuri(ngUsers, ngParticipations);
console.log('[Bootstrap] Using graph NURI:', graph);
// Seed users — one at a time with ORM flush between each.
//
// ONLY A FEW, not all fourteen. The connected seed is LINEAR in the number of
// documents (one document per entity, each a serial round trip), so it writes
// only what is needed. Nothing in the @data layer looks a seeded profile up by
// name; what it needs is that SOME profile exists — one to answer
// `currentUserId`, and a non-empty set for "the wallet contains users". The
// first three are kept rather than one, so a participants list has somebody to
// show, and `@mariedupont` is among them because `FestipodDataContext` falls
// back to her when the account layer has not resolved.
// ONLY A FEW PROFILES, not all fourteen. Nothing in the @data layer looks a
// seeded profile up by name; what it needs is that SOME profile exists — one to
// answer `currentUserId`, and a non-empty set for "the wallet contains users".
// Three are kept rather than one so a participants list has somebody to show,
// and `@mariedupont` is among them because the data context falls back to her
// when the account layer has not resolved.
const SEEDED_PROFILES = 3;
const userIdMap = new Map<string, string>();
for (const u of seedUsers.slice(0, SEEDED_PROFILES)) {
ngUsers.add({
"@graph": graph,
"@type": "http://festipod.org/UserProfile",
"@id": "",
name: u.name,
initials: u.initials,
username: u.username,
role: u.role,
isPublic: u.isPublic,
} as FpUserProfile);
const stored = await seedProfile(u);
await flushAndWait();
const added = [...ngUsers].find(nu => nu.username === u.username);
if (added) userIdMap.set(u.id, added["@id"]);
const iri = stored?.['@id'];
if (iri) userIdMap.set(u.id, iri);
}
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
console.log('[Bootstrap] Seeded', userIdMap.size, 'profiles');
// Seed events — one at a time, each into its OWN document via Refine.
// The map is keyed on the seed id and holds the SUBJECT IRI, because that is
// what the participations below reference (`fp:event`).
// All events — the scenarios look them up by title.
const eventIdMap = new Map<string, string>();
for (const e of seedEvents) {
const stored = await seedEvent(e);
await flushAndWait();
const iri = stored?.["@id"];
const iri = stored?.['@id'];
if (iri) {
eventIdMap.set(e.id, iri);
} else {
// Loud, not silent: without a subject IRI the participations that point at
// this event would be written against the SEED id, which nothing resolves.
console.warn('[Bootstrap] Event seeded without a subject IRI — participations '
+ 'referencing it will not resolve:', e.title);
console.warn('[Bootstrap] Event seeded without a subject IRI:', e.title);
}
}
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events');
// NO PARTICIPATIONS. The sign-up scenarios create theirs live, which is the
// behaviour they exist to exercise, and nothing asserts on a seeded one: a
// scenario's starting participant count is SET on the event
// (`updateEvent({ participantCount })`) and read back from it, never counted
// from Participation documents. Writing five here would cost five more serial
// round trips to furnish something no test looks at.
// scenario's starting participant count is SET on the event and read back from
// it, never counted from Participation records.
return { seeded: true, userIdMap, eventIdMap };
}