diff --git a/src/modules/auth/steps/data/connexion.steps.ts b/src/modules/auth/steps/data/connexion.steps.ts index dfb0a3e..a77dac7 100644 --- a/src/modules/auth/steps/data/connexion.steps.ts +++ b/src/modules/auth/steps/data/connexion.steps.ts @@ -40,10 +40,13 @@ Given('le portefeuille contient déjà des événements', async function (this: const td = (window as any).__testData; td.loadTestData(); }); - // Wait for data to propagate + // Wait for data to propagate. `waitForFunction(fn, arg, options)` — the + // timeout goes in the THIRD slot; passed second it is silently taken as the + // predicate's argument and the wait runs on the 30s default instead. await this.appFrame!.waitForFunction( () => (window as any).__testData.events.size > 0, - { timeout: 10000 }, + undefined, + { timeout: 75000 }, ); } }); @@ -58,22 +61,37 @@ When('je charge les données de test', async function (this: FestipodWorld) { }); (this as any)._eventCountBefore = countBefore; - await this.appFrame!.evaluate(() => { + // AWAIT the seed's own promise (loadTestData returns a BootstrapResult promise) + // and record whether it actually seeded — so the propagation wait below can tell + // a genuinely-populated wallet (nothing to appear) from an empty one that must + // seed. Fire-and-forget here would let the assertions race the async seed. + const seededResult = await this.appFrame!.evaluate(async () => { const td = (window as any).__testData; - td.loadTestData(); + const r = await td.loadTestData(); + return { seeded: r?.seeded ?? false }; }); + (this as any)._loadSeeded = seededResult.seeded; - // Wait for data to propagate (if wallet was empty, data should appear) - await this.appFrame!.waitForFunction( - () => { - const td = (window as any).__testData; - // Either data was already there, or it should appear after loading - return td.events.size > 0 || td._loadResult?.seeded === false; - }, - { timeout: 10000 }, - ).catch(() => { - // Timeout is OK if wallet was already populated (idempotent case) - }); + // Wait for data to propagate, and for BOTH events and users — not just events. + // They arrive by two independent paths here: events through the Refine read, + // the seeded profiles through the ORM subscription on the protected scope. If + // only events are awaited, `contient des utilisateurs` asserts before the + // profile read lands and flakes to users:0. On a wallet that already had data + // (seeded === false) there is nothing to wait for. The assertions still verify + // the real counts; this only synchronizes. + if (seededResult.seeded) { + await this.appFrame!.waitForFunction( + () => { + const td = (window as any).__testData; + return td.events.size > 0 && td.users.size > 0; + }, + undefined, + { timeout: 75000 }, + ).catch(() => { + // Timeout tolerated — the assertions below surface the real failure with a + // clearer message than a raw waitForFunction timeout. + }); + } }); // --- Assertions --- diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index becd215..9f1e1f2 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -21,6 +21,7 @@ import { applyIsolation } from '../utils/isolation'; import { ensureAccount, resolveReadGraphs, resolveWriteGraph } from '../utils/storeRegistry'; import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults'; import { RESOURCE_EVENTS } from '../refine/shapeTypes'; +import { useBackend } from '../refine/RefineProvider'; // Multi-document mode (storeRegistry): one document per (account × scope), // mirroring the target per-user stores. Default OFF — the validated mono-store @@ -97,36 +98,13 @@ function findNg(set: Set, predicate: (item: T) = return undefined; } -// NG shape → app type mappers -const mapEvent = (e: FpEvent): FpEventData => ({ - id: e["@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, -}); - -/** - * 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) => ({ - "@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, -}); +// EVENTS are read and written through the selected Refine BACKEND, so the record +// shape, the vocabulary and the two identifiers are its business, not this file's +// — see refine/providers/types.ts. The binding is read from `useBackend()` inside +// the provider below, NOT captured here: a module-level constant would pin the app +// to whichever backend was current at import time, which is exactly what makes a +// backend unswitchable. Everything else in this file still speaks `fp:` directly, +// because profiles and participations have not moved to Refine yet. const mapUser = (u: FpUserProfile): FpUserData => ({ id: u["@id"], @@ -266,6 +244,9 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { function useNgData(): FestipodDataContextValue { const { session } = useNextGraph(); 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; // 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; @@ -331,7 +312,7 @@ function useNgData(): FestipodDataContextValue { const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true); const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true); - const events = eventRows.map(mapEvent); + const events = eventRows.map(eventBinding.toDomain); const users = usersShape.items; const participations = participationsShape.items; @@ -340,49 +321,50 @@ function useNgData(): FestipodDataContextValue { const { mutateAsync: updateEventRow } = useUpdate(); /** - * 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. + * Create ONE event and return the stored record. The backend's binding supplies + * the values, including whatever type marker that backend needs — see its + * `toValues`, and `providers/types.ts` for why that is not this file's business. */ const createEventDoc = useCallback( async (event: Omit): Promise => { const created = await createEventRow({ resource: RESOURCE_EVENTS, - values: eventFields(event), + values: eventBinding.toValues(event), }); return created?.data; }, - [createEventRow], + [createEventRow, eventBinding], ); /** - * Overwrite an event, addressed by its app id (the subject IRI). + * Update an event, addressed by its APP id — which is not necessarily the key + * Refine addresses the record by, so the binding answers both. * - * 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. + * When the backend's `update` REPLACES the record, every persisted field has to + * be sent or whatever is omitted is silently dropped; that is why the current + * record is read back and merged first. A backend that patches does not need + * the merge, so the binding says which it is rather than this file assuming. */ const writeEvent = useCallback( async (id: string, updates: Partial): Promise => { - const row = eventRows.find(r => r['@id'] === id); + const row = eventRows.find(r => eventBinding.domainId(r) === id); if (!row) { console.warn('[FestipodData] updateEvent: no event known under', id); return; } + // Replacing → send the merged WHOLE record. Patching → send only what + // changed, so a concurrent edit to another field is not overwritten by a + // value this render happened to be holding. + const values = eventBinding.updateReplaces + ? eventBinding.toValues({ ...eventBinding.toDomain(row), ...updates }) + : eventBinding.toPatch(updates); 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 }), + id: eventBinding.recordKey(row), + values, }); }, - [eventRows, updateEventRow], + [eventRows, updateEventRow, eventBinding], ); // Not in SHEX shapes yet @@ -509,7 +491,7 @@ function useNgData(): FestipodDataContextValue { } as FpParticipation); // 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); + const current = eventRows.find(r => eventBinding.domainId(r) === eventId); if (current) { void writeEvent(eventId, { participantCount: current.participantCount + 1 }); } @@ -522,7 +504,7 @@ function useNgData(): FestipodDataContextValue { if (ngPart) { console.log('[FestipodData] Deleting participation via ngSet.delete():', ngPart["@id"]); participationsShape.ngSet.delete(ngPart); - const current = eventRows.find(r => r["@id"] === eventId); + const current = eventRows.find(r => eventBinding.domainId(r) === eventId); if (current) { void writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) }); } diff --git a/src/shared/refine/RefineProvider.tsx b/src/shared/refine/RefineProvider.tsx index a600155..1715cde 100644 --- a/src/shared/refine/RefineProvider.tsx +++ b/src/shared/refine/RefineProvider.tsx @@ -1,49 +1,100 @@ /** - * RefineProvider — mounts Refine over the NextGraph providers. + * RefineProvider — mounts Refine over the SELECTED backend, and owns the selection. * - * 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`. + * This is the app's data entry point for the Events vertical: screens keep calling + * `useFestipodData()`, and `FestipodDataContext` reads and writes events through + * Refine's hooks, which land on whichever backend is current. + * + * SWITCHING AT RUNTIME is what shapes this file. The selection is React state, and + * `` carries the backend name as its `key`, so switching REMOUNTS it: + * Refine builds its own query client inside, and remounting is what discards the + * cache of the backend being left. Without that key the new backend would serve + * its first reads out of the old one's cached rows. + * + * Leaving a backend loses access to its data — accepted, and nothing is migrated. + * What is NOT solved here is the sign-in: `AuthGate` still gates on the NextGraph + * session, so a second backend needs its own barrier before it can actually be + * reached. That is the next piece, not this one. * * 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 { createContext, useCallback, useContext, useMemo, useState, 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'; +import { getBackend, initialBackendName, type RefineBackend } from './providers'; -const ngAuthProvider = createNgAuthProvider(); -const ngDataProvider = dataProvider({ shapeTypes, authProvider: ngAuthProvider }); -const ngLiveProvider = liveProvider({ shapeTypes }); +interface BackendSelection { + backend: RefineBackend; + /** Switch backends. The data of the one being left becomes unreachable. */ + switchTo(name: string): void; +} + +const BackendContext = createContext(null); + +/** + * The current backend. Read it rather than importing one: a module-level import + * of a specific backend is what makes it unswitchable. + */ +export function useBackend(): RefineBackend { + const selection = useContext(BackendContext); + if (!selection) { + throw new Error('useBackend must be used within a RefineProvider'); + } + return selection.backend; +} + +/** The selection itself, for whatever eventually offers the choice. */ +export function useBackendSelection(): BackendSelection { + const selection = useContext(BackendContext); + if (!selection) { + throw new Error('useBackendSelection must be used within a RefineProvider'); + } + return selection; +} export function RefineProvider({ children }: { children: ReactNode }) { + const [name, setName] = useState(initialBackendName); + const backend = useMemo(() => getBackend(name), [name]); + + // Built once PER BACKEND, not per render: a fresh provider identity makes Refine + // re-run every query behind it. + const providers = useMemo(() => { + console.log(`[refine] backend: ${backend.name}`); + return backend.build(); + }, [backend]); + + const switchTo = useCallback((next: string) => { + // Resolved here so an unknown name fails at the call, not on the next render. + getBackend(next); + setName(next); + }, []); + + const selection = useMemo(() => ({ backend, switchTo }), [backend, switchTo]); + return ( - - {children} - + + ({ 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. + 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} + + ); } diff --git a/src/shared/refine/providers/index.ts b/src/shared/refine/providers/index.ts new file mode 100644 index 0000000..65b9d61 --- /dev/null +++ b/src/shared/refine/providers/index.ts @@ -0,0 +1,73 @@ +/** + * The backend registry — which Refine backends exist, and which one starts. + * + * SWITCHING IS MEANT TO HAPPEN AT RUNTIME, so nothing here is resolved at module + * scope: a module-level `const backend = …` is precisely what makes a backend + * unswitchable. The current one lives in React state, held by `RefineProvider`, + * and consumers read it through `useBackend()`. The build-time global below only + * decides which one is selected FIRST. + * + * Leaving a backend loses access to its data, and that is accepted: each holds its + * own behind its own sign-in, and nothing is migrated between them. + * + * There is no selector screen yet — that is interface work, not seam work. + * + * ADDING A BACKEND is one file implementing `RefineBackend`, plus one line here. + * What that file must answer is not negotiable and cannot be guessed — see + * `types.ts`: how its providers are configured, what its records look like, which + * key addresses a record versus which id the app references, and whether its + * `update` replaces or patches. + * + * `@activitypods/refine-providers` is the next candidate, and two of those four + * are already known from its published configuration: providers take + * `resources: { events: { shapeTreeUri } }` plus a `jsonContext`, and auth is + * Solid-OIDC with a `clientId`. The other two — its record shape and whether + * `update` patches — are not, and neither is the question that gates the whole + * thing: it declares resources in ActivityStreams, while Festipod persists `fp:` + * shapes. `as:Event` exists; a meeting point, the product's unit of value, has no + * obvious ActivityStreams equivalent. That has to be settled before an adapter + * can be written rather than invented. + */ + +import { nextgraphBackend } from './nextgraph'; +import type { RefineBackend } from './types'; + +export type { RecordBinding, RefineBackend } from './types'; + +const BACKENDS: Record = { + [nextgraphBackend.name]: nextgraphBackend, +}; + +// Build-injected, same plumbing as the other build-time globals (build.ts +// `define`s it). Absent → the default below, so nothing has to be configured. +declare global { + // eslint-disable-next-line no-var + var __FESTIPOD_BACKEND__: string | undefined; +} + +const DEFAULT_BACKEND = nextgraphBackend.name; + +/** Every registered backend — what a selector would offer. */ +export function listBackends(): RefineBackend[] { + return Object.values(BACKENDS); +} + +/** + * Look a backend up by name. An unknown name throws rather than falling back: + * running against a different store than the one asked for is worse than + * failing, because nothing downstream would look wrong. + */ +export function getBackend(name: string): RefineBackend { + const backend = BACKENDS[name]; + if (!backend) { + throw new Error( + `Unknown Refine backend "${name}" — known: ${Object.keys(BACKENDS).join(', ')}`, + ); + } + return backend; +} + +/** Which backend is selected when the app starts. */ +export function initialBackendName(): string { + return globalThis.__FESTIPOD_BACKEND__ || DEFAULT_BACKEND; +} diff --git a/src/shared/refine/providers/nextgraph.ts b/src/shared/refine/providers/nextgraph.ts new file mode 100644 index 0000000..de5f32a --- /dev/null +++ b/src/shared/refine/providers/nextgraph.ts @@ -0,0 +1,89 @@ +/** + * The NextGraph backend — `@ng-org/refine-providers` over the wallet this + * deployment already opens. + * + * Everything backend-specific that used to sit inline in `FestipodDataContext` + * lives here: the `fp:` vocabulary, the two identifiers, and the fact that this + * provider's `update` replaces a record rather than patching it. + */ + +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 { createNgAuthProvider } from '../ngAuthProvider'; +import type { RecordBinding, RefineBackend } from './types'; + +/** The RDF class Festipod events are persisted as. */ +const EVENT_TYPE = 'http://festipod.org/Event'; + +const eventBinding: RecordBinding = { + // 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'], + + toDomain: (record) => { + const e = record as FpEvent; + return { + id: e['@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, + }; + }, + + // `@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. Drop it and the shape stops matching on read. + toValues: (data) => ({ + '@type': EVENT_TYPE, + title: data.title, + description: data.description, + date: data.date, + location: data.location, + distance: data.distance, + participantCount: data.participantCount, + coverImage: data.coverImage, + hostName: data.hostName, + hostInitials: data.hostInitials, + }), + + // Measured: `update` deletes the old object and adds one built from `values`. + updateReplaces: true, + + // 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'); + }, +}; + +export const nextgraphBackend: RefineBackend = { + name: 'nextgraph', + resources: [RESOURCE_EVENTS], + event: eventBinding, + 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. + const authProvider = createNgAuthProvider(); + return { + authProvider, + dataProvider: dataProvider({ shapeTypes, authProvider }), + liveProvider: liveProvider({ shapeTypes }), + }; + }, +}; diff --git a/src/shared/refine/providers/types.ts b/src/shared/refine/providers/types.ts new file mode 100644 index 0000000..4e4611f --- /dev/null +++ b/src/shared/refine/providers/types.ts @@ -0,0 +1,74 @@ +/** + * The contract a Refine BACKEND must satisfy to serve Festipod. + * + * Refine's own `DataProvider` / `AuthProvider` / `LiveProvider` already abstract + * the transport, so they are not what this file adds. What they do NOT abstract + * is everything around them, and each item below exists because the Events + * vertical ran into it against `@ng-org/refine-providers` — none of it is + * anticipated: + * + * - **Configuration.** `@ng-org` takes SHEX shape types; `@activitypods` takes + * `resources: { events: { shapeTreeUri } }` plus a `jsonContext`. There is no + * common shape, so building the providers belongs to the backend. + * - **Record shape.** A record's fields are the backend's vocabulary. `@ng-org` + * yields `fp:` predicates with `@id` / `@graph` / `@type`; ActivityPods yields + * ActivityStreams JSON-LD. So domain mapping belongs to the backend too. + * - **Two identifiers.** Refine addresses a record by one id, and the app's + * entities reference each other by another. Under `@ng-org` those differ: the + * address is the document NURI (`@graph`), while a participation points at the + * subject IRI (`@id`). Collapsing them silently breaks the references. + * - **Update semantics.** `@ng-org`'s `update` REPLACES the record, so a partial + * write drops every field it omits. A backend that patches would not need the + * read-merge the app does today, so the app has to be told which it is. + * + * A backend that cannot answer one of these cannot be added by guessing: the + * answer is a fact about that backend, to be read from its contract or measured. + */ + +import type { AuthProvider, BaseKey, DataProvider, LiveProvider } from '@refinedev/core'; +import type { FpEventData } from '../../data/types'; + +/** How one entity's records translate to and from the app's domain type. */ +export interface RecordBinding { + /** + * The key Refine's `update` / `getOne` address this record by. NOT necessarily + * the domain id — see "Two identifiers" above. + */ + recordKey(record: unknown): BaseKey; + /** The id the APP and its other entities use to reference this record. */ + domainId(record: unknown): string; + /** Read a backend record into the app's domain type. */ + toDomain(record: unknown): T; + /** + * The values to send on create, and on update when the backend REPLACES: the + * complete field set, because anything omitted is dropped. + */ + toValues(data: Omit): Record; + /** + * The values to send when the backend PATCHES — only the fields that changed, + * so a concurrent edit elsewhere in the record survives. Never called when + * `updateReplaces` is true. + */ + toPatch(updates: Partial): Record; + /** True when `update` replaces the record rather than patching it. */ + readonly updateReplaces: boolean; +} + +/** Everything Festipod needs from one Refine backend. */ +export interface RefineBackend { + /** Short, meaning-bearing name — used in logs and, later, in a selector. */ + readonly name: string; + /** + * Build this backend's Refine providers, with its own configuration. Called + * ONCE: a new provider identity makes Refine re-run every query behind it. + */ + build(): { + dataProvider: DataProvider; + authProvider?: AuthProvider; + liveProvider?: LiveProvider; + }; + /** Resource names this backend serves, for ``. */ + readonly resources: readonly string[]; + /** The Events vertical is the only one wired so far. */ + readonly event: RecordBinding; +}