Make the Refine backend a choice rather than a wiring

A backend now answers a contract, and each of its four points is there because
the Events vertical ran into it: how its providers are configured (SHEX shape
types here, resources+jsonContext for ActivityPods), what its records look like,
which key Refine addresses a record by versus which id the app references, and
whether its update replaces or patches. Nothing in it is anticipated.

Switching happens at runtime, so nothing resolves at module scope: the current
backend is React state held by RefineProvider, read through useBackend(), and
<Refine> carries its name as key so switching remounts it and drops the cache
of the backend being left. Leaving one loses access to its data, accepted.

Not done, and not guessed: no activitypods backend. Its configuration is
published, its record shape and update semantics are not, and it declares
resources in ActivityStreams while Festipod persists fp: shapes -- as:Event
exists, a meeting point does not. And AuthGate still gates on the NextGraph
session, so a second backend needs its own barrier to be reachable at all.

The @data budgets in connexion.steps.ts came from ng-eventually, which had
already met this: the timeout was in waitForFunction's SECOND slot, where it is
taken as the predicate's argument, so the wait silently ran on the 30s default.
This commit is contained in:
Sylvain Duchesne
2026-09-28 16:08:13 +02:00
parent 750538bd73
commit efdfcac4c1
6 changed files with 388 additions and 101 deletions
+33 -15
View File
@@ -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 ---
+35 -53
View File
@@ -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<T extends { "@id": string }>(set: Set<T>, 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<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,
});
// 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<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.
* 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<FpEventData, 'id'>): Promise<FpEvent | undefined> => {
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<FpEventData>): Promise<void> => {
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) });
}
+84 -33
View File
@@ -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
* `<Refine>` 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<BackendSelection | null>(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<BackendSelection>(() => ({ backend, switchTo }), [backend, switchTo]);
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>
<BackendContext.Provider value={selection}>
<Refine
key={backend.name}
dataProvider={providers.dataProvider}
liveProvider={providers.liveProvider}
authProvider={providers.authProvider}
resources={backend.resources.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.
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>
</BackendContext.Provider>
);
}
+73
View File
@@ -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<string, RefineBackend> = {
[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;
}
+89
View File
@@ -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<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'],
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 }),
};
},
};
+74
View File
@@ -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<T extends { id: string }> {
/**
* 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<T, 'id'>): Record<string, unknown>;
/**
* 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<T>): Record<string, unknown>;
/** 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 `<Refine resources>`. */
readonly resources: readonly string[];
/** The Events vertical is the only one wired so far. */
readonly event: RecordBinding<FpEventData>;
}