diff --git a/.project/concepts/data-layer/_debt.md b/.project/concepts/data-layer/_debt.md new file mode 100644 index 0000000..fe2eb0d --- /dev/null +++ b/.project/concepts/data-layer/_debt.md @@ -0,0 +1,15 @@ +# Doc-debt — data-layer + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Block: the Refine seam's comments describe a switching design this base superseded + +- **why**: The Refine seam was carried over verbatim from `festipod-refine` and lands here as unreferenced scaffolding, on a base that has moved past two of its stated premises. `RefineProvider.tsx` presents **runtime hot-switching** as what shapes the file (selection held in React state, `key={backend.name}` remounting `` so the previous backend's cache is discarded); the product decision since is **one app variant per provider set, selected by URL, with a full page reload** — there is no in-session switch left for the file to be shaped by. And both `RefineProvider.tsx` and `providers/index.ts` say the per-backend sign-in barrier is "the next piece" because `AuthGate` gates on the NextGraph session; on this base `AuthGate` renders no access screen of its own and delegates the whole access experience to the SDK. The prose was left untouched on transfer on purpose — it is settled with the wiring, not during the carry-over. +- **files**: `src/shared/refine/RefineProvider.tsx`, `src/shared/refine/providers/index.ts`, `src/shared/refine/providers/types.ts` +- **verify (what must be updated)**: + - `RefineProvider.tsx` header — "SWITCHING AT RUNTIME is what shapes this file", the `key`/remount rationale, and whether `switchTo` / `useBackendSelection` survive at all once the selection is a URL plus a reload. + - `RefineProvider.tsx` — the "What is NOT solved here is the sign-in: `AuthGate` still gates on the NextGraph session … That is the next piece" paragraph, against this base's `src/app/AuthGate.tsx`. + - `providers/index.ts` — same sign-in claim, plus the build-injected `__FESTIPOD_BACKEND__` global it reads: `build.ts` on this base defines no such thing, so `initialBackendName()` can only ever fall back to the first registered backend. + - Branch-relative phrasing that points nowhere from here: `types.ts` "the inbox on the sibling branch" (that inbox is local — `src/shared/data/registration.ts`) and `providers/index.ts` "the worked implementation is in this branch's history" (it is on `festipod-refine`, not in this branch's ancestry). + - Leaves: `data-layer/_overview.md` gained the seam's triggers but still describes no seam, and `brief_2026-10-03_structure-for-two-rights-models` states the contract is in code — both to revisit once the seam is actually wired. diff --git a/.project/concepts/data-layer/_overview.md b/.project/concepts/data-layer/_overview.md index fbdc361..4e6d90e 100644 --- a/.project/concepts/data-layer/_overview.md +++ b/.project/concepts/data-layer/_overview.md @@ -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 diff --git a/.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md b/.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md new file mode 100644 index 0000000..4da4e7d --- /dev/null +++ b/.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md @@ -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 // keyed by Refine resource name +type JsonContext = string | string[] | Record +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`, `getAppStatus(): Promise`. 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": , ...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. diff --git a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md new file mode 100644 index 0000000..c4683f6 --- /dev/null +++ b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md @@ -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. diff --git a/.project/concepts/data-layer/brief_2026-10-03_structure-for-two-rights-models.md b/.project/concepts/data-layer/brief_2026-10-03_structure-for-two-rights-models.md new file mode 100644 index 0000000..9eb4ac6 --- /dev/null +++ b/.project/concepts/data-layer/brief_2026-10-03_structure-for-two-rights-models.md @@ -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. diff --git a/.project/contracts.yaml b/.project/contracts.yaml index b5c9f99..8ca1af9 100644 --- a/.project/contracts.yaml +++ b/.project/contracts.yaml @@ -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,11 @@ # access to a local checkout lives in `.project/contracts.local.yaml`, which is never # committed. +publish: + # Key qualified by interface: a second declaration is coming for the NextGraph+polyfill + # backend, and publish keys must stay unique. + activitypods-providers--festipod: concepts/data-layer/activitypods-providers/usage_festipod.md + consume: - contract: polyfill-surface into: concepts/data-layer/polyfill-surface/ @@ -43,3 +51,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 diff --git a/package.json b/package.json index 74efa09..8039f60 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09bc1f4..20cc995 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/shared/refine/RefineProvider.tsx b/src/shared/refine/RefineProvider.tsx new file mode 100644 index 0000000..0d6d77f --- /dev/null +++ b/src/shared/refine/RefineProvider.tsx @@ -0,0 +1,128 @@ +/** + * RefineProvider — mounts Refine over the SELECTED backend, and owns the selection. + * + * This is the app's data entry point for the Events vertical: screens keep calling + * `useFestipodData()`, and `FestipodDataContext` reads and writes events through + * Refine's hooks, which land on whichever backend is current. + * + * SWITCHING AT RUNTIME is what shapes this file. The selection is React state, and + * `` carries the backend name as its `key`, so switching REMOUNTS it: + * Refine builds its own query client inside, and remounting is what discards the + * cache of the backend being left. Without that key the new backend would serve + * its first reads out of the old one's cached rows. + * + * Leaving a backend loses access to its data — accepted, and nothing is migrated. + * What is NOT solved here is the sign-in: `AuthGate` still gates on the NextGraph + * session, so a second backend needs its own barrier before it can actually be + * reached. That is the next piece, not this one. + * + * 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, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; +import { Refine } from '@refinedev/core'; +import { getBackend, initialBackendName, resourceNames, type RefineBackend } from './providers'; + +interface BackendSelection { + /** `undefined` when no backend is registered — see providers/index.ts. */ + backend: RefineBackend | undefined; + /** Switch backends. The data of the one being left becomes unreachable. */ + switchTo(name: string): void; +} + +const BackendContext = createContext(null); + +/** + * The current backend. Read it rather than importing one: a module-level import + * of a specific backend is what makes it unswitchable. + */ +/** + * The current backend, or `undefined` when none is registered. Callers that READ + * or WRITE data must handle the undefined case — there is nothing to read a + * connected wallet through, and pretending otherwise returns empty lists that + * look like an empty wallet. + */ +export function useBackend(): RefineBackend | undefined { + const selection = useContext(BackendContext); + if (!selection) { + throw new Error('useBackend must be used within a RefineProvider'); + } + return selection.backend; +} + +/** The selection itself, for whatever eventually offers the choice. */ +export function useBackendSelection(): BackendSelection { + const selection = useContext(BackendContext); + if (!selection) { + throw new Error('useBackendSelection must be used within a RefineProvider'); + } + return selection; +} + +export function RefineProvider({ children }: { children: ReactNode }) { + const [name, setName] = useState(initialBackendName); + const backend = useMemo(() => (name ? getBackend(name) : undefined), [name]); + + // Built once PER BACKEND, not per render: a fresh provider identity makes Refine + // re-run every query behind it. + const providers = useMemo(() => { + if (!backend) { + console.log('[refine] no backend registered — the app runs in demo mode'); + return undefined; + } + console.log(`[refine] backend: ${backend.name}`); + return backend.build(); + }, [backend]); + + const switchTo = useCallback((next: string) => { + // Resolved here so an unknown name fails at the call, not on the next render. + getBackend(next); + setName(next); + }, []); + + const selection = useMemo(() => ({ backend, switchTo }), [backend, switchTo]); + + // NO BACKEND, NO . Mounting it without a data provider would give the + // hooks something that answers nothing, which reads downstream as an empty + // wallet. The selection context is still published, so a selector can appear. + if (!backend || !providers) { + return {children}; + } + + return ( + + ({ name: resource }))} + options={{ + // `auto` is what makes the live provider actually subscribe behind every + // query — without it the ORM pushes changes and no hook is listening. + 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} + + + ); +} diff --git a/src/shared/refine/providers/index.ts b/src/shared/refine/providers/index.ts new file mode 100644 index 0000000..849b7dd --- /dev/null +++ b/src/shared/refine/providers/index.ts @@ -0,0 +1,85 @@ +/** + * The backend registry — which Refine backends exist, and which one starts. + * + * IT IS EMPTY ON PURPOSE, and that is the current state of the work rather than + * an oversight. Festipod must end up supporting SEVERAL Refine backends, and the + * seam for that is what this directory is: the contract in `types.ts`, the + * selection below, `RefineProvider`'s runtime switch. What is missing is backends. + * + * 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 in this branch's history (`providers/nextgraph.ts`, + * up to the commit that withdrew it) and is the reference for the next one's shape. + * + * 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, whether its `update` replaces or patches, and + * whether it can derive the participant count. + * + * 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 type { RefineBackend } from './types'; + +export type { RecordBinding, RecordBindings, RefineBackend } from './types'; +export { resourceNames } from './types'; + +const BACKENDS: Record = {}; + +// Build-injected, same plumbing as the other build-time globals (build.ts +// `define`s it). Absent → the first registered backend, if there is one. +declare global { + // eslint-disable-next-line no-var + var __FESTIPOD_BACKEND__: string | undefined; +} + +/** Every registered backend — what a selector would offer. */ +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; +} + +/** + * Which backend is selected when the app starts, or `undefined` when none is + * registered. A requested name that does not exist still throws — asking for a + * specific backend and silently getting another is the failure worth avoiding. + */ +export function initialBackendName(): string | undefined { + const requested = globalThis.__FESTIPOD_BACKEND__; + if (requested) { + getBackend(requested); + return requested; + } + return Object.keys(BACKENDS)[0]; +} diff --git a/src/shared/refine/providers/types.ts b/src/shared/refine/providers/types.ts new file mode 100644 index 0000000..eae4ab9 --- /dev/null +++ b/src/shared/refine/providers/types.ts @@ -0,0 +1,142 @@ +/** + * 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 { + /** + * 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): Record; + /** + * 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): Record; + /** 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. + * + * `meetingPoint` is OPTIONAL only because its SHEX shape does not exist on this + * branch yet — adding it means regenerating the ORM bindings, which is its own + * step with its own failure mode (the generator drops the `Fp` prefix, since the + * emitted name follows the shape IRI). Once that lands it becomes required like + * the others. `Friendship` is deliberately absent: it has no shape and no + * persistence anywhere in this project. + */ +export interface RecordBindings { + event: RecordBinding; + profile: RecordBinding; + participation: RecordBinding; + meetingPoint?: RecordBinding; +} + +/** 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 on + * the sibling branch is how a sign-up is DELIVERED to the event's owner so the + * owner can maintain the aggregate; it is one implementation of the count, not a + * precondition for having one. + * + * 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))]; +}