4 Commits

Author SHA1 Message Date
Sylvain Duchesne b1add6d8c8 Record the registration precedent, and the event-in-event feasibility without new shapes 2026-10-09 10:26:25 +02:00
Sylvain Duchesne 184998a343 Record the ActivityPods-first directives, and what the reference reconnaissance established 2026-10-09 09:48:52 +02:00
Sylvain Duchesne df1db790ad The URL says which provider set runs, and the seam stops claiming otherwise
The seam arrived carrying a switching design that no longer holds. It presented
runtime hot-switching as what shaped it — selection in state, a key on <Refine>
to drop the previous backend's cache — and it read the backend from a build
global whose comment claimed build.ts defined it. Nothing ever did, and nothing
could have: this project serves from src/ in production too, so that define
block never reaches what runs.

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

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

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

Also: both usage declarations are named usage_festipod.md, so the stem is now
ambiguous as a link. They are referred to by folder path, and both are listed
under publish so each provider can re-find its own if it moves.
2026-10-08 10:08:40 +02:00
Sylvain Duchesne e282384e00 The Refine seam travels to this base, deliberately unwired
The seam that puts Festipod's persistence behind interchangeable Refine backends
was built on `festipod-refine`, over a base this one has since overtaken
everywhere else. Carrying the seam rather than the branch keeps the evolved data
layer intact and costs nothing: the backend contract, the registry and the
provider that mounts Refine depend only on the domain types, so they compile
here untouched once `@refinedev/core` is present — the one dependency this adds,
installed through pnpm as this project requires.

Nothing imports them and the registry is empty, which is the intended state.
Wiring the seam means rewriting `FestipodDataContext` against this base's
version of it — its own decision, with its own failure modes, and not one to
smuggle into a transfer. Landing the files first makes that decision reviewable
against real code instead of a diff between two branches.

The doctrine travels with the code, because the seam is unreadable without it:
the reconstructed observation of `@activitypods/refine-providers` — a package
that publishes no engagement at all, hence `type: reconstruct` and a pin on the
commit it was read at — our usage declaration beside it, and the brief that
arbitrates one data shape for two rights models. So the manifest grows a
`publish:` section: this repo still offers no interface, but it does now publish
what it consumes, which is the only channel a provider has for learning that.

The brief was written where three of the leaves it cites did not exist, and
named them in prose to say so. Here they exist, so it links them; and its open
deduplication point now reads against this repo's own `materializeAttendance`
instead of against a sibling branch. It stays open, and the finding is
unchanged: the active set is keyed per deposit, which buys replay idempotence
and not person identity, so the count counts deposits rather than people.

Two comment zones came over knowingly superseded — runtime hot-switching as what
shapes the provider, and a sign-in barrier waiting on something `AuthGate` no
longer does. They are recorded as data-layer doc-debt instead of being quietly
rewritten, so the rewrite lands with the wiring that settles what they should
say.
2026-10-05 18:46:32 +02:00
14 changed files with 1044 additions and 8 deletions
+6 -3
View File
@@ -2,8 +2,8 @@
type: _overview
summary: How Festipod persists its data through the @ng-eventually/polyfill SDK — entities stored as documents placed by scope, direct SPARQL writes + reactive shape reads, SHEX stack, connected/demo modes, seed
triggers:
keywords: [nextgraph, "@ng-eventually", polyfill, watchShape, useShape, useShapeQuery, SHEX, shape, scope, "@graph", NURI, inbox, share, sparql, seed, wallet, FestipodData, ngSession, ngGraph, storeRegistry, bootstrap, document, entité, déconnexion, reconnexion]
paths: ["src/shared/shapes/**", "src/shared/data/**", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/*", "src/shared/data/seedData.ts"]
keywords: [nextgraph, "@ng-eventually", polyfill, watchShape, useShape, useShapeQuery, SHEX, shape, scope, "@graph", NURI, inbox, share, sparql, seed, wallet, FestipodData, ngSession, ngGraph, storeRegistry, bootstrap, document, entité, déconnexion, reconnexion, refine, provider, activitypods, participation, WAC]
paths: ["src/shared/shapes/**", "src/shared/data/**", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/*", "src/shared/data/seedData.ts", "src/shared/refine/**"]
---
# Data layer
@@ -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`
@@ -0,0 +1,88 @@
---
type: contract
summary: The Refine data, auth and live providers @activitypods/refine-providers exposes to an application over a Solid Pod — what each operation does, what a record looks like, and what the package does not offer
built_from: https://github.com/activitypods/refine-providers.git/src
built_version: 66616484b96fb2227cd8cdf02321aacfb4811365
built_at: 2026-10-02
---
# contract_activitypods-providers — `@activitypods/refine-providers`
**This is a RECONSTRUCTION, not the provider's promise.** The package publishes no engagement and no change policy, so this document is an observation made once, at adoption time, and pinned to the commit named in `built_version`. Nothing here is committed to by its authors: any release may move any of it. Re-observe at each upgrade — the pin is what makes a difference visible.
## Scope
Three Refine providers over a Solid Pod running ActivityPods: a `dataProvider` (CRUD over LDP containers), an `authProvider` (Solid-OIDC sign-in plus the app-registration dance ActivityPods requires), and a `liveProvider` (push over the Solid Notifications Protocol). Resources are not addressed by a path the caller chooses: each configured resource is mapped at runtime to an LDP container discovered in a WebID's Solid TypeIndex.
Two Ant Design components also ship (`antd-auth-page`, `antd-background-checks`) and are outside this scope — `antd` and `@ant-design/icons` are optional peer dependencies, so an app with its own interface consumes the three providers without them. `react-router` is likewise an optional peer dependency.
Package `@activitypods/refine-providers`, version 0.1.5 at the pinned commit. Peer dependency `@refinedev/core ^5.0.12`. Full typed shape: the package's shipped declarations, `./types` (`dist/types.d.ts`) and `./index` (`dist/index.d.ts`).
## Surface
```ts
dataProvider({ resources, authProvider, jsonContext? }): DataProvider
authProvider({ clientId, redirectUri?, allowAnonymous?, appStatusCheckInterval? }): AuthProvider
liveProvider({ resources, authProvider, jsonContext? }): LiveProvider
type ResourceConfig = { shapeTreeUri?: string; types?: string | string[] }
type ResourcesConfig = Record<string, ResourceConfig> // keyed by Refine resource name
type JsonContext = string | string[] | Record<string, any>
type AuthSession = { token: string; webId: string }
```
`AuthProvider` is Refine's `AuthProvider` plus four members: `getSession(): AuthSession | undefined` (synchronous), `handleCallback(): Promise<{ webId, redirect }>`, `registerApp(webId?): Promise<string | undefined>`, `getAppStatus(): Promise<AppStatus>`. Its `login`/`register` take `{ issuer, redirect?, isSignup? }` — the Pod provider's base URL is a caller-supplied parameter, so the app must let the user pick one.
`DataProvider` implements `getList`, `getOne`, `getMany`, `create`, `update`, `deleteOne` and `getApiUrl`. Nothing else.
Utility functions are exported from the package root as well (notably the TypeIndex discovery and container resolution used internally); they are not part of what this document engages.
## Guarantees
Observed at the pinned commit.
**A record is a JSON-LD resource with an `id`.** `id` is the resource's own URI — `id` or `@id` of the fetched document, whichever is present; a resource carrying neither raises. The record also carries `@context` (the container's, on a listed record) and every other predicate verbatim. There is exactly ONE identifier: `getOne`, `update` and `deleteOne` take that same `id` and use it directly as the URL they fetch.
**`getList`** resolves the resource's container, fetches it whole, and maps `ldp:contains`. Refine's filters and sorters are then applied **in memory**, and pagination slices the result; `total` is the count before slicing. The filter set is broad (equality, `in`, substring, prefix/suffix, ordering, `between`, null checks, and `and`/`or` nesting), string comparison being case-insensitive unless the case-sensitive variant is used, and a filter field may be a dotted path.
**`create`** POSTs `{ "@context": jsonContext, "@type": <resolved types>, ...variables }` to the container, requires a `Location` response header, and returns the record re-fetched from it. The `@type` is the resource's configured `types` expanded through `jsonContext`, or, when only `shapeTreeUri` is configured, the `sh:targetClass` of the shape the shape tree points at.
**`update` merges — a partial write is safe.** It fetches the current resource, drops its `id` and `@context`, and PUTs `{ "@context": jsonContext, ...current, ...variables }`. So values the caller omits survive. The merge is **shallow**: a nested object in `variables` replaces its counterpart whole. It returns the record re-fetched after the write.
**`deleteOne`** DELETEs the resource and returns `{ id }` only.
**A configured resource's container is discovered, not configured.** Resolution reads the WebID document, then its `solid:publicTypeIndex`, then best-effort its `pim:preferencesFile` → `solid:privateTypeIndex`, and takes the first type registration whose `solid:forClass` matches the resource's resolved types and which carries a `solid:instanceContainer`. No match raises, naming the WebID and the types. A `shapeTreeUri` is fetched over plain HTTP, as is the shape it points at — so a shape tree the caller hosts itself works, provided it is reachable and carries `st:shape` → `sh:targetClass`.
**`jsonContext`** is sent as `@context` on every create and update, and is what expands configured CURIE types. It defaults to the ActivityStreams 2 context; an application with its own vocabulary passes its own.
**`liveProvider` pushes — the caller never polls.** It opens a `notify:WebSocketChannel2023` channel per topic, discovering the subscription endpoint from the Pod origin's `.well-known/solid`. Watching specific records subscribes to each record and reports `Update` → `updated`, `Delete`/`Remove` → `deleted`. Watching a list subscribes to the **container** and reports `Add` → `created`, `Remove` → `deleted`. Unsubscribing closes the socket and deletes the channel best-effort. A resource absent from `resources` yields a no-op unsubscribe rather than an error.
**Auth is Solid-OIDC with PKCE.** `login` redirects to the issuer's authorization endpoint with scope `openid webid offline_access`; `handleCallback`, called from the redirect route, completes the code grant and stores the ID token. The session's `webId` is the token's `webid` claim, and an expired token reads as no session. `logout` clears local state. `check` reports unauthenticated with `redirectTo: "/login"` unless `allowAnonymous` is set. `getIdentity` returns `{ id: webId, name, avatar }`, name and avatar read from the WebID document and the profile it links — ActivityPods' own profile vocabulary, unrelated to any vocabulary the app defines for its own user records. `registerApp` returns the AppRegistration URI when the app is registered and its grants current, and otherwise redirects the browser to the user's authorization agent and resolves `undefined`.
**Failures throw, and carry the HTTP status.** Every Pod call that returns non-2xx raises an `Error` with `status` and `body` attached, its message taken from the response body's error message when there is one. `onError` maps a 401 to logout.
## Non-guarantees
**Every read and write is scoped to the logged-in user's own Pod.** Container resolution always runs against the session's own WebID — in `getList`, in `create`, and in a list subscription alike. There is no parameter, filter or option through which an operation reaches another user's resources: `getList` returns what the current user holds, and nothing else. (`getOne`/`getMany` fetch a URI directly and so are not bound this way, but they require the caller to already hold that URI.)
**No server-side querying.** Filtering, sorting and pagination are applied after the whole container has been fetched, so cost grows with the container, not with the page.
**`getManyReference` is not implemented**, despite the data provider's own documentation mentioning it. Neither are `createMany`, `updateMany` and `deleteMany`.
**`getMany` drops what it cannot fetch.** Failed fetches are filtered out silently, so the returned array may be shorter than `ids` with no indication of which are missing.
**Caches are module-level and never invalidated** — shape trees by URI, type registrations by WebID, subscription endpoints by origin. A container that becomes available after a first lookup (the user granting access, for instance) is not seen until the page reloads, which is what the registration flow expects.
**`authProvider` installs process-global timers it never removes.** Unless `appStatusCheckInterval: false` is passed, construction registers a `setInterval` and a `visibilitychange` listener, with no teardown; constructing the provider more than once per page accumulates them.
**One session per page.** The token lives at a fixed `localStorage` key, so two concurrent sessions cannot coexist.
**A list subscription does not report field changes.** A container notifies about membership only, so a member's own predicates changing is invisible to a list; the record has to be watched directly.
**`types` wins over `shapeTreeUri` when both are set** — the opposite of what the configuration type's own documentation states. Configure exactly one. Likewise, when several type registrations match a resource's types, the first encountered wins, with no stated ordering.
**Unregistered apps read and write nothing.** An ActivityPods app must hold an AppRegistration before any data operation succeeds, and keeping it current is the app's business, through `registerApp` and `getAppStatus`.
## Change policy
None is published. The package carries no engagement document, no stated versioning commitment and, at the pinned commit, no git tags — its released versions are visible only as `package.json` version bumps. Treat every upgrade as potentially breaking: re-read the surface against the new commit and re-stamp this document. Divergences found between this reconstruction and observed behaviour are defects in this document, to be fixed here.
@@ -0,0 +1,71 @@
---
type: usage
summary: What Festipod consumes from @activitypods/refine-providers as one of its interchangeable Refine backends, and the conditions its own vocabulary and confidentiality model put on that backend
against: 66616484b96fb2227cd8cdf02321aacfb4811365
---
# usage_festipod — Festipod on `@activitypods/refine-providers`
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
Nothing is wired yet, so the list below is **declared, not measured**: it is what Festipod will call, derived from the seam's requirements and from the observation of the package pinned beside this declaration. `against:` names that observation's commit. The package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
## Consumed surface
### Provider construction
- `authProvider({ clientId, redirectUri, appStatusCheckInterval: false })` — one call site, per backend selection. `appStatusCheckInterval` is disabled deliberately, see friction 1; Festipod drives the check itself.
- `dataProvider({ authProvider, resources, jsonContext })` — one call site, handed the `authProvider` above.
- `liveProvider({ authProvider, resources, jsonContext })` — same, so Refine can subscribe behind every query.
All three are built together, **per backend instance**, inside the seam's `build()`.
### Auth members
`login({ issuer, redirect })` and `register(...)` — Festipod supplies its own Pod-provider picker. `handleCallback()` from its own callback route. `logout()`. `check()`. `getSession()` for the `webId` and the token. `registerApp()` and `getAppStatus()`, the second one on Festipod's own schedule since the built-in interval is off.
### Resources and operations
Four resources, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is configured with `types` naming Festipod's own class URIs, and `jsonContext` carries the context that expands them.
Through Refine's hooks only, never the provider directly: `useList` → `getList`, `useCreate` → `create`, `useUpdate` → `update`, `useDelete` → `deleteOne`. `getOne` is reached by Refine's cache plumbing rather than an explicit call. The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`.
### Record handling
A record's `id` — the resource's URI — is the **only** identifier Festipod uses: the seam's `recordKey` and `domainId` both return it. `update` merging the fields it is given means the seam declares this backend as patching, and sends only what changed.
### Not consumed
- `shapeTreeUri` — exactly one of the two resource identifiers must be configured, and `types` names Festipod's classes directly, without a shape tree to host and keep reachable.
- `getIdentity` — the signed-in identity and a Festipod user profile are two unrelated things in this app; the profile is read as an ordinary record, and only `getSession`'s `webId` is needed.
- `getMany`, `getApiUrl`, `allowAnonymous`, and both Ant Design entry points — Festipod has its own interface.
## Constraints
What must keep holding for this backend to serve the app.
1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied.
2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently.
3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error.
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. See need 1: this is the constraint the package does not currently let the app satisfy.
5. **The count is never a stored, incremented field.** Reading a counter, adding one and writing it back derives each write from a read of the previous one and races itself through any data provider. This is measured, not theoretical, and it is why the app derives the count from the participation list at read time.
6. **Changes must be pushed, not polled.** Neither the app nor its BDD suites has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis.
7. **No process-global state that outlives a remount.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must have no side effect that accumulates across switches — see friction 1, which is where this currently bites.
8. **Each entity carries a declared visibility** — public, protected or private, per the product's confidentiality model — and it is the backend that honours it. Refine has no notion of it, so the intent is declared at the seam and enforcement is the backend's.
## Frictions
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely.
2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience.
3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible.
4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins.
## Needs
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID. So `getList('events')` returns only the events the current user created, and `getList('participations')` only their own participations.
This blocks the product, not a detail of it. Festipod exists to let a user **discover other people's public events** and see **how many people have signed up** to a meeting point. Through this provider the app can show neither: no other user's event is reachable, and the participant count of constraint 4 cannot be computed from participations it cannot read.
The request is modest in shape, because the capability already sits one layer down: the package's own TypeIndex discovery takes a WebID as a parameter, and notes that the public TypeIndex is readable without any special grant. What is missing is a way to reach it **through the data provider** — a way to scope a `getList` (and the matching list subscription) to a given WebID, or to a set of them. Festipod has no interest in reimplementing container resolution beside the provider; that would put the app back in the business the provider exists to hold.
2. **Releases that can be pinned, and a statement of what changes between them.** The package carries no git tags, so the only thing this repo can pin is a commit on a moving branch, and the only way to learn what an upgrade changed is to re-read the source. Tagged releases plus a note of what moved in each — in whatever form suits the project — would let a consumer upgrade deliberately instead of re-observing from scratch every time.
@@ -0,0 +1,100 @@
---
type: brief
summary: One persisted shape holding on BOTH backends — NextGraph (per-document encryption) and ActivityPods (WAC) — references rather than embedded content, no stored counter, with the acl:Append behaviour and the deduplication gap left open
---
# Brief (2026-10-03) — One data shape for two rights models
Festipod puts all its persistence behind interchangeable Refine backends (the seam's own contract is in code, `src/shared/refine/providers/types.ts`). Two are targeted: NextGraph plus its polyfill, and `@activitypods/refine-providers`, whose reconstructed observation is pinned beside this leaf ([[contract_activitypods-providers]]). The question this brief answers: what structure survives both rights models unchanged.
## The primitives, side by side
| What is needed | ActivityPods | NextGraph |
|---|---|---|
| Unit of rights | LDP resource + WAC | **document** |
| List attached to a resource | `as:Collection` attached by a predicate, `semapps:dereferenceItems` | a Set inside the document |
| Let a third party contribute | `acl:Append`, or `apods:PostOutbox` to the recipient's inbox | `inbox.postToDocument` |
| Grant "my connections" | `acl:agentGroup` + a WAC group | per-document sharing, once per connection |
| Name without granting access | reference + WAC refuses the GET | reference carrying no key |
| Revoke | `pod-permissions.remove` exists | not published |
| Write offline | no (client-server) | yes (local-first) |
## The design rule that follows
**The common denominator is NextGraph**, whose model is the strict subset: no groups, no revocation, sharing wholesale per document. So structure for NextGraph and ActivityPods will serve that structure — more cheaply even, with groups. The converse does not hold: any structure that needs a server-side projection or a revocation is untenable on NextGraph.
## The structure
```
Event own document, public, written by its creator
└─ participations an attached list of REFERENCES (never embedded content)
Participation own document, owned by THE PARTICIPANT
├─ event → ref
├─ active → boolean
└─ participant → ref to the Profile
Profile own document, protected
```
## Why references and not embedded content — three facts
1. **Ownership.** The participation must be writable by its participant and by nobody else (withdrawal must be authoritative — [[caveat_participation-deletion]]). On NextGraph write access is granted per document, so the participation **must be** a document; embedded in the event it would be writable only by the event's creator.
2. **Per-reader variation is impossible inside a single document on NextGraph**, because encryption and sharing are per document. It is obtained by **indirection**: the reference is always present, and what varies is the ability to **follow** it.
3. [[rule_document-per-entity]] already says this, and says it for exactly this reason.
## The non-visibility mechanism differs — PO precision, 2026-10-03
On NextGraph the target is **undecipherable** for want of a key: nothing is absent, the reference itself is readable. On ActivityPods it is either inaccessible (WAC refuses the GET) or the reference is omitted by server-side projection. Both land on the same application state: **a reference one may or may not resolve.**
## Consequence on the domain types
The type models the NextGraph shape, where everything is present:
```ts
interface FpParticipationData {
id: string;
event: string;
active: boolean; // NOT optional — the participation is readable by everyone
participant: string; // NOT optional — a reference, which names without granting access
}
```
Resolution alone may fail: `resolveParticipant(ref)` yields `FpUserData | undefined`, where `undefined` means "not a connection". ActivityPods' omission is a **degradation the binding absorbs**: it reports "a participation whose participant does not resolve" — the same state as "I do not hold the key".
An error made in session and corrected here: `active?` and `participant?` were proposed first, which lifted the ActivityPods adaptation into the common model.
## The count
**No stored counter, ever.** The number of participants is `participations.length`. `knownParticipants` is the participations whose participant resolves — a presence test, not an access check. Withdrawal is `active: false` on one's own document.
## The three invariants that make the app platform-indifferent
1. One document per entity; never embedded content.
2. A reference may be unresolvable, and that is a **normal state** — neither an error nor an absence (the app shows "someone").
3. The list is an **upper bound that converges**; never assume it is complete at the instant (already documented by [[caveat_participant-count-one-connection-lag]]).
## Open points
### NOT VERIFIED — does `acl:Append` on a collection let a third party insert an item without being able to remove one?
WAC mode enforcement lives in `@semapps/webacl`, absent from the ActivityPods checkout consulted and not installed. The mode is real and genuinely used (`tests/apps/example.app.js` declares `accessMode: ['acl:Read', 'acl:Append']`), but its behaviour on a SparqlPatch `INSERT DATA` / `DELETE DATA` was not observed.
**The structure does not depend on the answer.** If Append works that way, the participant appends directly and the count is immediate. Otherwise the route is `apods:PostOutbox` to the creator's inbox, whose app adds the reference — which is exactly the NextGraph shape. Only the latency changes.
### OPEN — deduplication: neither platform counts people, and the implementation here does not either
[[brief_2026-07-20_attendance-set-model]] requires that the creator tell two references from the *same* person apart from two references from different people, **without knowing who** — otherwise the count is not a count of people and a participant can inflate it. **The code here does not do that** (verified 2026-10-05). A different problem is solved, and solved well: `materializeAttendance` (`src/shared/data/registration.ts`) keys its active set by a **per-deposit** `uid` minted from time plus randomness, so a broker re-sync replaying the same deposits converges instead of double-counting — that is **replay idempotence**, not person identity. The count is therefore a count of **deposits**: the same person joining twice counts twice. Three further findings about the current implementation:
- **The plaintext `userId` travels in the deposit**, and the cancellation fallback depends on it — a leave without a known `regUid` cancels the earliest still-active join carrying that `userId`, so N leaves cancel N joins. The attendance-set brief's own change *"drop the `userId` from inbox deposits"* was never made, so the creator **does** know who and the anonymity property does not hold today. The privacy-preserving discriminator was never needed because the privacy was never implemented.
- **Participations live in the protected scope**, not public, and withdrawal is still a deletion ([[caveat_participation-deletion]]) — so the target model is not in place either. The attendance-set brief's own status line, *model settled / implementation gated*, still holds.
- **The gap was never raised through the declared channel.** The five frictions in this app's polyfill declaration (`polyfill-surface/usage_festipod.md`) cover inbox idempotence, the absence of a push on one's own deposit, the reactive read not seeing one's own write, the impossibility of resetting the test wallet, and a wrong published signature. None asks for a per-person discriminator.
So the divergence is not "NextGraph has an upstream gap while ActivityPods does not": **both count deposits rather than people.** ActivityPods could arbitrate server-side because the server sees everything; NextGraph awaits a primitive. And the gap sits in **neither Refine provider** — it is in the polyfill on one side and the server on the other, which is what matters when deciding what to contribute where. The upper bound remains the common fallback position, already accepted by the PO.
## Two ActivityPods constraints not to rediscover later
- **The Refine providers cover CRUD only.** Creating the collection, setting WAC, creating a group, posting to the outbox are not in the data provider, which does not implement Refine's `custom` either — so there is no escape hatch through Refine and those calls happen beside it.
- **A custom ontology prefix must be registered at prefix.cc**, the Pod provider requiring every ontology to have a known prefix; this concerns our `fp:`. Shape trees must also sit at a public URL, one per entity, since they cannot reference other shapes — no sharing cascade.
## Reservation to keep alive
The reference to the profile is readable by everyone and therefore collectable: it is a **correlatable pseudonym, not anonymity**, and must never be presented as such. The reservation comes from the attendance-set-model brief, and the 2026-10-03 precision reactivates it.
@@ -0,0 +1,43 @@
---
type: brief
summary: ActivityPods is the sole priority provider — everything that can goes through the Refine providers, a backend for actions on behalf of a user is to be added, the NextGraph+polyfill backend is suspended (temporary); reference material and open questions recorded 2026-10-09
---
# Brief (2026-10-09) — ActivityPods first, and a backend for on-behalf actions
Directives recorded at the resumption of the hand-off (`.project/handoff/refine-provider-abstraction.md`), to anchor the ActivityPods integration workstream.
## Directives
1. **ActivityPods is the priority.** The Refine backend over NextGraph plus its polyfill is **suspended, temporarily** — it is not abandoned (the two-rights-models structure still targets both), but no work goes into it while ActivityPods is being wired.
2. **Everything that can must go through `@activitypods/refine-providers`.** The Refine boundary rule from the hand-off applies: what Refine can express is a Refine resource; what it cannot (collection creation, WAC, groups, outbox posting) happens beside Refine, in the seam or in the new backend. Capability differences stay inside the provider layer, never in the app.
3. **A backend must be added for actions "on behalf of a user".** Festipod needs server-side actions executed for the account of a user (the pattern used by the reference apps below). Its mechanism, packages and deployment shape are the first design question of this workstream.
4. **Minimise new shapes — interoperability first (directive 2026-10-09).** A new shape is a last resort, not a default: anything an existing vocabulary already carries must reuse it. A MeetingPoint is therefore modelled as **an event within an event** — an `as/Event` distinguished by an **optional `parentEvent` reference** to its anchor event, not a new `fp:MeetingPoint` type. Whether an existing ActivityStreams predicate (e.g. `as:context`) can carry that link, or the `as/Event` shape must gain an optional property, is part of the survey. For Participation, the existing fediverse vocabulary must be surveyed before anything is created.
## Reference material (user-designated 2026-10-09)
Reading these at this stage is adoption-time reading, explicitly directed — the standing constraint against reading provider sources at work time is unaffected (`@activitypods/refine-providers` remains specified by its pinned reconstruction, `activitypods-providers/contract_activitypods-providers.md`).
- **Example apps** — `/home/sylvain/projects/reconnexion/welcometomyplace` and `/home/sylvain/projects/reconnexion/mutual-aid.app`: working integrations to mine for provider configuration, resources, and the on-behalf backend pattern.
- **ActivityPods platform source** — `/home/sylvain/projects/activitypods/activitypods`; Semapps lives in a sibling folder.
- **Shapes** — `/home/sylvain/projects/activitypods/shapes`: readily usable, and **evolvable by us** if Festipod's entities require it (that repo is ours to adapt).
## Constraints already on record (see the 2026-10-03 brief and the reconstruction)
- `fp:` must be registered at prefix.cc; shape trees must sit at public URLs, one per entity, no sharing cascade.
- The Refine providers cover CRUD only; `custom` is not implemented — no escape hatch through Refine.
- `getMany` silently drops failed fetches — it erases the "unreadable reference" state the model needs (a candidate contribution).
- `acl:Append` third-party insert-without-remove behaviour is NOT VERIFIED; the structure does not depend on the answer.
## Reconnaissance answers (2026-10-09)
- **On-behalf mechanism — ANSWERED.** An app backend is an ActivityPub actor (`@activitypods/app` `AppService`, Moleculer); it signs pod requests as the app actor and the pod impersonates the user after checking registration + grants. Frontend calls use the Solid-OIDC dual-identity token instead. No unattended per-user backend token exists.
- **Registration/Participation — production precedent found.** welcometomyplace persists NO registration resource: a join is an `as:Join` activity posted to the user's outbox, the backend service handles it and adds the actor to the event's `apods:attendees` collection (attached via `attachPredicate`, `PodCollectionsHandlerMixin`); a leave is `as:Leave`, same path in reverse. Membership is read via `pod-collections.getItems`. mutual-aid uses none of this (comments, likes, announces only).
- **Event-in-event — feasible without touching any shape.** The `as/Event` and `as/Profile` SHACL shapes are open (no `sh:closed`), and the generic ActivityStreams vocabulary already defines `as:context`, `as:partOf`, `as:inReplyTo`. A meeting point is a plain `as/Event` carrying an `as:context` link to its parent event — the presence of the link is what distinguishes it, nothing else. Shapes are consumed for access needs and container setup, not server-side resource validation, so nothing breaks.
## Open questions
- Participation topology — the one real divergence from the 2026-10-03 structure: welcometomyplace keeps the canonical list creator-side (an `apods:attendees` collection on the event, writes backend-mediated), where the two-rights-models structure makes each participation a participant-owned document. Both land on the same application state (a reference that may or may not resolve); the choice is who owns the write path. To arbitrate before implementation.
- `as:Join` has a shape but no shape tree: if participations must be addressable as resources in access needs, adding `shapetrees/as/Join.ttl` for an already-existing AS type is the minimal move — or the collection pattern makes it unnecessary.
- The backend's deployment shape (service alongside the app? a platform app?).
- Whether the four pointed-to provider contributions (from the hand-off) are still the right set once the backend exists — some may move from "contribution to the providers" to "our backend's job".
+34 -5
View File
@@ -1,7 +1,10 @@
# Inter-repo contracts. Festipod is a CONSUMER only: it publishes no interface of its own,
# and it consumes two — the SDK surface `@ng-eventually/polyfill` engages toward the
# applications built on it, and the indexing layer `ng-helpers` engages toward the
# applications that need to make things findable.
# Inter-repo contracts. Festipod publishes no interface of its own — what it lists under
# `publish:` is its own consumer-side DECLARATION, the channel a provider pulls to learn
# what this app calls, what it needs and what blocks it. It consumes three — the SDK
# surface `@ng-eventually/polyfill` engages toward the applications built on it, the
# indexing layer `ng-helpers` engages toward the applications that need to make things
# findable, and the Refine data providers `@activitypods/refine-providers`, which engage
# nothing at all.
#
# The pulled copy under `into:` IS the specification Festipod codes against. An agent
# working here reads that copy and never opens the provider's own source: a gap is raised
@@ -9,7 +12,7 @@
#
# Each interface gets its own FOLDER inside the concept that owns it, holding the pulled
# engagement and — once Festipod actually consumes the interface — the `usage_festipod.md`
# declaration beside it. Both interfaces land in `data-layer`: it is the concept that owns
# declaration beside it. All three land in `data-layer`: it is the concept that owns
# how Festipod uses an external data surface, including the machinery behind discovery
# (`functional-domain` owns the product intent of discovery and explicitly delegates its
# technical how to the data SDK).
@@ -19,6 +22,14 @@
# access to a local checkout lives in `.project/contracts.local.yaml`, which is never
# committed.
publish:
# 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:
- contract: polyfill-surface
into: concepts/data-layer/polyfill-surface/
@@ -43,3 +54,21 @@ consume:
# Pinned on the TAG, never on a branch: a branch moves under us and the pin would stop
# naming a state anyone can go back to. Re-pin to the next tag at each upgrade.
ref: v1.0.1
- contract: activitypods-providers
into: concepts/data-layer/activitypods-providers/
# `@activitypods/refine-providers` publishes NO engagement: no contract document, no
# stated change policy, not even a git tag. So this entry is `type: reconstruct` — the
# one bounded crossing this protocol allows, made once at adoption time: the leaf under
# `into:` is OUR observation of that package, authored here and pinned to the commit it
# was read at. It is not a promise anyone made us, and every upgrade has to re-observe
# it. Once reconstructed, that leaf IS the specification this app codes against.
type: reconstruct
# Canonical identity: the package's git remote plus the repo-relative folder the
# observation was made from. Per-developer access to a checkout of it lives in
# `.project/contracts.local.yaml`, which is never committed.
reconstructFrom: https://github.com/activitypods/refine-providers.git/src
# The branch the observation was read from. A tag would be the better pin, but this
# repository publishes none — the resolver turns this into the commit actually read,
# which is what `built_version` records.
ref: master
@@ -0,0 +1,137 @@
---
subject: Refine as Festipod's provider-swap abstraction layer
created: 2026-10-08
status: open
---
# Refine as the provider-swap abstraction layer
## Where the work lives
Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`, pushed to origin. Two commits: `e282384` (the seam carried onto this base) and `df1db79` (URL selection + corrections).
It is based on **`ng-eventually`** at `c7cda38` — the most advanced branch, and the reason this branch exists. An earlier attempt, **`festipod-refine`** (tip `352d1f1`, worktree `.claude/worktrees/festipod-64`), was built on `main`, which turned out to be far behind; the Refine work was transferred here and that branch is now only an archive. It holds the **only** worked implementation of a `@ng-org/refine-providers` backend (`src/shared/refine/providers/nextgraph.ts`, in its history up to the commit that withdrew it) — the reference for the shape of the next backend.
The main checkout `/home/sylvain/projects/festipod/festipod` is itself on `ng-eventually`. Do not modify it. Note that the concept hook reads it, so it announces leaves that may not exist in whichever worktree you are in.
## The goal
Put all of Festipod's persistence behind Refine so that several **provider sets** can serve the same app. **ActivityPods is the default and the sole priority** — directive of 2026-10-09 (recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`): everything that can goes through `@activitypods/refine-providers`, and a **backend for actions on behalf of a user** is to be added. A Refine provider over NextGraph plus its polyfill remains the declared second target, but its development is **suspended, temporarily** — no work goes into it while ActivityPods is wired.
## Directives of 2026-10-09 (resumption) — and first reconnaissance
Recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`; the essentials and what the reconnaissance (three read-only passes over the user-designated reference repos) established:
- **The on-behalf mechanism is mapped.** An app backend IS an ActivityPub actor (`@activitypods/app` `AppService`, Moleculer). It signs pod requests with HTTP signatures as the app actor; the pod provider checks the app's registration and grants, then impersonates the user (`pod-provider/backend/middlewares/app-control.js`). Services call `pod-outbox.post`, `pod-resources.*`, `pod-collections.*`, `pod-wac-groups.*`, `pod-permissions.*` with an `actorUri`. Frontend calls use the Solid-OIDC dual-identity token instead (`webId` = app client_id, `impersonatedUser` = user). Reference implementations: `welcometomyplace/backend/services/registration.service.js`, `attendees-matcher.service.js`; both example apps run the same stack (`@activitypods/app` 2.3 + `@semapps/*` 1.2, Moleculer backend + Vite/React/Refine frontend, Docker-deployed).
- **The refine-providers package is NOT in the platform monorepo** — it lives at `/home/sylvain/projects/activitypods/refine-providers` (a `/home/sylvain/projects/nextgraph/activitypodes-refine-providers` checkout also exists; the pinned reconstruction remains the work-time specification).
- **Shapes**: 15 shape trees published at `https://shapes.activitypods.org` from `/home/sylvain/projects/activitypods/shapes` (repo evolvable by us). The 2026-10-09 directives **amended the first mapping proposal — minimise new shapes, interoperability first**: a MeetingPoint is a plain `as/Event` carrying an `as:context` link to its parent event (event within an event; the `as/Event` shape is open — no `sh:closed` — so nothing breaks and no new shape is needed). For Participation the existing vocabulary was surveyed: see the registration precedent in the next bullet.
- **Registration/Participation — production precedent found.** welcometomyplace persists no registration resource: join = `as:Join` activity in the user's outbox, handled by the backend service which adds the actor to the event's `apods:attendees` collection (`PodCollectionsHandlerMixin`, `attachPredicate`); leave = `as:Leave`; reads via `pod-collections.getItems`. The one open design question this raises — collection creator-side (this pattern) vs participant-owned participation documents (the 2026-10-03 structure) — is recorded in the brief, to arbitrate before implementation.
- **No unattended per-user backend token exists** on the platform: server-side per-user actions ride the app-actor signature + grants; the platform-idiomatic channel for user-facing reactions is the app outbox → user inbox (notifications style).
## What exists, and what deliberately does not
In `src/shared/refine/`:
- `variant.ts` + `variant.test.ts` — pure `resolveVariant(pathname, known)`: which provider set the URL selects. 8 tests covering the case space, including the one true today (empty registry → every URL is the default).
- `providers/types.ts` — the contract a backend must satisfy (`RefineBackend`, `RecordBinding`), each field derived from a measured encounter rather than anticipated.
- `providers/index.ts` — the registry. **EMPTY on purpose**: no backend is implemented yet.
- `RefineProvider.tsx` — mounts `<Refine>` over the backend the URL named.
**None of it is wired.** Nothing imports `RefineProvider`; `FestipodDataContext` on this base is untouched and still talks to the polyfill directly. Wiring it means rewriting that context against this base's much-evolved version — that is the step everything else waits on.
In `.project/`:
- `concepts/data-layer/activitypods-providers/contract_activitypods-providers.md` — a **reconstruction**, not a pull: that package publishes no engagement, so this is our own observation, pinned (`built_version: 66616484b9…`). It is the specification to code against.
- `concepts/data-layer/activitypods-providers/usage_festipod.md` — our declaration toward them. Its Frictions/Needs are the channel to the provider.
- `concepts/data-layer/brief_2026-10-03_structure-for-two-rights-models.md` — the data-shape design, below.
- `concepts/data-layer/brief_2026-10-09_activitypods-first-backend.md` — the 2026-10-09 directives (ActivityPods priority, on-behalf backend, NextGraph suspended) and the reference material; the brief this hand-off's resumption added.
- `handoff/refine-provider-abstraction.md` — this document.
- `contracts.yaml` — `publish:` (both declarations, keys qualified by interface) and three `consume:` entries (polyfill-surface, indexing-layer, activitypods-providers).
- `contracts.local.yaml` — gitignored, per-worktree. Must exist for `contracts.py check` to resolve the ActivityPods entry.
## Decisions, and why
**One app variant per provider set, selected by a URL path prefix.** The default at the root, another behind a prefix naming it; switching is a plain link and costs a full page reload. The reload is the mechanism, not a cost: it discards the previous backend's Refine cache and session with no code to undo them, and losing access to the left backend's data is accepted. An earlier plan for in-session hot switching was dropped. The deep reason: **the sign-in experience is not abstractable** — the NextGraph 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 must render itself (Pod-provider picker, callback route). One confiscates the screen, the other delegates a flow.
The prefix **is** the backend's name, so a backend must never be named after a top-level route (`events`, `profile`, `home`, `settings`, `users`).
**The router does not yet strip the prefix, deliberately.** `parsePath`/`navigate` in `src/app/router.tsx` are a shared primitive every screen depends on; the suites that would prove a change there are not run in this worktree; and while one backend is registered no URL carries a prefix, so nothing is broken. That half lands with the second backend, when it becomes testable.
**The data shape: references, not embedded content.** Three levels — Event (own document, public, written by its creator, carrying an attached list of *references*); Participation (own document, owned by **the participant**, carrying `event`, `active`, `participant`); Profile (own document, protected). Three facts force it: the participation must be writable by its participant and nobody else, and NextGraph grants write per document; per-reader variation is impossible inside one document where encryption is per document, so it is obtained by **indirection** (the reference is always present, the ability to *follow* it varies); and `rule_document-per-entity` already says this for that reason.
The non-visibility mechanism differs and both land on the same application state — *a reference one may or may not resolve*. On NextGraph the target is **undecipherable** for want of a key (nothing is absent). On ActivityPods it is refused by WAC, or omitted by server-side projection. So the domain type models the NextGraph shape, where everything is present: `active` and `participant` are **not** optional; only resolution fails. (Proposing them optional was an error — it lifted an ActivityPods adaptation into the common model.)
**No stored counter, ever.** The participant count is `participations.length`. `knownParticipants` is the participations whose participant resolves — a presence test, not an access check. Withdrawal is `active: false` on one's own document.
**NextGraph is the common denominator.** Its model is the strict subset: no WAC groups, no revocation, sharing wholesale per document, but writes work offline. Structure for it and ActivityPods serves the structure, more cheaply even. The converse fails: anything needing a server projection or a revocation is untenable on NextGraph.
**The Refine boundary rule.** Refine's `DataProvider` is flat CRUD (`getList`, `getMany?`, `getOne`, `create`, `createMany?`, `update`, `updateMany?`, `deleteOne`, `deleteMany?`, `getApiUrl`, `custom?`) — **no relation or join concept at all**, and no `getManyReference`. Everything that makes Festipod social (append to someone else's collection, grant/revoke read, discovery, offline queueing) is outside its vocabulary. Since cache, live subscription and optimistic update are bought **per resource**, the rule is: *anything the app displays and that changes must be a Refine resource; anything one-shot and invisible may live in the seam.* A write passed beside Refine does not invalidate its cache, so the list would not refresh.
Refine does support **several data providers at once** (`Resource.dataProviderName`, `useList({dataProviderName})`). Deliberately unused for switching, but it is the tool for reading one entity from another backend (discovery from a shared index) or for migration.
**The seam should shrink.** `types.ts` was written from measurements against one raw provider that could not be changed. Since we can contribute to the providers, several of its fields are symptoms rather than requirements: `updateReplaces` + `toPatch` should vanish (a provider exposes one semantic), and `recordKey` vs `domainId` should collapse to `id` (ActivityPods already does). `toDomain`/`toValues` shrink to vocabulary mapping; `visibility` and `resource` stay — visibility is a product declaration, not a transport fact. **The measure of success is how small `RecordBinding` gets.** Capability differences belong inside the provider, never in the app.
## What is known about `@activitypods/refine-providers`
The reconstruction is the specification. Its source was read **once, deliberately, at adoption time** (that is what a reconstruction is); it is not to be read again at work time — a gap goes into the declaration's Frictions/Needs. Checkouts, if ever needed again: the package at `/home/sylvain/projects/nextgraph/activitypodes-refine-providers`, the platform at `/home/sylvain/projects/activitypods/activitypods`.
- A record's `id` is the LDP resource URI, and it is the **only** identifier — `getOne`/`update`/`deleteOne` use it directly as the URL. The seam's two identifiers collapse into one here.
- `update` fetches the current resource, strips `id`/`@context`, and PUTs `{...current, ...variables}` — so **a partial write is safe**, the merge being **shallow**.
- `getList` resolves the resource's container from the **session's own WebID** (public Solid TypeIndex, best-effort private), so it returns only what the current user holds. `getOne`/`getMany` fetch a URI directly and so **do** cross Pods — they just need the URI.
- No server-side filtering, sorting or pagination: the whole container is fetched, then filtered in memory.
- **`getMany` silently drops failed fetches** (`allSettled` then filter) — it erases exactly the "unreadable reference" state the model needs.
- Module-level caches (shape trees, type registrations per WebID, subscription endpoints) are **never invalidated**.
- `authProvider` installs a `setInterval` and a `visibilitychange` listener at construction with **no teardown**; the only opt-out is `appStatusCheckInterval: false`.
- Live updates are real push (`notify:WebSocketChannel2023`), but a **list** subscription reports membership only (`Add`/`Remove`), never a member's field change.
- `types` takes precedence over `shapeTreeUri` when both are set — the **opposite** of what the configuration type documents.
- `custom` is not implemented, so there is **no escape hatch through Refine** for non-CRUD operations.
- Collections are POSTable LDP resources (`/as/collection`) with `semapps:dereferenceItems` — *"all items will be dereferenced (if permissions allow)"*. That is how `event.participations` can come back as full objects for a reader who is allowed, and as URIs otherwise, in one read. Items are added/removed by SparqlPatch on `as:items`.
- `acl:Append` exists among the WAC modes and is really used in app access needs. **Whether it lets a third party insert an item without being able to remove one is NOT VERIFIED** — enforcement lives in `@semapps/webacl`, absent from the checkout. The structure does not depend on the answer: if it works that way the participant appends directly; otherwise the route is `apods:PostOutbox` to the owner's inbox, which is exactly the NextGraph shape, and only latency changes.
- An app declares `accessNeeds` (`shapeTreeUri` + `accessMode`) plus special needs: `apods:CreateCollection`, `apods:CreateWacGroup`, `apods:PostOutbox`, `apods:QuerySparqlEndpoint`, `apods:UpdateWebId`, inbox/outbox reads.
- **Backend-less apps are supported**: `onlineBackend` is false only when the app description document at the `clientId` URI is not fetchable. A static file suffices.
- A custom ontology prefix **must be registered at prefix.cc** (the Pod provider requires every ontology to have a known prefix) — this concerns Festipod's `fp:`. Shape trees must sit at a public URL, one per entity, and **cannot reference other shapes** (no sharing cascade).
**Four contributions this points to** (we may contribute to these providers): `getMany` must signal the unreadable instead of swallowing it; an append path into a collection one does not own; collection support as a resource with `dereferenceItems`; and a teardown for the auth provider's timer.
## The deduplication question — answered, and still open
The attendance model (`brief_2026-07-20_attendance-set-model`, PO 2026-07-27) requires the creator to tell two references from the *same* person apart from two from different people **without knowing who**, or the count is not a count of people and a participant can inflate it.
**Checked on this base: not solved.** A different problem is, and well. `materializeAttendance` (`src/shared/data/registration.ts`) keys its active set by a **per-deposit** `uid` minted from time plus randomness, so a broker re-sync replaying the same deposits converges — that is **replay idempotence**, not person identity. The count counts **deposits**: the same person joining twice counts twice. Further: the **plaintext `userId` travels in the deposit** and the cancellation fallback depends on it (a leave without a known `regUid` cancels the earliest still-active join for that `userId`), so the creator *does* know who and the anonymity the model rests on does not hold. Participations are in the **protected** scope rather than public, and withdrawal is still a deletion. And the gap was **never raised** through the channel meant for it.
So both platforms count deposits rather than people, and the gap sits in **neither Refine provider** — polyfill on one side, server on the other. ActivityPods could arbitrate server-side because the server sees everything.
## Problems hit, and how they turned out
- **Two false claims were written into durable records and corrected.** First, that the participant count needs an inbox and that a CRUD-only backend therefore could not carry the model — wrong: every participation is readable by everyone, only the link to a person is protected, so counting is ordinary CRUD. Second, a declared "need" for a WebID filter on `getList` — Festipod does not need it: `getOne`/`getMany` already cross Pods, the participations arrive with the event, and discovery is an **index** problem (already decided on this base: `decision_2026-08-17_discovery-through-a-shared-index`), not a provider one. Both corrections are in the files.
- **`__FESTIPOD_BACKEND__` was a lie in a comment** claiming `build.ts` defined it. Nothing did, and nothing could: this project serves from `src/` in production too (`bun run start`), so `build.ts`'s `define` block never reaches what runs (`tech-stack`, `knowledge_build-pipeline`). The runtime mechanism this base already uses instead is a config route — `/festipod-config.json` served by `src/index.ts`, read by `src/app/frontend.tsx` *before* it imports `App`. Replaced by the URL read.
- **Three separate times the answer was already on `ng-eventually`** (the runtime config route, the delegation of wallet access to the SDK, the dedup implementation). That is what triggered rebasing onto it.
- **The migration was delegated to a subagent** and verified independently afterwards: the five transferred files are byte-identical, the manifest merge preserved both pre-existing entries, and the gates were re-run by hand. Its one worry — that a missing local `node_modules` had made the baseline incomparable — was **unfounded** (pnpm had installed the full tree locally; 22 entries against the main checkout's 21). Its debt block was better than the specification it was given: it caught the `__FESTIPOD_BACKEND__` consequence and two branch-relative phrasings.
- **Moving base falsified three claims in the transferred code**, all corrected: the inbox the count comment points at is local here; the worked provider implementation is not in this branch's ancestry; and the `MeetingPoint` SHEX shape **does** exist here, so its binding stopped being optional (a backend that cannot carry one of the four entities cannot serve the app).
## Current gate state
`bunx tsc --noEmit` exit 0; `bun test` 32 pass / 0 fail; `contracts.py check --integrity-only` clean. `lint` is clean except one **pre-existing ERROR**: `polyfill-surface/usage_festipod.md` has **no `## Needs` section**. The e2e / `@data` / cucumber suites have **not** been run in this worktree, so their state here is unknown.
`contracts.py check` also reports, pre-existing: `polyfill-surface: provider moved: a33fb8a214 → a8d53010c2` — the pulled copy is behind the pinned ref.
## Open points
- Adding `## Needs` to the polyfill declaration is where the per-person discriminator belongs. It is **outward-facing** (the provider reads it on its next pull), so it awaits the user's go.
- `acl:Append` behaviour, as above.
- The router's prefix stripping, with the second backend.
- The four provider contributions.
- Wiring the seam: rewriting `FestipodDataContext` against this base, and a first `RefineBackend` (ActivityPods, being the default).
- Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path.
## Standing constraints
- A pulled or reconstructed contract **is** the specification. Never read a provider's source at work time; a gap goes into the declaration's Frictions/Needs and is signalled out of band (content lives in the document). Never describe in this repo how the data layer is implemented.
- **No attribution to Claude** anywhere in produced artifacts — commits, PRs, docs, comments. This overrides harness defaults.
- Project documentation in **English**, never hard-wrapped (one line per paragraph or list item). Conversation with the user in **French**.
- **No effort or timeline estimates**, anywhere.
- Package installation goes through **pnpm**, never `bun install`; Bun-first otherwise.
- Project facts are documented through `/concept document`, not written freehand into `.project/`.
- Commit freely on a branch; push at validated states without being asked (Gitea SSH needs the sandbox disabled).
- Never bare `git stash` — the stack is shared across worktrees and sessions.
- `pre-push` blocks while any `_debt.md` exists.
+1
View File
@@ -33,6 +33,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@refinedev/core": "^5.0.12",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+102
View File
@@ -38,6 +38,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@refinedev/core':
specifier: ^5.0.12
version: 5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
bun-plugin-tailwind:
specifier: ^0.1.2
version: 0.1.2(bun@1.3.14)
@@ -1134,6 +1137,34 @@ packages:
'@rdfjs/types@1.1.2':
resolution: {integrity: sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==}
'@refinedev/core@5.0.12':
resolution: {integrity: sha512-9y5Bi9Lb7XyJmM55b8rCeBTDRCBU41p47OymJldasLfrtpUm2EwI+27DjjNpHTOugymiZsIbLlPtHCPQIXBHcg==}
engines: {node: '>=20'}
peerDependencies:
'@tanstack/react-query': ^5.81.5
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@refinedev/devtools-internal@2.0.2':
resolution: {integrity: sha512-1YYizOW1lyy9ep8eQ7TcUPBooKXIlvzTLjLdDArsQwx7P33cn2uXdqM7So5VhlNFXhjOjAKFgrH5c1jleRF8Jg==}
engines: {node: '>=20'}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@refinedev/devtools-shared@2.0.2':
resolution: {integrity: sha512-3cTjR1mEWn0tHFZBfPD5aVpBGLUhpAkfjqYCwKrijIicr1Utp/j0BqiPRnNqTf+W71HTng3znBpUhnR83u+tuA==}
engines: {node: '>=20'}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@shexjs/parser@1.0.0-alpha.28':
resolution: {integrity: sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw==}
engines: {node: '>=0.10.0'}
@@ -1361,6 +1392,14 @@ packages:
'@swc/types@0.1.27':
resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
'@tanstack/query-core@5.104.1':
resolution: {integrity: sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==}
'@tanstack/react-query@5.104.1':
resolution: {integrity: sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==}
peerDependencies:
react: ^18 || ^19
'@teppeis/multimaps@3.0.0':
resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==}
engines: {node: '>=14'}
@@ -2377,6 +2416,9 @@ packages:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -2608,6 +2650,9 @@ packages:
resolution: {integrity: sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g==}
engines: {node: '>=0.10.0'}
papaparse@5.7.0:
resolution: {integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==}
param-case@3.0.4:
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
@@ -2677,6 +2722,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
postcss-modules-extract-imports@3.1.0:
resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==}
engines: {node: ^10 || ^12 || >= 14}
@@ -3242,6 +3291,9 @@ packages:
validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
warn-once@0.1.1:
resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==}
watchpack@2.5.2:
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'}
@@ -4208,6 +4260,41 @@ snapshots:
dependencies:
'@types/node': 26.1.1
'@refinedev/core@5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@refinedev/devtools-internal': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tanstack/react-query': 5.104.1(react@19.2.7)
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
lodash: 4.18.1
lodash-es: 4.18.1
papaparse: 5.7.0
pluralize: 8.0.0
qs: 6.15.3
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
tslib: 2.8.1
warn-once: 0.1.1
'@refinedev/devtools-internal@2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@refinedev/devtools-shared': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tanstack/react-query': 5.104.1(react@19.2.7)
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
error-stack-parser: 2.1.4
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@refinedev/devtools-shared@2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@tanstack/react-query': 5.104.1(react@19.2.7)
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
error-stack-parser: 2.1.4
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@shexjs/parser@1.0.0-alpha.28':
dependencies:
'@shexjs/util': 1.0.0-alpha.28
@@ -4483,6 +4570,13 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
'@tanstack/query-core@5.104.1': {}
'@tanstack/react-query@5.104.1(react@19.2.7)':
dependencies:
'@tanstack/query-core': 5.104.1
react: 19.2.7
'@teppeis/multimaps@3.0.0': {}
'@testing-library/dom@10.4.1':
@@ -5532,6 +5626,8 @@ snapshots:
dependencies:
p-locate: 4.1.0
lodash-es@4.18.1: {}
lodash.merge@4.6.2: {}
lodash.mergewith@4.6.2: {}
@@ -5738,6 +5834,8 @@ snapshots:
dependencies:
repeat-string: 1.6.1
papaparse@5.7.0: {}
param-case@3.0.4:
dependencies:
dot-case: 3.0.4
@@ -5800,6 +5898,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
pluralize@8.0.0: {}
postcss-modules-extract-imports@3.1.0(postcss@8.5.19):
dependencies:
postcss: 8.5.19
@@ -6326,6 +6426,8 @@ snapshots:
spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1
warn-once@0.1.1: {}
watchpack@2.5.2:
dependencies:
graceful-fs: 4.2.11
+113
View File
@@ -0,0 +1,113 @@
/**
* RefineProvider — mounts Refine over the backend this page runs.
*
* 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.
*
* 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.
*
* 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
* navigating the same history. Refine works without it — only its navigation
* helpers (which this app does not call) go dormant.
*/
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import { Refine } from '@refinedev/core';
import { getBackend, initialBackendName, resourceNames, type RefineBackend } from './providers';
/** `undefined` when no backend is registered — see providers/index.ts. */
const BackendContext = createContext<RefineBackend | undefined | null>(null);
/**
* 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 backend = useContext(BackendContext);
if (backend === null) {
throw new Error('useBackend must be used within a RefineProvider');
}
return backend;
}
export function RefineProvider({ children }: { children: ReactNode }) {
// 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.
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]);
// 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 context is still published, so callers get `undefined` rather
// than a thrown "outside a provider".
if (!backend || !providers) {
return <BackendContext.Provider value={undefined}>{children}</BackendContext.Provider>;
}
return (
<BackendContext.Provider value={backend}>
<Refine
dataProvider={providers.dataProvider}
liveProvider={providers.liveProvider}
authProvider={providers.authProvider}
resources={resourceNames(backend).map((resource) => ({ name: resource }))}
options={{
// `auto` is what makes the live provider actually subscribe behind every
// query — without it the 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
// 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
// navigation) has nothing to hook and would only warn.
warnWhenUnsavedChanges: false,
}}
>
{children}
</Refine>
</BackendContext.Provider>
);
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 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, 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
* it is not the target: what is coming is a Refine provider over NextGraph *plus
* the polyfill*, and hardening this one meanwhile buys nothing.
*
* NOT because it was incapable — an earlier version of this comment said so, and
* was wrong. It blamed the absence of an inbox for the participant count, having
* misread the model: every Participation is readable by everyone, only the link
* to a person is protected, so counting them is ordinary CRUD. See `types.ts`.
* What the measurements did establish is narrower and still worth knowing: the
* 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 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
* is kept and only the entry is gone.
*
* 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, 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';
export { resourceNames } from './types';
const BACKENDS: Record<string, RefineBackend> = {};
/** Every registered backend — what the variant links offer. */
export function listBackends(): RefineBackend[] {
return Object.values(BACKENDS);
}
/**
* Look a backend up by name. An unknown name throws rather than falling back:
* running against a different store than the one asked for is worse than
* failing, because nothing downstream would look wrong.
*/
export function getBackend(name: string): RefineBackend {
const backend = BACKENDS[name];
if (!backend) {
const known = Object.keys(BACKENDS).join(', ') || '(none registered)';
throw new Error(`Unknown Refine backend "${name}" — known: ${known}`);
}
return backend;
}
/**
* 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 names = Object.keys(BACKENDS);
return resolveVariant(window.location.pathname, names).backend ?? names[0];
}
+140
View File
@@ -0,0 +1,140 @@
/**
* The contract a Refine BACKEND must satisfy to serve Festipod.
*
* Refine's own `DataProvider` / `AuthProvider` / `LiveProvider` already abstract
* the transport, so they are not what this file adds. What they do NOT abstract
* is everything around them, and each item below exists because the Events
* vertical ran into it against `@ng-org/refine-providers` — none of it is
* anticipated:
*
* - **Configuration.** `@ng-org` takes SHEX shape types; `@activitypods` takes
* `resources: { events: { shapeTreeUri } }` plus a `jsonContext`. There is no
* common shape, so building the providers belongs to the backend.
* - **Record shape.** A record's fields are the backend's vocabulary. `@ng-org`
* yields `fp:` predicates with `@id` / `@graph` / `@type`; ActivityPods yields
* ActivityStreams JSON-LD. So domain mapping belongs to the backend too.
* - **Two identifiers.** Refine addresses a record by one id, and the app's
* entities reference each other by another. Under `@ng-org` those differ: the
* address is the document NURI (`@graph`), while a participation points at the
* subject IRI (`@id`). Collapsing them silently breaks the references.
* - **Update semantics.** `@ng-org`'s `update` REPLACES the record, so a partial
* write drops every field it omits. A backend that patches would not need the
* read-merge the app does today, so the app has to be told which it is.
*
* A backend that cannot answer one of these cannot be added by guessing: the
* answer is a fact about that backend, to be read from its contract or measured.
*/
import type { AuthProvider, BaseKey, DataProvider, LiveProvider } from '@refinedev/core';
import type {
FpEventData,
FpMeetingPointData,
FpParticipationData,
FpUserData,
} from '../../data/types';
/** How one entity's records translate to and from the app's domain type. */
export interface RecordBinding<T extends { id: string }> {
/**
* The key Refine's `update` / `getOne` address this record by. NOT necessarily
* the domain id — see "Two identifiers" above.
*/
recordKey(record: unknown): BaseKey;
/** The id the APP and its other entities use to reference this record. */
domainId(record: unknown): string;
/** Read a backend record into the app's domain type. */
toDomain(record: unknown): T;
/**
* The values to send on create, and on update when the backend REPLACES: the
* complete field set, because anything omitted is dropped.
*/
toValues(data: Omit<T, 'id'>): Record<string, unknown>;
/**
* The values to send when the backend PATCHES — only the fields that changed,
* so a concurrent edit elsewhere in the record survives. Never called when
* `updateReplaces` is true.
*/
toPatch(updates: Partial<T>): Record<string, unknown>;
/** True when `update` replaces the record rather than patching it. */
readonly updateReplaces: boolean;
/**
* Which Refine resource these records live under. The NAME is the backend's,
* not the app's: `@ng-org` keys `shapeTypes` by it, `@activitypods` keys
* `resources` by it, and nothing says the two must agree on a spelling.
*/
readonly resource: string;
/**
* WHO MUST BE ABLE TO READ these records. Festipod's confidentiality model is
* per-entity (concept `functional-domain`), and each backend enforces it its own
* way — NextGraph by placing a document in a store, ActivityPods by shape trees
* and ACLs. Refine itself has NO notion of it, so the intent is declared here
* and the backend is what honours it.
*
* Naming it does not make it enforced: the NextGraph data provider currently
* reads across every graph it can see, so this records the INTENT and marks
* where a backend has to close the gap.
*/
readonly visibility: 'public' | 'protected' | 'private';
}
/**
* 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.
*
* `Friendship` is deliberately absent: it has no shape and no persistence
* anywhere in this project (concept `data-layer`, `knowledge_entities`).
*/
export interface RecordBindings {
event: RecordBinding<FpEventData>;
profile: RecordBinding<FpUserData>;
participation: RecordBinding<FpParticipationData>;
meetingPoint: RecordBinding<FpMeetingPointData>;
}
/** Everything Festipod needs from one Refine backend. */
export interface RefineBackend {
/** Short, meaning-bearing name — used in logs and, later, in a selector. */
readonly name: string;
/**
* Build this backend's Refine providers, with its own configuration. Called
* ONCE: a new provider identity makes Refine re-run every query behind it.
*/
build(): {
dataProvider: DataProvider;
authProvider?: AuthProvider;
liveProvider?: LiveProvider;
};
/** One binding per entity the app persists. */
readonly bindings: RecordBindings;
}
/**
* HOW THE PARTICIPANT COUNT IS OBTAINED — a domain rule, not a backend capability.
*
* `participantCount` is the number of participants, and every Participation is
* READABLE BY EVERYONE: what is protected is the link to a person, not the
* record. Only a connection can read the profile a participation points at and
* recognise who it is. So the count is obtained by COUNTING the participations of
* an event — no filtering by visibility, because none is needed (concept
* `data-layer`, brief_2026-07-20_attendance-set-model).
*
* 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 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
* writing it back derives each write from a read of the previous one, which
* races itself through any data provider — and the project's brief declares that
* mutated counter superseded.
*/
/** 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))];
}
+72
View File
@@ -0,0 +1,72 @@
import { expect, test } from 'bun:test';
import { resolveVariant } from './variant';
// The names a registry could hold. `nextgraph` is the one variant planned beside
// the default; `events` is NOT a backend and appears here only to prove an
// ordinary route is never mistaken for a prefix.
const KNOWN = ['activitypods', 'nextgraph'];
// THE CASE THAT IS TRUE TODAY, and the one most likely to be left untested: the
// registry is empty, so every URL resolves to the default variant.
test('no registered backend — every path is the default variant', () => {
expect(resolveVariant('/', [])).toEqual({ backend: undefined, basePath: '' });
expect(resolveVariant('/events/42', [])).toEqual({ backend: undefined, basePath: '' });
expect(resolveVariant('/nextgraph', [])).toEqual({ backend: undefined, basePath: '' });
});
// The normal case once a registry exists: the root serves the DEFAULT variant, and
// `undefined` is what says "the default", never "none".
test('the root is the default variant', () => {
expect(resolveVariant('/', KNOWN)).toEqual({ backend: undefined, basePath: '' });
});
test('an ordinary route is the default variant, not a prefix', () => {
expect(resolveVariant('/events', KNOWN)).toEqual({ backend: undefined, basePath: '' });
expect(resolveVariant('/events/42/participants', KNOWN)).toEqual({
backend: undefined,
basePath: '',
});
expect(resolveVariant('/profile/edit', KNOWN)).toEqual({ backend: undefined, basePath: '' });
});
test('a registered name as first segment selects that variant', () => {
expect(resolveVariant('/nextgraph', KNOWN)).toEqual({
backend: 'nextgraph',
basePath: '/nextgraph',
});
});
// The prefix carries through to every route under it — this is the shape the
// router will have to strip.
test('the prefix holds over a deep path', () => {
expect(resolveVariant('/nextgraph/events/42/participants', KNOWN)).toEqual({
backend: 'nextgraph',
basePath: '/nextgraph',
});
});
// A name that is registered but matched only as a LATER segment is an event id or
// a user id that happens to spell a backend's name, never a variant switch.
test('a registered name deeper in the path is not a prefix', () => {
expect(resolveVariant('/events/nextgraph', KNOWN)).toEqual({
backend: undefined,
basePath: '',
});
});
test('an unknown first segment falls back to the default variant', () => {
expect(resolveVariant('/whatever', KNOWN)).toEqual({ backend: undefined, basePath: '' });
});
// Shapes a browser or a hand-typed link really produces.
test('empty, bare and repeated slashes resolve without throwing', () => {
expect(resolveVariant('', KNOWN)).toEqual({ backend: undefined, basePath: '' });
expect(resolveVariant('//nextgraph', KNOWN)).toEqual({
backend: 'nextgraph',
basePath: '/nextgraph',
});
expect(resolveVariant('/nextgraph/', KNOWN)).toEqual({
backend: 'nextgraph',
basePath: '/nextgraph',
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* Which provider set the current URL selects.
*
* Festipod ships as ONE build serving SEVERAL variants, one per provider set, at
* the same origin. The default variant lives at the root; another is reached
* through a path prefix naming it. Switching is a plain link, so it costs a full
* page load — which is the point: the React tree, Refine's cache and the previous
* provider's session all go away, and nothing has to be migrated or torn down.
* Losing access to the data of the variant being left is accepted.
*
* WHY NOT A BUILD-TIME CONSTANT. An earlier version of this read a build-injected
* `__FESTIPOD_BACKEND__`, and its comment claimed `build.ts` defined it. Nothing
* ever did — and it could not have worked here anyway: `bun run start` serves from
* `src/`, so `build.ts`'s `define` block never reaches what the deployment runs
* (concept `tech-stack`, `knowledge_build-pipeline`). A URL needs no injection, is
* the same in dev and in production, and is linkable — which is what the switch is.
*
* NAMES ARE THE PREFIXES, so a backend must never be named after a top-level route
* (`events`, `profile`, `home`, `settings`, `users`): registering one would turn
* that route into a variant switch. The registry owns those names, so this is a
* naming constraint rather than a check — see `providers/index.ts`.
*/
/** The provider set a URL names, and the prefix that carries it. */
export interface Variant {
/** The backend named by the URL, or `undefined` to mean "the default one". */
backend: string | undefined;
/**
* What every app path is prefixed with under this variant — `''` for the
* default. The router has to strip it on parse and restore it on navigate; it
* does not yet, because no URL carries a prefix while one backend is registered.
*/
basePath: string;
}
/**
* Read the variant out of a pathname. Pure, so the whole rule is one test away.
*
* `known` is the registered backend names. Matching only against those is what
* keeps an ordinary route (`/events`) from being read as a variant prefix.
*/
export function resolveVariant(pathname: string, known: readonly string[]): Variant {
const firstSegment = pathname.replace(/^\/+/, '').split('/')[0] ?? '';
if (firstSegment && known.includes(firstSegment)) {
return { backend: firstSegment, basePath: `/${firstSegment}` };
}
return { backend: undefined, basePath: '' };
}