From df1db790addd745ff07613d168d8c7f1e3d14ad3 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Thu, 8 Oct 2026 10:08:40 +0200 Subject: [PATCH] The URL says which provider set runs, and the seam stops claiming otherwise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- .project/concepts/data-layer/_debt.md | 15 ---- .project/concepts/data-layer/_overview.md | 5 +- .project/contracts.yaml | 7 +- src/shared/refine/RefineProvider.tsx | 99 ++++++++++------------- src/shared/refine/providers/index.ts | 50 ++++++------ src/shared/refine/providers/types.ts | 22 +++-- src/shared/refine/variant.test.ts | 72 +++++++++++++++++ src/shared/refine/variant.ts | 48 +++++++++++ 8 files changed, 208 insertions(+), 110 deletions(-) delete mode 100644 .project/concepts/data-layer/_debt.md create mode 100644 src/shared/refine/variant.test.ts create mode 100644 src/shared/refine/variant.ts 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: '' }; +}