diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 158019d..8fc0606 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -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): Promise; - updateEvent(id: string, updates: Partial): 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): Promise; + joinEvent(eventId: string, userId?: string): Promise; + leaveEvent(eventId: string, userId?: string): Promise; addMeetingPoint(mp: Omit): void; addFriend(friendId: string): void; updateProfile(updates: Partial): 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) => { + const updateEvent = useCallback(async (_id: string, _updates: Partial) => { 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) => { @@ -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({ + resource: profileBinding.resource, + pagination: { mode: 'off' }, + }); + const { result: participationsResult } = useList({ + 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(); const { mutateAsync: updateEventRow } = useUpdate(); + const { mutateAsync: createProfileRow } = useCreate(); + const { mutateAsync: updateProfileRow } = useUpdate(); + const { mutateAsync: createParticipationRow } = useCreate(); + const { mutateAsync: deleteParticipationRow } = useDelete(); + + /** Sign one user up to one event — a Participation record of its own. */ + const createParticipation = useCallback( + async (eventId: string, userId: string): Promise => { + 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): Promise => { + 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) => { + const updateEvent = useCallback(async (id: string, updates: Partial) => { 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) => { setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); @@ -526,47 +573,39 @@ function useNgData(): FestipodDataContextValue { }, [currentUserId]); const updateProfile = useCallback((updates: Partial) => { - console.log('[FestipodData] updateProfile (NG):', updates); - const ngUser = findNg(usersShape.ngSet as any as Set, 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 => { - 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, diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index bdbef73..22773cc 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -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 . @@ -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 = (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) { - 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) { + 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; } diff --git a/src/shared/utils/ngBootstrap.ts b/src/shared/utils/ngBootstrap.ts index fe120b7..520e19e 100644 --- a/src/shared/utils/ngBootstrap.ts +++ b/src/shared/utils/ngBootstrap.ts @@ -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; } -/** Writes ONE event as its own document and returns the stored row (or nothing). */ -export type EventSeeder = (event: Omit) => Promise; +/** Writes ONE entity and returns the stored record (or nothing). */ +export type Seeder = (entity: T) => Promise; 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; - ngParticipations: DeepSignalSet; + walletHasData: boolean; + seedEvent: Seeder, FpEvent>; + seedProfile: Seeder, 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 { - 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 { - // 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(); 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(); 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 }; }