WIP: events read and written through Refine

Mounts Refine over @ng-org/refine-providers and routes the Events vertical
through it. Known incomplete: the @data harness still mounts the data context
outside <Refine> and calls the old bootstrapWallet signature.
This commit is contained in:
Sylvain Duchesne
2026-09-21 11:58:32 +02:00
parent 266e33556d
commit 33d54c6e3e
9 changed files with 395 additions and 122 deletions
+15 -10
View File
@@ -2,6 +2,7 @@ import { RouterProvider, useRouter } from './router';
import { ThemeProvider } from '../shared/context/ThemeContext';
import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { AccountProvider } from '../shared/context/AccountContext';
import { RefineProvider } from '../shared/refine/RefineProvider';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { AuthGate } from './AuthGate';
import { ToastContainer } from '../shared/components/sketchy';
@@ -58,16 +59,20 @@ export function App() {
<ThemeProvider>
<NextGraphProvider>
<AccountProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
{/* Refine wraps the data context: FestipodDataContext reads and writes
events through Refine's hooks, so it must sit inside <Refine>. */}
<RefineProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
</RefineProvider>
</AccountProvider>
</NextGraphProvider>
</ThemeProvider>
+149 -75
View File
@@ -14,11 +14,13 @@ import {
seedMeetingPoints,
seedFriendships,
} from '../data/seedData';
import { useList, useCreate, useUpdate } from '@refinedev/core';
import { useNextGraph } from './NextGraphContext';
import { useAccount, normalizeUsername } from './AccountContext';
import { applyIsolation } from '../utils/isolation';
import { ensureAccount, resolveReadGraphs, resolveWriteGraph, createEntityDoc, listEntityDocs } from '../utils/storeRegistry';
import { ensureAccount, resolveReadGraphs, resolveWriteGraph } from '../utils/storeRegistry';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
import { RESOURCE_EVENTS } from '../refine/shapeTypes';
// Multi-document mode (storeRegistry): one document per (account × scope),
// mirroring the target per-user stores. Default OFF — the validated mono-store
@@ -28,8 +30,9 @@ import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefa
// custom vars, so a bare `process.env.FESTIPOD_MULTISTORE` throws
// "process is not defined" in the browser harness. Guard it.
const MULTISTORE = typeof process !== 'undefined' && process?.env?.FESTIPOD_MULTISTORE === '1';
// `FpEventShapeType` is NOT imported here any more: events go through Refine, and
// the resource → shape binding lives in refine/shapeTypes.ts.
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
@@ -107,6 +110,23 @@ const mapEvent = (e: FpEvent): FpEventData => ({
hostInitials: e.hostInitials,
});
/**
* Every persisted field of an event, in the form the Refine data provider takes.
* `@type` is carried explicitly — see `createEventDoc` for why it must be.
*/
const eventFields = (e: Omit<FpEventData, 'id'>) => ({
"@type": "http://festipod.org/Event",
title: e.title,
description: e.description,
date: e.date,
location: e.location,
distance: e.distance,
participantCount: e.participantCount,
coverImage: e.coverImage,
hostName: e.hostName,
hostInitials: e.hostInitials,
});
const mapUser = (u: FpUserProfile): FpUserData => ({
id: u["@id"],
name: u.name,
@@ -251,7 +271,7 @@ function useNgData(): FestipodDataContextValue {
// Multi-document state (storeRegistry): read fan-out (all accounts' docs per
// scope) and the current account's write docs. Populated by the effect below.
const [readGraphs, setReadGraphs] = useState<{ public: string[]; protected: string[] }>({ public: [], protected: [] });
const [readGraphs, setReadGraphs] = useState<{ protected: string[] }>({ protected: [] });
const [writeGraphs, setWriteGraphs] = useState<{ protected?: string }>({});
useEffect(() => {
@@ -260,15 +280,16 @@ function useNgData(): FestipodDataContextValue {
(async () => {
try {
await ensureAccount(username); // create this account's index docs on first sight
// Public = per-entity documents (events/PdR) listed by the public index.
// Protected = grouped in each account's protected index document.
const [pub, prot, wProt] = await Promise.all([
listEntityDocs('public'),
// NO public fan-out any more: events are a Refine resource, and the data
// provider addresses them by shape across every graph it can see — it
// never takes a graph list, so the public index has no reader left.
const [prot, wProt] = await Promise.all([
resolveReadGraphs('protected'),
resolveWriteGraph(username, 'protected'),
]);
if (cancelled) return;
setReadGraphs({ public: pub, protected: prot });
setReadGraphs({ protected: prot });
setWriteGraphs({ protected: wProt });
} catch (err) {
console.error('[FestipodData] storeRegistry init failed:', err);
@@ -277,28 +298,92 @@ function useNgData(): FestipodDataContextValue {
return () => { cancelled = true; };
}, [privateNuri, username]);
// Scope per entity: events live in the PUBLIC docs, profiles + participations
// in the PROTECTED docs. Mono-store mode collapses all to the private store.
const publicScope: ShapeScope = MULTISTORE
? (readGraphs.public.length ? { graphs: readGraphs.public } : undefined)
: privateNuri;
// Scope for profiles + participations, which still read through the ORM.
// Mono-store mode collapses them to the private store.
const protectedScope: ShapeScope = MULTISTORE
? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined)
: privateNuri;
// --- Events: read through REFINE -----------------------------------------
// `useList` goes to `@ng-org/refine-providers`, which resolves the resource
// name to its SHEX shape (see refine/shapeTypes.ts) and reads every graph the
// verifier holds. Pagination is off: the screens show the whole list and the
// provider does not paginate at the source anyway.
const { result: eventsResult, query: eventsQuery } = useList<FpEvent>({
resource: RESOURCE_EVENTS,
pagination: { mode: 'off' },
});
// The rows carry BOTH ids: Refine's `id` (the DOCUMENT NURI, which is what its
// `update` addresses) and `@id` (the SUBJECT IRI, which is what participations
// reference). `FpEventData.id` stays the subject IRI so `isParticipating` and
// `getEventParticipants` keep matching; the document NURI is looked up below.
const eventRows = eventsResult?.data ?? [];
// "No events" and "not read yet" are different answers, and the auto-seed below
// must not confuse them — an empty list while the query is still in flight is
// not an empty wallet.
const eventsRead = eventsQuery.isSuccess;
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyEvents: FpEventData[] = [];
const emptyUsers: FpUserData[] = [];
const emptyParticipations: FpParticipationData[] = [];
const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
const events = eventsShape.items;
const events = eventRows.map(mapEvent);
const users = usersShape.items;
const participations = participationsShape.items;
// --- Events: write through REFINE ----------------------------------------
const { mutateAsync: createEventRow } = useCreate<FpEvent>();
const { mutateAsync: updateEventRow } = useUpdate<FpEvent>();
/**
* Create ONE event as its own document. Returns the stored row, whose `@id`
* is the subject IRI the participations reference.
*
* `@type` IS NOT OPTIONAL. The data provider writes a hardcoded ActivityStreams
* `Note` type (the type of the example app it was written against) and spreads
* the caller's `values` AFTER it — so passing `@type` is what makes the record
* a Festipod Event rather than a Note. Drop it and the shape stops matching on
* read, and the event disappears.
*/
const createEventDoc = useCallback(
async (event: Omit<FpEventData, 'id'>): Promise<FpEvent | undefined> => {
const created = await createEventRow({
resource: RESOURCE_EVENTS,
values: eventFields(event),
});
return created?.data;
},
[createEventRow],
);
/**
* Overwrite an event, addressed by its app id (the subject IRI).
*
* The provider's `update` REPLACES the record — it deletes the old object and
* adds a new one built from `values` — so every persisted field has to be sent.
* A partial write here would silently drop whatever it left out, which is why
* the current row is read back and merged first.
*/
const writeEvent = useCallback(
async (id: string, updates: Partial<FpEventData>): Promise<void> => {
const row = eventRows.find(r => r['@id'] === id);
if (!row) {
console.warn('[FestipodData] updateEvent: no event known under', id);
return;
}
await updateEventRow({
resource: RESOURCE_EVENTS,
// Refine addresses the record by its DOCUMENT NURI, not its subject IRI.
id: row['@graph'],
values: eventFields({ ...mapEvent(row), ...updates }),
});
},
[eventRows, updateEventRow],
);
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
const [friendships, setFriendships] = useState<FpFriendshipData[]>([]);
@@ -315,9 +400,13 @@ function useNgData(): FestipodDataContextValue {
}, [events.length, selectedEventId]);
// Dev auto-seed: if the wallet is still empty 3s after the session is ready,
// bootstrap with seed data. `bootstrapWallet()` self-checks (ngSet.size > 0
// bootstrap with seed data. `bootstrapWallet()` self-checks (existing data
// → 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.
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
@@ -325,20 +414,25 @@ 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');
return;
}
hasTriedAutoSeed.current = true;
if (eventsShape.ngSet.size === 0 && usersShape.ngSet.size === 0) {
if (eventRows.length === 0 && usersShape.ngSet.size === 0) {
console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…');
bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
bootstrapWallet({
eventCount: eventRows.length,
seedEvent: createEventDoc,
ngUsers: usersShape.ngSet as any,
ngParticipations: participationsShape.ngSet as any,
}).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, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
}, [privateNuri, eventsRead, eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]);
// --- Derived ---
// Resolve current user from the chosen account username (the perceived login);
@@ -372,52 +466,29 @@ function useNgData(): FestipodDataContextValue {
const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || privateNuri || '';
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (NG):', event.title);
// Per-entity: in multistore each event is its OWN document. Mono-store: the
// private store. (Multistore create reactivity is best-effort — the new doc
// is appended to the read fan-out so it shows after re-subscribe.)
const eventGraph = MULTISTORE
? await createEntityDoc(username || '', 'public')
: (privateNuri || '');
if (MULTISTORE && eventGraph) {
setReadGraphs(prev =>
prev.public.includes(eventGraph) ? prev : { ...prev, public: [...prev.public, eventGraph] },
);
}
eventsShape.ngSet.add({
"@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
location: event.location, distance: event.distance,
participantCount: event.participantCount || 1,
coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials,
} as FpEvent);
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
console.log('[FestipodData] createEvent (Refine):', event.title);
// The data provider creates the document itself, one per record — so there is
// no graph to resolve here any more, and nothing to append to a read fan-out.
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: addedEvent["@id"], user: currentUserId, isConfirmed: true,
event: eventIri, user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
setSelectedEventId(eventIri);
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, privateNuri, username]);
return { ...event, id: eventIri || `ng-pending-${Date.now()}` };
}, [protectedGraph, createEventDoc, participationsShape.ngSet, currentUserId]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === id);
if (ngEvent) {
if (updates.title !== undefined) ngEvent.title = updates.title;
if (updates.description !== undefined) ngEvent.description = updates.description;
if (updates.date !== undefined) ngEvent.date = updates.date;
if (updates.location !== undefined) ngEvent.location = updates.location;
if (updates.distance !== undefined) ngEvent.distance = updates.distance;
if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount;
}
}, [eventsShape.ngSet]);
console.log('[FestipodData] updateEvent (Refine):', id, updates);
void writeEvent(id, updates);
}, [writeEvent]);
const joinEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
console.log('[FestipodData] joinEvent (Refine):', eventId, 'user:', uid);
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (existing) {
console.log('[FestipodData] Already participating, skipping');
@@ -427,25 +498,27 @@ function useNgData(): FestipodDataContextValue {
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = ngEvent.participantCount + 1;
// 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 => r["@id"] === eventId);
if (current) {
void writeEvent(eventId, { participantCount: current.participantCount + 1 });
}
}, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [protectedGraph, participationsShape.ngSet, eventRows, writeEvent, currentUserId]);
const leaveEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid);
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);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
const current = eventRows.find(r => r["@id"] === eventId);
if (current) {
void writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) });
}
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [participationsShape.ngSet, eventRows, writeEvent, currentUserId]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
@@ -476,12 +549,13 @@ function useNgData(): FestipodDataContextValue {
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (NG)');
return bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
);
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
return bootstrapWallet({
eventCount: eventRows.length,
seedEvent: createEventDoc,
ngUsers: usersShape.ngSet as any,
ngParticipations: participationsShape.ngSet as any,
});
}, [eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]);
return {
currentUserId, currentUser,
+49
View File
@@ -0,0 +1,49 @@
/**
* RefineProvider — mounts Refine over the NextGraph providers.
*
* This is the app's new data entry point for the Events vertical: screens keep
* calling `useFestipodData()`, and `FestipodDataContext` now reads and writes
* events through Refine's hooks, which land on `@ng-org/refine-providers`.
*
* NO ROUTER PROVIDER, deliberately. Festipod has its own path-based router
* (`app/router.tsx`); handing Refine a second one would give the app two actors
* navigating the same history. Refine works without it — only its navigation
* helpers (which this app does not call) go dormant.
*
* The providers are built ONCE at module scope, as the package's README does.
* Rebuilding the data provider on a render would hand Refine a new provider
* identity and re-run every query behind it.
*/
import type { ReactNode } from 'react';
import { Refine } from '@refinedev/core';
import { dataProvider, liveProvider } from '@ng-org/refine-providers';
import shapeTypes, { RESOURCE_EVENTS } from './shapeTypes';
import { createNgAuthProvider } from './ngAuthProvider';
const ngAuthProvider = createNgAuthProvider();
const ngDataProvider = dataProvider({ shapeTypes, authProvider: ngAuthProvider });
const ngLiveProvider = liveProvider({ shapeTypes });
export function RefineProvider({ children }: { children: ReactNode }) {
return (
<Refine
dataProvider={ngDataProvider}
liveProvider={ngLiveProvider}
authProvider={ngAuthProvider}
resources={[{ name: RESOURCE_EVENTS }]}
options={{
// `auto` is what makes the live provider actually subscribe behind every
// query — without it the ORM pushes changes and no hook is listening.
liveMode: 'auto',
// Nothing about this app's usage is sent anywhere.
disableTelemetry: true,
// The app has no router, so Refine's unsaved-changes prompt (which hooks
// navigation) has nothing to hook and would only warn.
warnWhenUnsavedChanges: false,
}}
>
{children}
</Refine>
);
}
+72
View File
@@ -0,0 +1,72 @@
/**
* ngAuthProvider — the `AuthProvider` that `@ng-org/refine-providers` asks to be
* handed, backed by Festipod's own NextGraph session.
*
* WHY NOT THE PACKAGE'S OWN `authProvider()`. Its factory calls `init(…)` from
* `@ng-org/web` the moment it runs, and then `initNg(ng, session)` — which is
* exactly, line for line, what `utils/ngSession.init()` already does. Using both
* would register TWO init callbacks against one WASM session: outside the broker
* iframe `init` REDIRECTS the page, so the second registration is a second
* redirect, and the two callbacks race to be the one that owns `session`.
*
* `DataProviderConfig` declares `authProvider` as an INJECTION POINT — the data
* provider only ever calls `getSession()` on it, to read a `session_id` for
* `doc_create`. So supplying Festipod's session here is using the published
* surface as it is meant to be used, not working around it: the session stays
* single, and `ngSession` stays the only module that calls `init`.
*
* The remaining `AuthProvider` members are Refine's auth contract. They are wired
* to what Festipod actually has — the technical NextGraph login/logout — rather
* than stubbed: `check` reports whether the wallet session is open, and nothing
* here invents an identity the app does not hold.
*/
import type { AuthProvider } from '@ng-org/refine-providers';
import type { Session } from '@ng-org/web';
import { session, login as ngLogin, logoutNg } from '../utils/ngSession';
export function createNgAuthProvider(): AuthProvider {
return {
/**
* The real technical login: opening the wallet page. `ngSession.login()`
* navigates away, so the resolved value is only reached when the broker
* round-trip did not happen (already logged in).
*/
login: async () => {
await ngLogin();
return { success: true };
},
logout: async () => {
await logoutNg();
return { success: true };
},
/**
* Authenticated = the NextGraph session is open. `AuthGate` is what actually
* gates the screens (it reads `NextGraphContext`), so no `redirectTo` is
* returned here: Refine has no router in this app and must not try to route.
*/
check: async () => ({ authenticated: session !== undefined }),
onError: async (error) => ({ error }),
/**
* The NextGraph session carries the wallet user id under `user` when one is
* open. Festipod's PERCEIVED login (the chosen username) lives in
* `AccountContext`, which is React state and not reachable from here — so
* this reports the technical identity only, and `undefined` when there is none.
*/
getIdentity: async () => {
const user = session?.user;
return typeof user === 'string' && user ? { id: user } : undefined;
},
/**
* The handle the data provider actually consumes. Festipod's
* `NextGraphSession` is the same object `@ng-org/web` hands its `init`
* callback; the cast only reconciles the two local declarations of it.
*/
getSession: () => session as Session | undefined,
};
}
+25
View File
@@ -0,0 +1,25 @@
/**
* The Refine RESOURCE MAP — the one place that says which Refine resource name
* stands for which SHEX shape.
*
* `@ng-org/refine-providers` identifies data by shape type, not by endpoint: its
* data provider looks `shapeTypes[resource]` up on every call, so a resource name
* that is absent here silently resolves to `undefined` and the read comes back
* empty. Every `useList` / `useOne` / `useCreate` / `useUpdate` call site must
* therefore name a resource declared below.
*
* Only `events` is wired: the Events vertical is what goes through Refine —
* profiles and participations still read through `useShapeWithDefaults`.
*/
import type { ShapeTypes } from '@ng-org/refine-providers';
import { FpEventShapeType } from '../shapes/orm/festipodShapes.shapeTypes';
/** Refine resource name for Festipod events. Import it — never retype the string. */
export const RESOURCE_EVENTS = 'events';
const shapeTypes: ShapeTypes = {
[RESOURCE_EVENTS]: FpEventShapeType,
};
export default shapeTypes;
+45 -25
View File
@@ -3,10 +3,20 @@
*
* Called once after NG connection + shapes ready. If the wallet already
* has events/users, it's a returning user — skip seeding.
*
* EVENTS ARE SEEDED THROUGH REFINE, not through an ORM set like users and
* participations. They have to be: the Refine data provider derives a record's
* `id` from its `@graph`, and it creates a fresh document on every `create`.
* Seeding events the old way — all of them into one shared graph — would give
* every seeded event the SAME Refine id, so the list would collide on itself and
* an update would address the whole document instead of one event. The seeder is
* injected rather than imported because `useCreate` is a React hook and this
* 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,
@@ -20,6 +30,21 @@ 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>;
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.
*/
eventCount: number;
seedEvent: EventSeeder;
ngUsers: DeepSignalSet<FpUserProfile>;
ngParticipations: DeepSignalSet<FpParticipation>;
}
/**
* Flush ORM microtask batch and give the broker time to process.
*
@@ -33,22 +58,21 @@ async function flushAndWait(ms = 100): Promise<void> {
}
export async function bootstrapWallet(
ngEvents: DeepSignalSet<FpEvent>,
ngUsers: DeepSignalSet<FpUserProfile>,
ngParticipations: DeepSignalSet<FpParticipation>,
{ eventCount, seedEvent, ngUsers, ngParticipations }: BootstrapDeps,
): Promise<BootstrapResult> {
// Already has data → returning user, nothing to seed
if (ngEvents.size > 0 || ngUsers.size > 0) {
console.log('[Bootstrap] Wallet already has data — events:', ngEvents.size,
if (eventCount > 0 || ngUsers.size > 0) {
console.log('[Bootstrap] Wallet already has data — events:', eventCount,
'users:', ngUsers.size, 'participations:', ngParticipations.size);
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
}
console.log('[Bootstrap] First time for this wallet — seeding default data...');
// Create (or get) a document in the private store for our data.
// The ORM requires a real document NURI as @graph, not the store ID.
const graph = await ensureGraphNuri(ngEvents, ngUsers, ngParticipations);
// 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
@@ -70,26 +94,22 @@ export async function bootstrapWallet(
}
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
// Seed events — one at a time with ORM flush between each
// 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`).
const eventIdMap = new Map<string, string>();
for (const e of seedEvents) {
ngEvents.add({
"@graph": graph,
"@type": "http://festipod.org/Event",
"@id": "",
title: e.title,
description: e.description,
date: e.date,
location: e.location,
distance: e.distance,
participantCount: e.participantCount,
coverImage: e.coverImage,
hostName: e.hostName,
hostInitials: e.hostInitials,
} as FpEvent);
const stored = await seedEvent(e);
await flushAndWait();
const added = [...ngEvents].find(ne => ne.title === e.title);
if (added) eventIdMap.set(e.id, added["@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.log('[Bootstrap] Seeded', eventIdMap.size, 'events');