Withdraw the ng-org backend, keep the seam that will carry the next two

The participant count is the number of participants INCLUDING those this session
cannot see, so it cannot be a count of the participations we are allowed to
read. The project derives it: whoever signs up deposits a marker into the
event's inbox, and the event OWNER recomputes the field from those deposits.
That needs an inbox. @ng-org/refine-providers is CRUD over SHEX shapes and has
none, so it cannot carry the model — and the counter it could carry, incremented
from a value read back out of a list, races itself and is the one the project's
brief declares superseded. Measured, not assumed: optimistic mutations moved one
assertion from the seeded value to the written one and left the rest.

So the wiring goes and the structure stays. The contract, the registry, the
runtime switch and useBackend are untouched; what is gone is the single entry,
its three ng-org-specific files and the dependency. What the next backend must
answer, the derived count included, is written in types.ts, and the withdrawn
implementation stays in this branch's history as the worked example.

Two refusals rather than quiet degradations. With no backend the data context
falls back to demo data and SAYS so, instead of mounting a connected path that
would hand every screen empty lists. And the @data harness refuses to publish
its bridge at all: on fixtures those scenarios would assert happily and report
green while touching no wallet, which is worse than red.
This commit is contained in:
Sylvain Duchesne
2026-10-01 16:26:11 +02:00
parent 4b4dfa6cad
commit 3de86c8be2
10 changed files with 135 additions and 346 deletions
+16 -6
View File
@@ -20,7 +20,6 @@ import { useAccount, normalizeUsername } from './AccountContext';
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),
@@ -252,7 +251,8 @@ 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 bindings = useBackend().bindings;
// Non-null: `FestipodDataProvider` only mounts this when a backend exists.
const bindings = useBackend()!.bindings;
const eventBinding = bindings.event;
const profileBinding = bindings.profile;
const participationBinding = bindings.participation;
@@ -301,7 +301,7 @@ function useNgData(): FestipodDataContextValue {
// verifier holds. Pagination is off: the screens show the whole list and the
// provider does not paginate at the source anyway.
const { result: eventsResult, query: eventsQuery } = useList<FpEvent>({
resource: RESOURCE_EVENTS,
resource: eventBinding.resource,
pagination: { mode: 'off' },
});
// The rows carry BOTH ids: Refine's `id` (the DOCUMENT NURI, which is what its
@@ -375,7 +375,7 @@ function useNgData(): FestipodDataContextValue {
const createEventDoc = useCallback(
async (event: Omit<FpEventData, 'id'>): Promise<FpEvent | undefined> => {
const created = await createEventRow({
resource: RESOURCE_EVENTS,
resource: eventBinding.resource,
values: eventBinding.toValues(event),
});
return created?.data;
@@ -406,7 +406,7 @@ function useNgData(): FestipodDataContextValue {
? eventBinding.toValues({ ...eventBinding.toDomain(row), ...updates })
: eventBinding.toPatch(updates);
await updateEventRow({
resource: RESOURCE_EVENTS,
resource: eventBinding.resource,
id: eventBinding.recordKey(row),
values,
});
@@ -637,8 +637,18 @@ function NgDataProvider({ children }: { children: ReactNode }) {
export function FestipodDataProvider({ children }: { children: ReactNode }) {
const { status } = useNextGraph();
console.log('[FestipodData] Provider — NG status:', status);
const backend = useBackend();
console.log('[FestipodData] Provider — NG status:', status, '| backend:', backend?.name ?? '(none)');
// CONNECTED BUT NO BACKEND REGISTERED → demo mode, and said out loud. The
// connected path reads and writes through a Refine backend; with none there is
// nothing to read a wallet through, and mounting it anyway would hand every
// screen empty lists that look exactly like an empty wallet. See
// refine/providers/index.ts for why the registry is empty today.
if (status === 'connected' && !backend) {
console.warn('[FestipodData] Connected, but no Refine backend is registered — falling back to demo data');
return <LocalDataProvider>{children}</LocalDataProvider>;
}
if (status === 'connected') {
return <NgDataProvider>{children}</NgDataProvider>;
}
+31 -3
View File
@@ -27,7 +27,8 @@ import { Refine } from '@refinedev/core';
import { getBackend, initialBackendName, resourceNames, type RefineBackend } from './providers';
interface BackendSelection {
backend: RefineBackend;
/** `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;
}
@@ -38,7 +39,13 @@ 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 {
/**
* 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');
@@ -57,11 +64,15 @@ export function useBackendSelection(): BackendSelection {
export function RefineProvider({ children }: { children: ReactNode }) {
const [name, setName] = useState(initialBackendName);
const backend = useMemo(() => getBackend(name), [name]);
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]);
@@ -74,6 +85,13 @@ export function RefineProvider({ children }: { children: ReactNode }) {
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
@@ -86,6 +104,16 @@ export function RefineProvider({ children }: { children: ReactNode }) {
// `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
-72
View File
@@ -1,72 +0,0 @@
/**
* ngAuthProvider — the `AuthProvider` that `@ng-org/refine-providers` asks to be
* handed, backed by Festipod's own NextGraph session.
*
* WHY NOT THE PACKAGE'S OWN `authProvider()`. Its factory calls `init(…)` from
* `@ng-org/web` the moment it runs, and then `initNg(ng, session)` — which is
* exactly, line for line, what `utils/ngSession.init()` already does. Using both
* would register TWO init callbacks against one WASM session: outside the broker
* iframe `init` REDIRECTS the page, so the second registration is a second
* redirect, and the two callbacks race to be the one that owns `session`.
*
* `DataProviderConfig` declares `authProvider` as an INJECTION POINT — the data
* provider only ever calls `getSession()` on it, to read a `session_id` for
* `doc_create`. So supplying Festipod's session here is using the published
* surface as it is meant to be used, not working around it: the session stays
* single, and `ngSession` stays the only module that calls `init`.
*
* The remaining `AuthProvider` members are Refine's auth contract. They are wired
* to what Festipod actually has — the technical NextGraph login/logout — rather
* than stubbed: `check` reports whether the wallet session is open, and nothing
* here invents an identity the app does not hold.
*/
import type { AuthProvider } from '@ng-org/refine-providers';
import type { Session } from '@ng-org/web';
import { session, login as ngLogin, logoutNg } from '../utils/ngSession';
export function createNgAuthProvider(): AuthProvider {
return {
/**
* The real technical login: opening the wallet page. `ngSession.login()`
* navigates away, so the resolved value is only reached when the broker
* round-trip did not happen (already logged in).
*/
login: async () => {
await ngLogin();
return { success: true };
},
logout: async () => {
await logoutNg();
return { success: true };
},
/**
* Authenticated = the NextGraph session is open. `AuthGate` is what actually
* gates the screens (it reads `NextGraphContext`), so no `redirectTo` is
* returned here: Refine has no router in this app and must not try to route.
*/
check: async () => ({ authenticated: session !== undefined }),
onError: async (error) => ({ error }),
/**
* The NextGraph session carries the wallet user id under `user` when one is
* open. Festipod's PERCEIVED login (the chosen username) lives in
* `AccountContext`, which is React state and not reachable from here — so
* this reports the technical identity only, and `undefined` when there is none.
*/
getIdentity: async () => {
const user = session?.user;
return typeof user === 'string' && user ? { id: user } : undefined;
},
/**
* The handle the data provider actually consumes. Festipod's
* `NextGraphSession` is the same object `@ng-org/web` hands its `init`
* callback; the cast only reconciles the two local declarations of it.
*/
getSession: () => session as Session | undefined,
};
}
+43 -35
View File
@@ -1,53 +1,53 @@
/**
* 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.
* 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.
*
* 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.
* WHY THE FIRST ONE WAS WITHDRAWN. `@ng-org/refine-providers` was wired and
* measured, and it works as plain CRUD over SHEX shapes. But Festipod's sign-up
* model needs more than CRUD: `participantCount` counts participants this session
* cannot see, which the project derives from inbox deposits recomputed by the
* event's owner — see the requirement in `types.ts`. A counter incremented from a
* value read back out of a list is a different mechanism, it races itself, and the
* project's own brief declares it superseded. Rather than harden a counter on a
* backend that structurally cannot carry the model, the wiring was removed; 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 shape of the
* next one.
*
* There is no selector screen yet — that is interface work, not seam work.
* WHAT COMES NEXT, and why the seam stays: a Refine provider over NextGraph *plus
* the polyfill*, which does publish inboxes, and `@activitypods/refine-providers`.
* Festipod has to support both, so the structure is kept and only the entry is
* gone.
*
* 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.
* 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.
*
* `@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.
* 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 { nextgraphBackend } from './nextgraph';
import type { RefineBackend } from './types';
export type { RecordBinding, RecordBindings, RefineBackend } from './types';
export { resourceNames } from './types';
const BACKENDS: Record<string, RefineBackend> = {
[nextgraphBackend.name]: nextgraphBackend,
};
const BACKENDS: Record<string, RefineBackend> = {};
// 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.
// `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;
}
const DEFAULT_BACKEND = nextgraphBackend.name;
/** Every registered backend — what a selector would offer. */
export function listBackends(): RefineBackend[] {
return Object.values(BACKENDS);
@@ -61,14 +61,22 @@ export function listBackends(): RefineBackend[] {
export function getBackend(name: string): RefineBackend {
const backend = BACKENDS[name];
if (!backend) {
throw new Error(
`Unknown Refine backend "${name}" — known: ${Object.keys(BACKENDS).join(', ')}`,
);
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. */
export function initialBackendName(): string {
return globalThis.__FESTIPOD_BACKEND__ || DEFAULT_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];
}
-167
View File
@@ -1,167 +0,0 @@
/**
* 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, 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 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> = {
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;
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': TYPE.event,
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,
}),
};
const profileBinding: RecordBinding<FpUserData> = {
resource: RESOURCE_PROFILES,
// A profile is readable by one's network, not by everyone.
visibility: 'protected',
recordKey,
domainId,
...replaces,
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',
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.
const authProvider = createNgAuthProvider();
return {
authProvider,
dataProvider: dataProvider({ shapeTypes, authProvider }),
liveProvider: liveProvider({ shapeTypes }),
};
},
};
+25
View File
@@ -111,6 +111,31 @@ export interface RefineBackend {
readonly bindings: RecordBindings;
}
/**
* WHAT A BACKEND MUST BE ABLE TO DO, beyond CRUD — recorded here because the
* first backend tried could NOT, and that is why it was withdrawn.
*
* `participantCount` is **the number of participants, including those this
* session cannot see**. So it is not a count of the participations we can read:
* filtering by what we are allowed to see would under-report it. The project's
* model (concept `data-layer`, brief_2026-07-20_attendance-set-model, and
* knowledge_context-internals) derives it instead:
*
* - whoever signs up writes their OWN participation, then DEPOSITS a marker
* into the event's inbox;
* - the event OWNER's session watches the inboxes of its own events and
* RECOMPUTES the count from the deposits, deduped, minus the cancelled ones.
* It is the field's only writer, and the count is `|active set|`.
*
* Two consequences for this contract. A backend offering only CRUD over records
* — no inbox, no per-document delivery — cannot support it: incrementing a
* counter read back from a list is a different mechanism, it races itself, and
* the project's brief declares it superseded. And a backend that CAN must say so,
* because the app's sign-up path differs accordingly.
*
* Until a backend provides it, `participantCount` is whatever the record carries.
*/
/** 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))];
-34
View File
@@ -1,34 +0,0 @@
/**
* The Refine RESOURCE MAP — the one place that says which Refine resource name
* stands for which SHEX shape.
*
* `@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 — no error, just nothing. Every `useList` / `useOne` / `useCreate` /
* `useUpdate` call site must therefore name a resource declared below.
*
* 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,
FpParticipationShapeType,
FpUserProfileShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
/** 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;
+13 -1
View File
@@ -10,7 +10,7 @@
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 { RefineProvider, useBackend } from '../refine/RefineProvider';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
import type { FpEventData, FpParticipationData, FpUserData } from '../data/types';
import { useShapeSet, useShapeSubscription } from '../hooks/useShapeSet';
@@ -46,6 +46,18 @@ function DataHarnessNG() {
// Wait for NG connection before exposing the test bridge
function HarnessRouter() {
const { status } = useNextGraph();
const backend = useBackend();
// NO BACKEND, NO BRIDGE — and this refusal is the point. Without one the data
// context falls back to demo fixtures, so a @data scenario would read seeded
// events, assert happily on them and report GREEN while touching no wallet at
// all. A layer that exists to test broker persistence must fail rather than
// pass on fixtures. `reason` is published so the step's timeout is diagnosable
// instead of mute.
if (!backend) {
(window as any).__testData = { ready: false, reason: 'no-refine-backend' };
return <div id="harness-status">NO_REFINE_BACKEND</div>;
}
if (status === 'connected') {
return <ConnectedHarness />;