From 3de86c8be2fb95eb53484ce7c8d782e24fa6f01a Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Thu, 1 Oct 2026 16:26:11 +0200 Subject: [PATCH] Withdraw the ng-org backend, keep the seam that will carry the next two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 15 +- pnpm-lock.yaml | 20 --- src/shared/context/FestipodDataContext.tsx | 22 ++- src/shared/refine/RefineProvider.tsx | 34 ++++- src/shared/refine/ngAuthProvider.ts | 72 --------- src/shared/refine/providers/index.ts | 78 +++++----- src/shared/refine/providers/nextgraph.ts | 167 --------------------- src/shared/refine/providers/types.ts | 25 +++ src/shared/refine/shapeTypes.ts | 34 ----- src/shared/test-harness/harness-ng.tsx | 14 +- 10 files changed, 135 insertions(+), 346 deletions(-) delete mode 100644 src/shared/refine/ngAuthProvider.ts delete mode 100644 src/shared/refine/providers/nextgraph.ts delete mode 100644 src/shared/refine/shapeTypes.ts diff --git a/package.json b/package.json index d79ed7f..95c008b 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "dependencies": { "@ng-org/alien-deepsignals": "0.1.2-alpha.13", "@ng-org/orm": "0.1.2-alpha.21", - "@ng-org/refine-providers": "0.1.2-alpha.1", "@ng-org/shex-orm": "0.1.2-alpha.9", "@ng-org/web": "0.1.2-alpha.14", "@radix-ui/react-label": "^2.1.7", @@ -42,6 +41,11 @@ "@cucumber/cucumber": "^12.5.0", "@cucumber/gherkin": "^29.0.0", "@cucumber/messages": "^26.0.1", + "@storybook/addon-a11y": "^10.3.5", + "@storybook/addon-docs": "^10.3.5", + "@storybook/addon-onboarding": "^10.3.5", + "@storybook/addon-webpack5-compiler-swc": "^4.0.3", + "@storybook/react-webpack5": "^10.3.5", "@types/bun": "latest", "@types/chai": "^5.2.3", "@types/react": "^19", @@ -49,15 +53,10 @@ "chai": "^6.2.2", "happy-dom": "^16.6.0", "playwright": "^1.58.2", + "storybook": "^10.3.5", "tailwindcss": "^4.1.11", "tsx": "^4.21.0", - "tw-animate-css": "^1.4.0", - "storybook": "^10.3.5", - "@storybook/react-webpack5": "^10.3.5", - "@storybook/addon-webpack5-compiler-swc": "^4.0.3", - "@storybook/addon-a11y": "^10.3.5", - "@storybook/addon-docs": "^10.3.5", - "@storybook/addon-onboarding": "^10.3.5" + "tw-animate-css": "^1.4.0" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cb9e01..52b5757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@ng-org/orm': specifier: 0.1.2-alpha.21 version: 0.1.2-alpha.21(react@19.3.0) - '@ng-org/refine-providers': - specifier: 0.1.2-alpha.1 - version: 0.1.2-alpha.1(@ng-org/alien-deepsignals@0.1.2-alpha.13(react@19.3.0))(@ng-org/orm@0.1.2-alpha.21(react@19.3.0))(@ng-org/shex-orm@0.1.2-alpha.9(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.14)(@refinedev/core@5.0.12(@tanstack/react-query@5.103.2(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) '@ng-org/shex-orm': specifier: 0.1.2-alpha.9 version: 0.1.2-alpha.9(typescript@6.0.3) @@ -520,15 +517,6 @@ packages: vue: optional: true - '@ng-org/refine-providers@0.1.2-alpha.1': - resolution: {integrity: sha512-MSaVTyCWiqbBR7jn4ESgCk8zYrxWW1EoZCquYAxIud1XBMtkZvl0X/rjIdCPG2GxAVHURC/ut3moavd5Eb+gsA==} - peerDependencies: - '@ng-org/alien-deepsignals': 0.1.2-alpha.13 - '@ng-org/orm': 0.1.2-alpha.21 - '@ng-org/shex-orm': 0.1.2-alpha.9 - '@ng-org/web': 0.1.2-alpha.14 - '@refinedev/core': ^5.0.12 - '@ng-org/shex-orm@0.1.2-alpha.9': resolution: {integrity: sha512-M69oIHrcjl24xKXIaiZjZ64rJvispAPIqn73asLPR73GWpEMvbiot/EN0vDkcfrbRy3cMEql36A9e1Nwv2csOA==} hasBin: true @@ -3615,14 +3603,6 @@ snapshots: optionalDependencies: react: 19.3.0 - '@ng-org/refine-providers@0.1.2-alpha.1(@ng-org/alien-deepsignals@0.1.2-alpha.13(react@19.3.0))(@ng-org/orm@0.1.2-alpha.21(react@19.3.0))(@ng-org/shex-orm@0.1.2-alpha.9(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.14)(@refinedev/core@5.0.12(@tanstack/react-query@5.103.2(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))': - dependencies: - '@ng-org/alien-deepsignals': 0.1.2-alpha.13(react@19.3.0) - '@ng-org/orm': 0.1.2-alpha.21(react@19.3.0) - '@ng-org/shex-orm': 0.1.2-alpha.9(typescript@6.0.3) - '@ng-org/web': 0.1.2-alpha.14 - '@refinedev/core': 5.0.12(@tanstack/react-query@5.103.2(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@ng-org/shex-orm@0.1.2-alpha.9(typescript@6.0.3)': dependencies: '@ldo/traverser-shexj': 1.0.0-alpha.28 diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 8fc0606..ce53afd 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -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({ - 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): Promise => { 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 {children}; + } if (status === 'connected') { return {children}; } diff --git a/src/shared/refine/RefineProvider.tsx b/src/shared/refine/RefineProvider.tsx index 0dc8270..0d6d77f 100644 --- a/src/shared/refine/RefineProvider.tsx +++ b/src/shared/refine/RefineProvider.tsx @@ -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(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(() => ({ backend, switchTo }), [backend, switchTo]); + // NO BACKEND, NO . 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 {children}; + } + return ( { - 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, - }; -} diff --git a/src/shared/refine/providers/index.ts b/src/shared/refine/providers/index.ts index 09449c5..1ceb7ae 100644 --- a/src/shared/refine/providers/index.ts +++ b/src/shared/refine/providers/index.ts @@ -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 = { - [nextgraphBackend.name]: nextgraphBackend, -}; +const BACKENDS: Record = {}; // 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]; } diff --git a/src/shared/refine/providers/nextgraph.ts b/src/shared/refine/providers/nextgraph.ts deleted file mode 100644 index bbf721b..0000000 --- a/src/shared/refine/providers/nextgraph.ts +++ /dev/null @@ -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 = { - 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 = { - 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 = { - 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 }), - }; - }, -}; diff --git a/src/shared/refine/providers/types.ts b/src/shared/refine/providers/types.ts index 4f8ec66..7af642b 100644 --- a/src/shared/refine/providers/types.ts +++ b/src/shared/refine/providers/types.ts @@ -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))]; diff --git a/src/shared/refine/shapeTypes.ts b/src/shared/refine/shapeTypes.ts deleted file mode 100644 index 5456e8f..0000000 --- a/src/shared/refine/shapeTypes.ts +++ /dev/null @@ -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; diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index 22773cc..c66ff83 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -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
NO_REFINE_BACKEND
; + } if (status === 'connected') { return ;