diff --git a/.project/concepts/data-layer/_debt.md b/.project/concepts/data-layer/_debt.md deleted file mode 100644 index fe2eb0d..0000000 --- a/.project/concepts/data-layer/_debt.md +++ /dev/null @@ -1,15 +0,0 @@ -# Doc-debt — data-layer - -> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. -> One block = one "big change": `why` + `files` + `verify` (leaves to review). - -## Block: the Refine seam's comments describe a switching design this base superseded - -- **why**: The Refine seam was carried over verbatim from `festipod-refine` and lands here as unreferenced scaffolding, on a base that has moved past two of its stated premises. `RefineProvider.tsx` presents **runtime hot-switching** as what shapes the file (selection held in React state, `key={backend.name}` remounting `` so the previous backend's cache is discarded); the product decision since is **one app variant per provider set, selected by URL, with a full page reload** — there is no in-session switch left for the file to be shaped by. And both `RefineProvider.tsx` and `providers/index.ts` say the per-backend sign-in barrier is "the next piece" because `AuthGate` gates on the NextGraph session; on this base `AuthGate` renders no access screen of its own and delegates the whole access experience to the SDK. The prose was left untouched on transfer on purpose — it is settled with the wiring, not during the carry-over. -- **files**: `src/shared/refine/RefineProvider.tsx`, `src/shared/refine/providers/index.ts`, `src/shared/refine/providers/types.ts` -- **verify (what must be updated)**: - - `RefineProvider.tsx` header — "SWITCHING AT RUNTIME is what shapes this file", the `key`/remount rationale, and whether `switchTo` / `useBackendSelection` survive at all once the selection is a URL plus a reload. - - `RefineProvider.tsx` — the "What is NOT solved here is the sign-in: `AuthGate` still gates on the NextGraph session … That is the next piece" paragraph, against this base's `src/app/AuthGate.tsx`. - - `providers/index.ts` — same sign-in claim, plus the build-injected `__FESTIPOD_BACKEND__` global it reads: `build.ts` on this base defines no such thing, so `initialBackendName()` can only ever fall back to the first registered backend. - - Branch-relative phrasing that points nowhere from here: `types.ts` "the inbox on the sibling branch" (that inbox is local — `src/shared/data/registration.ts`) and `providers/index.ts` "the worked implementation is in this branch's history" (it is on `festipod-refine`, not in this branch's ancestry). - - Leaves: `data-layer/_overview.md` gained the seam's triggers but still describes no seam, and `brief_2026-10-03_structure-for-two-rights-models` states the contract is in code — both to revisit once the seam is actually wired. diff --git a/.project/concepts/data-layer/_overview.md b/.project/concepts/data-layer/_overview.md index 4e6d90e..655cc16 100644 --- a/.project/concepts/data-layer/_overview.md +++ b/.project/concepts/data-layer/_overview.md @@ -16,8 +16,11 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e Each external interface this concept consumes lives in **its own folder**, holding the provider's engagement (pulled, version-pinned) and — once Festipod actually consumes it — our own declaration beside it. -- `polyfill-surface/` — [[contract_polyfill-surface]], **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources. Beside it, [[usage_festipod]] — what the app *actually* calls, the conditions it needs, and the frictions measured against the engagement. **Frictions are how a need reaches the provider**: put it there, then signal it out of band. +- `polyfill-surface/` — [[contract_polyfill-surface]], **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources. Beside it, `polyfill-surface/usage_festipod.md` — what the app *actually* calls, the conditions it needs, and the frictions measured against the engagement. **Frictions are how a need reaches the provider**: put it there, then signal it out of band. - `indexing-layer/` — [[contract_indexing-layer]], the `@ng-helpers/indexing` engagement, PULLED and pinned on `v1.0.0`: creating an index, depositing references into it, curating it, reading it back. **Nothing consumes it yet** — no declaration is authored beside it, deliberately, since an empty one would say nothing. +- `activitypods-providers/` — [[contract_activitypods-providers]], a RECONSTRUCTION rather than a pull: that package publishes no engagement, so the leaf is **our own pinned observation** of it, to be re-verified at every version bump. Beside it, `activitypods-providers/usage_festipod.md`, our declaration toward it. Nothing is wired yet — the Refine seam that would use it (`src/shared/refine/`) carries no backend. + +Two declarations now share the name `usage_festipod.md`, so refer to either by its folder path rather than by wikilink — the stem alone no longer says which. ## Model & data - [[knowledge_nextgraph-stack]] — the SHEX shapes, the reactive ORM bindings, `build:orm`, injection through `ngSession` diff --git a/.project/contracts.yaml b/.project/contracts.yaml index 8ca1af9..0ebce53 100644 --- a/.project/contracts.yaml +++ b/.project/contracts.yaml @@ -23,8 +23,11 @@ # committed. publish: - # Key qualified by interface: a second declaration is coming for the NextGraph+polyfill - # backend, and publish keys must stay unique. + # Both declarations are named `usage_festipod.md`, so the keys are qualified by their + # interface — publish keys must stay unique, and the bare subject no longer says which. + # Listing a declaration here is what lets its provider re-find it with `list --from` + # after it moves; it sends nothing on its own. + polyfill-surface--festipod: concepts/data-layer/polyfill-surface/usage_festipod.md activitypods-providers--festipod: concepts/data-layer/activitypods-providers/usage_festipod.md consume: diff --git a/src/shared/refine/RefineProvider.tsx b/src/shared/refine/RefineProvider.tsx index 0d6d77f..d2f5a54 100644 --- a/src/shared/refine/RefineProvider.tsx +++ b/src/shared/refine/RefineProvider.tsx @@ -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 - * `` 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(null); +/** `undefined` when no backend is registered — see providers/index.ts. */ +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. - */ -/** - * 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(() => ({ 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. + // wallet. The context is still published, so callers get `undefined` rather + // than a thrown "outside a provider". if (!backend || !providers) { - return {children}; + return {children}; } return ( - + ({ 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 diff --git a/src/shared/refine/providers/index.ts b/src/shared/refine/providers/index.ts index 849b7dd..1558a89 100644 --- a/src/shared/refine/providers/index.ts +++ b/src/shared/refine/providers/index.ts @@ -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 = {}; -// 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]; } diff --git a/src/shared/refine/providers/types.ts b/src/shared/refine/providers/types.ts index eae4ab9..4749ad8 100644 --- a/src/shared/refine/providers/types.ts +++ b/src/shared/refine/providers/types.ts @@ -78,20 +78,18 @@ export interface RecordBinding { } /** - * 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; profile: RecordBinding; participation: RecordBinding; - meetingPoint?: RecordBinding; + meetingPoint: RecordBinding; } /** 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 diff --git a/src/shared/refine/variant.test.ts b/src/shared/refine/variant.test.ts new file mode 100644 index 0000000..6f709d4 --- /dev/null +++ b/src/shared/refine/variant.test.ts @@ -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', + }); +}); diff --git a/src/shared/refine/variant.ts b/src/shared/refine/variant.ts new file mode 100644 index 0000000..7fc49ee --- /dev/null +++ b/src/shared/refine/variant.ts @@ -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: '' }; +}