The Refine seam travels to this base, deliberately unwired

The seam that puts Festipod's persistence behind interchangeable Refine backends
was built on `festipod-refine`, over a base this one has since overtaken
everywhere else. Carrying the seam rather than the branch keeps the evolved data
layer intact and costs nothing: the backend contract, the registry and the
provider that mounts Refine depend only on the domain types, so they compile
here untouched once `@refinedev/core` is present — the one dependency this adds,
installed through pnpm as this project requires.

Nothing imports them and the registry is empty, which is the intended state.
Wiring the seam means rewriting `FestipodDataContext` against this base's
version of it — its own decision, with its own failure modes, and not one to
smuggle into a transfer. Landing the files first makes that decision reviewable
against real code instead of a diff between two branches.

The doctrine travels with the code, because the seam is unreadable without it:
the reconstructed observation of `@activitypods/refine-providers` — a package
that publishes no engagement at all, hence `type: reconstruct` and a pin on the
commit it was read at — our usage declaration beside it, and the brief that
arbitrates one data shape for two rights models. So the manifest grows a
`publish:` section: this repo still offers no interface, but it does now publish
what it consumes, which is the only channel a provider has for learning that.

The brief was written where three of the leaves it cites did not exist, and
named them in prose to say so. Here they exist, so it links them; and its open
deduplication point now reads against this repo's own `materializeAttendance`
instead of against a sibling branch. It stays open, and the finding is
unchanged: the active set is keyed per deposit, which buys replay idempotence
and not person identity, so the count counts deposits rather than people.

Two comment zones came over knowingly superseded — runtime hot-switching as what
shapes the provider, and a sign-in barrier waiting on something `AuthGate` no
longer does. They are recorded as data-layer doc-debt instead of being quietly
rewritten, so the rewrite lands with the wiring that settles what they should
say.
This commit is contained in:
Sylvain Duchesne
2026-10-05 18:46:32 +02:00
parent c7cda38235
commit e282384e00
11 changed files with 765 additions and 7 deletions
+128
View File
@@ -0,0 +1,128 @@
/**
* RefineProvider — mounts Refine over the SELECTED backend, and owns the selection.
*
* 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.
*/
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
import { Refine } from '@refinedev/core';
import { getBackend, initialBackendName, resourceNames, type RefineBackend } from './providers';
interface BackendSelection {
/** `undefined` when no backend is registered — see providers/index.ts. */
backend: RefineBackend | undefined;
/** 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.
*/
/**
* The current backend, or `undefined` when none is registered. Callers that READ
* or WRITE data must handle the undefined case — there is nothing to read a
* connected wallet through, and pretending otherwise returns empty lists that
* look like an empty wallet.
*/
export function useBackend(): RefineBackend | undefined {
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(() => (name ? getBackend(name) : undefined), [name]);
// Built once PER BACKEND, not per render: a fresh provider identity makes Refine
// re-run every query behind it.
const providers = useMemo(() => {
if (!backend) {
console.log('[refine] no backend registered — the app runs in demo mode');
return undefined;
}
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]);
// NO BACKEND, NO <Refine>. Mounting it without a data provider would give the
// hooks something that answers nothing, which reads downstream as an empty
// wallet. The selection context is still published, so a selector can appear.
if (!backend || !providers) {
return <BackendContext.Provider value={selection}>{children}</BackendContext.Provider>;
}
return (
<BackendContext.Provider value={selection}>
<Refine
key={backend.name}
dataProvider={providers.dataProvider}
liveProvider={providers.liveProvider}
authProvider={providers.authProvider}
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.
liveMode: 'auto',
// A write must be VISIBLE to the read that follows it. Before Refine the
// app mutated the ORM object in place, so a screen — and a test — saw the
// new value at once. Through a data provider the write goes out and the
// list is re-read, and the re-read can still carry the old value: the
// measured symptom was a participant count stuck on what the seed wrote,
// with no error anywhere. `optimistic` puts the change into the cache
// straight away and rolls it back if the write fails, which is the
// behaviour that was lost — obtained from the framework instead of being
// rebuilt by hand.
mutationMode: 'optimistic',
// 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>
);
}
+85
View File
@@ -0,0 +1,85 @@
/**
* The backend registry — which Refine backends exist, and which one starts.
*
* IT IS EMPTY ON PURPOSE, and that is the current state of the work rather than
* an oversight. Festipod must end up supporting SEVERAL Refine backends, and the
* seam for that is what this directory is: the contract in `types.ts`, the
* selection below, `RefineProvider`'s runtime switch. What is missing is backends.
*
* WHY THE FIRST ONE WAS WITHDRAWN. `@ng-org/refine-providers` was wired and
* measured, and it works as plain CRUD over SHEX shapes. It was withdrawn because
* it is not the target: what is coming is a Refine provider over NextGraph *plus
* the polyfill*, and hardening this one meanwhile buys nothing.
*
* NOT because it was incapable — an earlier version of this comment said so, and
* was wrong. It blamed the absence of an inbox for the participant count, having
* misread the model: every Participation is readable by everyone, only the link
* to a person is protected, so counting them is ordinary CRUD. See `types.ts`.
* What the measurements did establish is narrower and still worth knowing: the
* count must not be an incremented field, because each write would derive from a
* read of the previous one and race itself.
*
* The worked implementation is in this branch's history (`providers/nextgraph.ts`,
* up to the commit that withdrew it) and is the reference for the next one's shape.
*
* WHAT COMES NEXT, and why the seam stays: that NextGraph+polyfill provider, and
* `@activitypods/refine-providers`. Festipod has to support both, so the structure
* is kept and only the entry is gone.
*
* ADDING ONE is a file implementing `RefineBackend` plus a line here. What that
* file must answer is in `types.ts` and cannot be guessed: how its providers are
* configured, what its records look like, which key addresses a record versus
* which id the app references, whether its `update` replaces or patches, and
* whether it can derive the participant count.
*
* WITH NO BACKEND the app runs in demo mode: `FestipodDataProvider` has nothing
* to read a connected wallet through, and says so rather than mounting a data
* path that would return nothing.
*/
import type { RefineBackend } from './types';
export type { RecordBinding, RecordBindings, RefineBackend } from './types';
export { resourceNames } from './types';
const BACKENDS: Record<string, RefineBackend> = {};
// Build-injected, same plumbing as the other build-time globals (build.ts
// `define`s it). Absent → the first registered backend, if there is one.
declare global {
// eslint-disable-next-line no-var
var __FESTIPOD_BACKEND__: string | undefined;
}
/** 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) {
const known = Object.keys(BACKENDS).join(', ') || '(none registered)';
throw new Error(`Unknown Refine backend "${name}" — known: ${known}`);
}
return backend;
}
/**
* Which backend is selected when the app starts, or `undefined` when none is
* registered. A requested name that does not exist still throws — asking for a
* specific backend and silently getting another is the failure worth avoiding.
*/
export function initialBackendName(): string | undefined {
const requested = globalThis.__FESTIPOD_BACKEND__;
if (requested) {
getBackend(requested);
return requested;
}
return Object.keys(BACKENDS)[0];
}
+142
View File
@@ -0,0 +1,142 @@
/**
* 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,
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 }> {
/**
* 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;
/**
* 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. */
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;
};
/** One binding per entity the app persists. */
readonly bindings: RecordBindings;
}
/**
* HOW THE PARTICIPANT COUNT IS OBTAINED — a domain rule, not a backend capability.
*
* `participantCount` is the number of participants, and every Participation is
* READABLE BY EVERYONE: what is protected is the link to a person, not the
* record. Only a connection can read the profile a participation points at and
* recognise who it is. So the count is obtained by COUNTING the participations of
* an event — no filtering by visibility, because none is needed (concept
* `data-layer`, brief_2026-07-20_attendance-set-model).
*
* This means NO backend capability is required for it: counting records is CRUD.
* An earlier version of this comment claimed the opposite — that an inbox was
* needed, and that a CRUD-only backend therefore could not carry the model. That
* was a misreading of the brief's "no filtering", and it is wrong. The inbox on
* the sibling branch is how a sign-up is DELIVERED to the event's owner so the
* owner can maintain the aggregate; it is one implementation of the count, not a
* precondition for having one.
*
* What IS a rule, and what the race measured here proved: the count must not be
* an INCREMENTED field. Reading a stored counter out of a list, adding one and
* writing it back derives each write from a read of the previous one, which
* races itself through any data provider — and the project's brief declares that
* mutated counter superseded.
*/
/** 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))];
}