Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b4dfa6cad | |||
| 974d251079 | |||
| fd02570b8b |
@@ -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().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,
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Refine } from '@refinedev/core';
|
||||
import { getBackend, initialBackendName, type RefineBackend } from './providers';
|
||||
import { getBackend, initialBackendName, resourceNames, type RefineBackend } from './providers';
|
||||
|
||||
interface BackendSelection {
|
||||
backend: RefineBackend;
|
||||
@@ -81,7 +81,7 @@ export function RefineProvider({ children }: { children: ReactNode }) {
|
||||
dataProvider={providers.dataProvider}
|
||||
liveProvider={providers.liveProvider}
|
||||
authProvider={providers.authProvider}
|
||||
resources={backend.resources.map((resource) => ({ name: resource }))}
|
||||
resources={resourceNames(backend).map((resource) => ({ name: resource }))}
|
||||
options={{
|
||||
// `auto` is what makes the live provider actually subscribe behind every
|
||||
// query — without it the ORM pushes changes and no hook is listening.
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
import { nextgraphBackend } from './nextgraph';
|
||||
import type { RefineBackend } from './types';
|
||||
|
||||
export type { RecordBinding, RefineBackend } from './types';
|
||||
export type { RecordBinding, RecordBindings, RefineBackend } from './types';
|
||||
export { resourceNames } from './types';
|
||||
|
||||
const BACKENDS: Record<string, RefineBackend> = {
|
||||
[nextgraphBackend.name]: nextgraphBackend,
|
||||
|
||||
@@ -9,24 +9,51 @@
|
||||
|
||||
import { dataProvider, liveProvider } from '@ng-org/refine-providers';
|
||||
import type { BaseKey } from '@refinedev/core';
|
||||
import type { FpEventData } from '../../data/types';
|
||||
import type { FpEvent } from '../../shapes/orm/festipodShapes.typings';
|
||||
import shapeTypes, { RESOURCE_EVENTS } from '../shapeTypes';
|
||||
import type { FpEventData, FpParticipationData, FpUserData } from '../../data/types';
|
||||
import type { FpEvent, FpParticipation, FpUserProfile } from '../../shapes/orm/festipodShapes.typings';
|
||||
import shapeTypes, {
|
||||
RESOURCE_EVENTS,
|
||||
RESOURCE_PARTICIPATIONS,
|
||||
RESOURCE_PROFILES,
|
||||
} from '../shapeTypes';
|
||||
import { createNgAuthProvider } from '../ngAuthProvider';
|
||||
import type { RecordBinding, RefineBackend } from './types';
|
||||
|
||||
/** The RDF class Festipod events are persisted as. */
|
||||
const EVENT_TYPE = 'http://festipod.org/Event';
|
||||
/**
|
||||
* The RDF classes Festipod persists. These IRIs ARE the stored data — renaming
|
||||
* one renames what is on disk, so they are never adjusted for convenience.
|
||||
*/
|
||||
const TYPE = {
|
||||
event: 'http://festipod.org/Event',
|
||||
profile: 'http://festipod.org/UserProfile',
|
||||
participation: 'http://festipod.org/Participation',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shared by every binding here: this provider addresses a record by the DOCUMENT
|
||||
* NURI, while the app's entities reference each other by the SUBJECT IRI. One
|
||||
* document per record keeps them 1-1, but they are different strings and `update`
|
||||
* only accepts the first.
|
||||
*/
|
||||
const recordKey = (record: unknown): BaseKey =>
|
||||
(record as { '@graph': string })['@graph'] as BaseKey;
|
||||
const domainId = (record: unknown): string => (record as { '@id': string })['@id'];
|
||||
|
||||
/** This provider's `update` deletes the old object and adds one built from values. */
|
||||
const replaces = {
|
||||
updateReplaces: true as const,
|
||||
toPatch: (): never => {
|
||||
throw new Error('nextgraph: update replaces the record — send the whole of it, not a patch');
|
||||
},
|
||||
};
|
||||
|
||||
const eventBinding: RecordBinding<FpEventData> = {
|
||||
// Refine addresses a record by its DOCUMENT NURI. `create` makes one document
|
||||
// per record, so this is 1-1 with the subject IRI below — but it is a different
|
||||
// string, and `update` only accepts this one.
|
||||
recordKey: (record) => (record as FpEvent)['@graph'] as BaseKey,
|
||||
|
||||
// What the rest of the app references: a participation's `fp:event` holds the
|
||||
// SUBJECT IRI, so that is the id `FpEventData` must carry.
|
||||
domainId: (record) => (record as FpEvent)['@id'],
|
||||
resource: RESOURCE_EVENTS,
|
||||
// Events are what other users must find — see concept `functional-domain`.
|
||||
visibility: 'public',
|
||||
recordKey,
|
||||
domainId,
|
||||
...replaces,
|
||||
|
||||
toDomain: (record) => {
|
||||
const e = record as FpEvent;
|
||||
@@ -49,7 +76,7 @@ const eventBinding: RecordBinding<FpEventData> = {
|
||||
// the caller's values AFTER it, so passing `@type` is what makes the record a
|
||||
// Festipod Event. Drop it and the shape stops matching on read.
|
||||
toValues: (data) => ({
|
||||
'@type': EVENT_TYPE,
|
||||
'@type': TYPE.event,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
date: data.date,
|
||||
@@ -60,22 +87,73 @@ const eventBinding: RecordBinding<FpEventData> = {
|
||||
hostName: data.hostName,
|
||||
hostInitials: data.hostInitials,
|
||||
}),
|
||||
};
|
||||
|
||||
// Measured: `update` deletes the old object and adds one built from `values`.
|
||||
updateReplaces: true,
|
||||
const profileBinding: RecordBinding<FpUserData> = {
|
||||
resource: RESOURCE_PROFILES,
|
||||
// A profile is readable by one's network, not by everyone.
|
||||
visibility: 'protected',
|
||||
recordKey,
|
||||
domainId,
|
||||
...replaces,
|
||||
|
||||
// Never reached while `updateReplaces` is true. It exists so a patching backend
|
||||
// has somewhere to put its answer, and it fails loudly rather than sending a
|
||||
// partial record to a provider that would drop everything else.
|
||||
toPatch: () => {
|
||||
throw new Error('nextgraph: update replaces the record — send the whole of it, not a patch');
|
||||
toDomain: (record) => {
|
||||
const u = record as FpUserProfile;
|
||||
return {
|
||||
id: u['@id'],
|
||||
name: u.name,
|
||||
initials: u.initials,
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
isPublic: u.isPublic,
|
||||
};
|
||||
},
|
||||
|
||||
toValues: (data) => ({
|
||||
'@type': TYPE.profile,
|
||||
name: data.name,
|
||||
initials: data.initials,
|
||||
username: data.username,
|
||||
role: data.role,
|
||||
isPublic: data.isPublic,
|
||||
}),
|
||||
};
|
||||
|
||||
const participationBinding: RecordBinding<FpParticipationData> = {
|
||||
resource: RESOURCE_PARTICIPATIONS,
|
||||
visibility: 'protected',
|
||||
recordKey,
|
||||
domainId,
|
||||
...replaces,
|
||||
|
||||
// `fp:event` and `fp:user` hold SUBJECT IRIs, which is why `domainId` above is
|
||||
// the subject and not the document: these references resolve against it.
|
||||
toDomain: (record) => {
|
||||
const p = record as FpParticipation;
|
||||
return {
|
||||
id: p['@id'],
|
||||
eventId: p.event,
|
||||
userId: p.user,
|
||||
isConfirmed: p.isConfirmed,
|
||||
};
|
||||
},
|
||||
|
||||
toValues: (data) => ({
|
||||
'@type': TYPE.participation,
|
||||
event: data.eventId,
|
||||
user: data.userId,
|
||||
isConfirmed: data.isConfirmed,
|
||||
}),
|
||||
};
|
||||
|
||||
export const nextgraphBackend: RefineBackend = {
|
||||
name: 'nextgraph',
|
||||
resources: [RESOURCE_EVENTS],
|
||||
event: eventBinding,
|
||||
bindings: {
|
||||
event: eventBinding,
|
||||
profile: profileBinding,
|
||||
participation: participationBinding,
|
||||
// meetingPoint: pending its SHEX shape — see types.ts.
|
||||
},
|
||||
build: () => {
|
||||
// Festipod's own AuthProvider, not the package's — see ngAuthProvider for why
|
||||
// a second `init()` on one WASM session is a collision, not a convenience.
|
||||
|
||||
@@ -26,7 +26,12 @@
|
||||
*/
|
||||
|
||||
import type { AuthProvider, BaseKey, DataProvider, LiveProvider } from '@refinedev/core';
|
||||
import type { FpEventData } from '../../data/types';
|
||||
import type {
|
||||
FpEventData,
|
||||
FpMeetingPointData,
|
||||
FpParticipationData,
|
||||
FpUserData,
|
||||
} from '../../data/types';
|
||||
|
||||
/** How one entity's records translate to and from the app's domain type. */
|
||||
export interface RecordBinding<T extends { id: string }> {
|
||||
@@ -52,6 +57,41 @@ export interface RecordBinding<T extends { id: string }> {
|
||||
toPatch(updates: Partial<T>): Record<string, unknown>;
|
||||
/** True when `update` replaces the record rather than patching it. */
|
||||
readonly updateReplaces: boolean;
|
||||
/**
|
||||
* Which Refine resource these records live under. The NAME is the backend's,
|
||||
* not the app's: `@ng-org` keys `shapeTypes` by it, `@activitypods` keys
|
||||
* `resources` by it, and nothing says the two must agree on a spelling.
|
||||
*/
|
||||
readonly resource: string;
|
||||
/**
|
||||
* WHO MUST BE ABLE TO READ these records. Festipod's confidentiality model is
|
||||
* per-entity (concept `functional-domain`), and each backend enforces it its own
|
||||
* way — NextGraph by placing a document in a store, ActivityPods by shape trees
|
||||
* and ACLs. Refine itself has NO notion of it, so the intent is declared here
|
||||
* and the backend is what honours it.
|
||||
*
|
||||
* Naming it does not make it enforced: the NextGraph data provider currently
|
||||
* reads across every graph it can see, so this records the INTENT and marks
|
||||
* where a backend has to close the gap.
|
||||
*/
|
||||
readonly visibility: 'public' | 'protected' | 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
* The entities the app binds. One key per domain type it persists.
|
||||
*
|
||||
* `meetingPoint` is OPTIONAL only because its SHEX shape does not exist on this
|
||||
* branch yet — adding it means regenerating the ORM bindings, which is its own
|
||||
* step with its own failure mode (the generator drops the `Fp` prefix, since the
|
||||
* emitted name follows the shape IRI). Once that lands it becomes required like
|
||||
* the others. `Friendship` is deliberately absent: it has no shape and no
|
||||
* persistence anywhere in this project.
|
||||
*/
|
||||
export interface RecordBindings {
|
||||
event: RecordBinding<FpEventData>;
|
||||
profile: RecordBinding<FpUserData>;
|
||||
participation: RecordBinding<FpParticipationData>;
|
||||
meetingPoint?: RecordBinding<FpMeetingPointData>;
|
||||
}
|
||||
|
||||
/** Everything Festipod needs from one Refine backend. */
|
||||
@@ -67,8 +107,11 @@ export interface RefineBackend {
|
||||
authProvider?: AuthProvider;
|
||||
liveProvider?: LiveProvider;
|
||||
};
|
||||
/** Resource names this backend serves, for `<Refine resources>`. */
|
||||
readonly resources: readonly string[];
|
||||
/** The Events vertical is the only one wired so far. */
|
||||
readonly event: RecordBinding<FpEventData>;
|
||||
/** One binding per entity the app persists. */
|
||||
readonly bindings: RecordBindings;
|
||||
}
|
||||
|
||||
/** Every resource name a backend serves, derived from its bindings. */
|
||||
export function resourceNames(backend: RefineBackend): string[] {
|
||||
return [...new Set(Object.values(backend.bindings).map((b) => b.resource))];
|
||||
}
|
||||
|
||||
@@ -5,21 +5,30 @@
|
||||
* `@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.
|
||||
* empty — no error, just nothing. 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`.
|
||||
* These names are the NextGraph backend's, which is why this file lives beside it
|
||||
* rather than in the app: another backend keys its own configuration by its own
|
||||
* names, and nothing says the two must agree on a spelling.
|
||||
*/
|
||||
|
||||
import type { ShapeTypes } from '@ng-org/refine-providers';
|
||||
import { FpEventShapeType } from '../shapes/orm/festipodShapes.shapeTypes';
|
||||
import {
|
||||
FpEventShapeType,
|
||||
FpParticipationShapeType,
|
||||
FpUserProfileShapeType,
|
||||
} from '../shapes/orm/festipodShapes.shapeTypes';
|
||||
|
||||
/** Refine resource name for Festipod events. Import it — never retype the string. */
|
||||
/** Resource names. Import them — never retype the strings. */
|
||||
export const RESOURCE_EVENTS = 'events';
|
||||
export const RESOURCE_PROFILES = 'profiles';
|
||||
export const RESOURCE_PARTICIPATIONS = 'participations';
|
||||
|
||||
const shapeTypes: ShapeTypes = {
|
||||
[RESOURCE_EVENTS]: FpEventShapeType,
|
||||
[RESOURCE_PROFILES]: FpUserProfileShapeType,
|
||||
[RESOURCE_PARTICIPATIONS]: FpParticipationShapeType,
|
||||
};
|
||||
|
||||
export default shapeTypes;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Before, After, BeforeAll, AfterAll, Status, setDefaultTimeout } from '@cucumber/cucumber';
|
||||
import { chromium, type Browser, type BrowserContext, type Page, type Frame } from 'playwright';
|
||||
import { execSync, spawn, type ChildProcess } from 'child_process';
|
||||
import { execSync, spawn, spawnSync, type ChildProcess } from 'child_process';
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
@@ -37,6 +37,84 @@ const HARNESS_NG_OUT = path.join('dist', 'test-harness-ng.js');
|
||||
// Persistent Chromium profile for NG wallet (not the user's daily browser)
|
||||
const PLAYWRIGHT_PROFILE = path.resolve('.playwright-profile');
|
||||
|
||||
/**
|
||||
* Rotate the profile once it exceeds this many MB. Override with
|
||||
* FESTIPOD_PROFILE_BLOAT_MB; 0 disables the rotation entirely.
|
||||
*/
|
||||
const BLOAT_THRESHOLD_MB = Number(process.env.FESTIPOD_PROFILE_BLOAT_MB ?? 50);
|
||||
|
||||
function dirSizeMB(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
try {
|
||||
const result = spawnSync('du', ['-sm', dir], { encoding: 'utf-8' });
|
||||
const line = result.stdout.trim().split('\n')[0] ?? '';
|
||||
return parseInt(line.split('\t')[0] ?? '0', 10);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any stale Chromium singleton files from the profile. Chromium refuses to
|
||||
* launch (ProcessSingleton error) if SingletonLock, SingletonCookie or
|
||||
* SingletonSocket are left over from a previous crashed run. Idempotent.
|
||||
*/
|
||||
function cleanSingletons(profilePath: string): void {
|
||||
if (!fs.existsSync(profilePath)) return;
|
||||
for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
|
||||
const f = path.join(profilePath, name);
|
||||
if (!fs.existsSync(f)) continue;
|
||||
try {
|
||||
fs.rmSync(f, { force: true });
|
||||
console.log(`[rotate] removed stale ${name}.`);
|
||||
} catch {
|
||||
// Non-fatal: if it cannot be removed, the launch fails with a clear error.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ROTATE THE TEST PROFILE when it has grown past the threshold.
|
||||
*
|
||||
* WHY THIS RUNS UNATTENDED. The @data suite writes into one wallet that nothing
|
||||
* empties, so every run leaves more behind. Past some volume the broker stops
|
||||
* answering in time and scenarios die on their step timeout — SILENTLY, nothing
|
||||
* in the console. That failure looks exactly like a bug in whatever was last
|
||||
* edited, and gets diagnosed as one: it happened twice in a single session before
|
||||
* anybody measured the profile. A guard nobody has to remember is the point.
|
||||
*
|
||||
* On THIS branch the rotation resets the DATA, not just the browser state:
|
||||
* nothing here serves a shared wallet file (`ngSession` configures none, and the
|
||||
* `.ngw` is used only by the @e2e staging build), so the @data wallet is the
|
||||
* broker-side one reached through this profile's storage. Removing the profile
|
||||
* takes the `.wallet-ready` marker with it and `ensureAuth` creates a fresh
|
||||
* wallet. The sibling branch documents the opposite, correctly for itself —
|
||||
* there the data lives in the served file and survives this.
|
||||
*
|
||||
* NOT free, and not quiet about it: the new wallet is created by driving the real
|
||||
* broker's sign-up, so each rotation leaves an abandoned wallet behind on it.
|
||||
* That is the price of a measurable suite; `FESTIPOD_PROFILE_BLOAT_MB=0` opts out.
|
||||
*/
|
||||
function rotateProfileIfBloated(): void {
|
||||
if (BLOAT_THRESHOLD_MB <= 0) {
|
||||
console.log('[rotate] disabled (FESTIPOD_PROFILE_BLOAT_MB=0)');
|
||||
return;
|
||||
}
|
||||
const sizeMB = dirSizeMB(PLAYWRIGHT_PROFILE);
|
||||
if (sizeMB > BLOAT_THRESHOLD_MB) {
|
||||
console.log(`[rotate] profile ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating (a NEW broker wallet will be created)`);
|
||||
try {
|
||||
fs.rmSync(PLAYWRIGHT_PROFILE, { recursive: true, force: true });
|
||||
console.log('[rotate] profile removed — it will be recreated, with empty data.');
|
||||
} catch (e) {
|
||||
console.warn(`[rotate] failed to remove the profile: ${e}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
cleanSingletons(PLAYWRIGHT_PROFILE);
|
||||
console.log(`[rotate] profile ${sizeMB}MB — below threshold, keeping it.`);
|
||||
}
|
||||
|
||||
let harnessServer: http.Server | null = null;
|
||||
let harnessPort = 0;
|
||||
let useRealBroker = false;
|
||||
@@ -345,6 +423,13 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
|
||||
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle --format=iife`, { stdio: 'pipe' });
|
||||
console.log(`[Harness] Built NG (${(fs.statSync(HARNESS_NG_OUT).size / 1024).toFixed(0)} KB)`);
|
||||
|
||||
// BEFORE touching the wallet: rotate the profile if it has bloated past the
|
||||
// point where the broker stops answering in time. Here and not earlier in
|
||||
// BeforeAll, because this is the branch that actually uses the profile — the
|
||||
// mock fallback below never opens it, and rotating it would cost a broker
|
||||
// sign-up for nothing.
|
||||
rotateProfileIfBloated();
|
||||
|
||||
// Ensure wallet exists in persistent profile (opens browser if needed)
|
||||
await ensureAuth();
|
||||
useRealBroker = true;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user