The URL says which provider set runs, and the seam stops claiming otherwise

The seam arrived carrying a switching design that no longer holds. It presented
runtime hot-switching as what shaped it — selection in state, a key on <Refine>
to drop the previous backend's cache — and it read the backend from a build
global whose comment claimed build.ts defined it. Nothing ever did, and nothing
could have: this project serves from src/ in production too, so that define
block never reaches what runs.

What replaces it is a URL. The default provider set lives at the root, another
behind a path prefix naming it, and moving between them is a link. The reload is
the mechanism rather than a cost to avoid: it discards the cache and the session
with no code to undo them. So the switch helpers are gone and the file is
smaller than what it replaced.

The resolution is a pure function with its own tests, including the case that is
true today — an empty registry, where every URL is the default. The router does
NOT yet strip the prefix, deliberately: it is a shared primitive every screen
depends on, the suites that would prove a change to it are not run here, and no
URL carries a prefix while one backend is registered. That half lands with the
second backend, which is what makes it testable.

Three claims the move to this base falsified are corrected too. The inbox the
count comment points at is local here, not on another branch. The worked
provider implementation is not in this branch's ancestry. And the MeetingPoint
shape exists here, so its binding stops being optional — a backend that cannot
carry one of the four entities cannot serve the app, and nothing optional should
let a half-backend register quietly.

Also: both usage declarations are named usage_festipod.md, so the stem is now
ambiguous as a link. They are referred to by folder path, and both are listed
under publish so each provider can re-find its own if it moves.
This commit is contained in:
Sylvain Duchesne
2026-10-08 10:08:40 +02:00
parent e282384e00
commit df1db790ad
8 changed files with 208 additions and 110 deletions
+42 -57
View File
@@ -1,20 +1,25 @@
/**
* RefineProvider — mounts Refine over the SELECTED backend, and owns the selection.
* RefineProvider — mounts Refine over the backend this page runs.
*
* 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.
* This is the app's data entry point: screens keep calling `useFestipodData()`,
* and `FestipodDataContext` reads and writes through Refine's hooks, which land
* on whichever backend the URL selected.
*
* 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.
* ONE BACKEND PER PAGE is what shapes this file, and it is why the file is small.
* The backend is read from the URL once (`providers/index.ts`, `variant.ts`) and
* never changes while the page lives. Moving to another provider set is a link to
* another variant, so it costs a full reload — and the reload is the mechanism,
* not a cost to work around: it is what discards the previous backend's Refine
* cache and session, with no code to undo them and nothing to migrate. Losing
* access to the data of the variant being left is accepted.
*
* 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.
* THE SIGN-IN IS THE BACKEND'S, and that is the deeper reason the variants are
* isolated rather than switchable. It is not abstractable: on this base the data
* SDK mounts its own full-screen barrier and owns the whole wallet experience
* (`src/app/AuthGate.tsx` renders no access screen), while a Solid backend hands
* the app an OIDC flow it has to render itself — a Pod-provider picker and a
* callback route. One confiscates the screen, the other delegates a flow; no
* single barrier serves both, so each variant brings its own.
*
* 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
@@ -22,49 +27,37 @@
* helpers (which this app does not call) go dormant.
*/
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
import { createContext, useContext, useMemo, 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);
/** `undefined` when no backend is registered — see providers/index.ts. */
const BackendContext = createContext<RefineBackend | undefined | 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.
* The backend this page runs, 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.
*
* Read it rather than importing one: a module-level import of a specific backend
* is what would make the variant irrelevant.
*/
export function useBackend(): RefineBackend | undefined {
const selection = useContext(BackendContext);
if (!selection) {
const backend = useContext(BackendContext);
if (backend === null) {
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;
return backend;
}
export function RefineProvider({ children }: { children: ReactNode }) {
const [name, setName] = useState(initialBackendName);
const backend = useMemo(() => (name ? getBackend(name) : undefined), [name]);
// Resolved once: the URL does not change under a page, and a variant change is
// a reload that runs this afresh.
const backend = useMemo(() => {
const name = initialBackendName();
return name ? getBackend(name) : undefined;
}, []);
// Built once PER BACKEND, not per render: a fresh provider identity makes Refine
// re-run every query behind it.
@@ -77,32 +70,24 @@ export function RefineProvider({ children }: { children: ReactNode }) {
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.
// wallet. The context is still published, so callers get `undefined` rather
// than a thrown "outside a provider".
if (!backend || !providers) {
return <BackendContext.Provider value={selection}>{children}</BackendContext.Provider>;
return <BackendContext.Provider value={undefined}>{children}</BackendContext.Provider>;
}
return (
<BackendContext.Provider value={selection}>
<BackendContext.Provider value={backend}>
<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.
// query — without it the backend 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
+27 -23
View File
@@ -1,10 +1,21 @@
/**
* The backend registry — which Refine backends exist, and which one starts.
* The backend registry — which Refine backends exist, and which one this page runs.
*
* 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.
* selection below, the mounting in `RefineProvider`. What is missing is backends.
*
* ONE VARIANT PER PAGE, NOT A SWITCH. A backend is selected by the URL — the
* default one at the root, another through a path prefix naming it (`variant.ts`)
* — so a page runs exactly one backend for its whole life, and changing backend
* is a link that reloads. Nothing here hands the app a way to switch in session,
* deliberately: the reload is what discards the previous backend's cache and
* session instead of code that would have to undo them.
*
* NAMES ARE THE URL PREFIXES, so a backend must never be named after a top-level
* route (`events`, `profile`, `home`, `settings`, `users`) — registering one would
* turn that route into a variant switch. Nothing checks it: the names are ours.
*
* 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
@@ -19,8 +30,9 @@
* 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.
* The worked implementation is not in this branch's history: it lives on branch
* `festipod-refine` (`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
@@ -29,14 +41,14 @@
* 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.
* which id the app references, and whether its `update` replaces or patches.
*
* 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 { resolveVariant } from '../variant';
import type { RefineBackend } from './types';
export type { RecordBinding, RecordBindings, RefineBackend } from './types';
@@ -44,14 +56,7 @@ 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. */
/** Every registered backend — what the variant links offer. */
export function listBackends(): RefineBackend[] {
return Object.values(BACKENDS);
}
@@ -71,15 +76,14 @@ export function getBackend(name: string): RefineBackend {
}
/**
* 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.
* The backend this page runs, read from the URL, or `undefined` when none is
* registered.
*
* A prefix is only ever matched against a REGISTERED name, so an unknown one is
* not a request that could fail — it is an ordinary path, and it lands on the
* default variant. Which is the right reading: `/events/42` names no backend.
*/
export function initialBackendName(): string | undefined {
const requested = globalThis.__FESTIPOD_BACKEND__;
if (requested) {
getBackend(requested);
return requested;
}
return Object.keys(BACKENDS)[0];
const names = Object.keys(BACKENDS);
return resolveVariant(window.location.pathname, names).backend ?? names[0];
}
+10 -12
View File
@@ -78,20 +78,18 @@ export interface RecordBinding<T extends { id: string }> {
}
/**
* The entities the app binds. One key per domain type it persists.
* The entities the app binds — one key per domain type it persists, all four
* required. A backend that cannot carry one of them cannot serve the app, so
* there is nothing optional here to let a half-backend register quietly.
*
* `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.
* `Friendship` is deliberately absent: it has no shape and no persistence
* anywhere in this project (concept `data-layer`, `knowledge_entities`).
*/
export interface RecordBindings {
event: RecordBinding<FpEventData>;
profile: RecordBinding<FpUserData>;
participation: RecordBinding<FpParticipationData>;
meetingPoint?: RecordBinding<FpMeetingPointData>;
meetingPoint: RecordBinding<FpMeetingPointData>;
}
/** Everything Festipod needs from one Refine backend. */
@@ -124,10 +122,10 @@ export interface RefineBackend {
* 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.
* was a misreading of the brief's "no filtering", and it is wrong. The inbox this
* app already uses (`src/shared/data/registration.ts`) 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
+72
View File
@@ -0,0 +1,72 @@
import { expect, test } from 'bun:test';
import { resolveVariant } from './variant';
// The names a registry could hold. `nextgraph` is the one variant planned beside
// the default; `events` is NOT a backend and appears here only to prove an
// ordinary route is never mistaken for a prefix.
const KNOWN = ['activitypods', 'nextgraph'];
// THE CASE THAT IS TRUE TODAY, and the one most likely to be left untested: the
// registry is empty, so every URL resolves to the default variant.
test('no registered backend — every path is the default variant', () => {
expect(resolveVariant('/', [])).toEqual({ backend: undefined, basePath: '' });
expect(resolveVariant('/events/42', [])).toEqual({ backend: undefined, basePath: '' });
expect(resolveVariant('/nextgraph', [])).toEqual({ backend: undefined, basePath: '' });
});
// The normal case once a registry exists: the root serves the DEFAULT variant, and
// `undefined` is what says "the default", never "none".
test('the root is the default variant', () => {
expect(resolveVariant('/', KNOWN)).toEqual({ backend: undefined, basePath: '' });
});
test('an ordinary route is the default variant, not a prefix', () => {
expect(resolveVariant('/events', KNOWN)).toEqual({ backend: undefined, basePath: '' });
expect(resolveVariant('/events/42/participants', KNOWN)).toEqual({
backend: undefined,
basePath: '',
});
expect(resolveVariant('/profile/edit', KNOWN)).toEqual({ backend: undefined, basePath: '' });
});
test('a registered name as first segment selects that variant', () => {
expect(resolveVariant('/nextgraph', KNOWN)).toEqual({
backend: 'nextgraph',
basePath: '/nextgraph',
});
});
// The prefix carries through to every route under it — this is the shape the
// router will have to strip.
test('the prefix holds over a deep path', () => {
expect(resolveVariant('/nextgraph/events/42/participants', KNOWN)).toEqual({
backend: 'nextgraph',
basePath: '/nextgraph',
});
});
// A name that is registered but matched only as a LATER segment is an event id or
// a user id that happens to spell a backend's name, never a variant switch.
test('a registered name deeper in the path is not a prefix', () => {
expect(resolveVariant('/events/nextgraph', KNOWN)).toEqual({
backend: undefined,
basePath: '',
});
});
test('an unknown first segment falls back to the default variant', () => {
expect(resolveVariant('/whatever', KNOWN)).toEqual({ backend: undefined, basePath: '' });
});
// Shapes a browser or a hand-typed link really produces.
test('empty, bare and repeated slashes resolve without throwing', () => {
expect(resolveVariant('', KNOWN)).toEqual({ backend: undefined, basePath: '' });
expect(resolveVariant('//nextgraph', KNOWN)).toEqual({
backend: 'nextgraph',
basePath: '/nextgraph',
});
expect(resolveVariant('/nextgraph/', KNOWN)).toEqual({
backend: 'nextgraph',
basePath: '/nextgraph',
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* Which provider set the current URL selects.
*
* Festipod ships as ONE build serving SEVERAL variants, one per provider set, at
* the same origin. The default variant lives at the root; another is reached
* through a path prefix naming it. Switching is a plain link, so it costs a full
* page load — which is the point: the React tree, Refine's cache and the previous
* provider's session all go away, and nothing has to be migrated or torn down.
* Losing access to the data of the variant being left is accepted.
*
* WHY NOT A BUILD-TIME CONSTANT. An earlier version of this read a build-injected
* `__FESTIPOD_BACKEND__`, and its comment claimed `build.ts` defined it. Nothing
* ever did — and it could not have worked here anyway: `bun run start` serves from
* `src/`, so `build.ts`'s `define` block never reaches what the deployment runs
* (concept `tech-stack`, `knowledge_build-pipeline`). A URL needs no injection, is
* the same in dev and in production, and is linkable — which is what the switch is.
*
* NAMES ARE THE PREFIXES, so a backend must never be named after a top-level route
* (`events`, `profile`, `home`, `settings`, `users`): registering one would turn
* that route into a variant switch. The registry owns those names, so this is a
* naming constraint rather than a check — see `providers/index.ts`.
*/
/** The provider set a URL names, and the prefix that carries it. */
export interface Variant {
/** The backend named by the URL, or `undefined` to mean "the default one". */
backend: string | undefined;
/**
* What every app path is prefixed with under this variant — `''` for the
* default. The router has to strip it on parse and restore it on navigate; it
* does not yet, because no URL carries a prefix while one backend is registered.
*/
basePath: string;
}
/**
* Read the variant out of a pathname. Pure, so the whole rule is one test away.
*
* `known` is the registered backend names. Matching only against those is what
* keeps an ordinary route (`/events`) from being read as a variant prefix.
*/
export function resolveVariant(pathname: string, known: readonly string[]): Variant {
const firstSegment = pathname.replace(/^\/+/, '').split('/')[0] ?? '';
if (firstSegment && known.includes(firstSegment)) {
return { backend: firstSegment, basePath: `/${firstSegment}` };
}
return { backend: undefined, basePath: '' };
}