Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df1db790ad | |||
| e282384e00 |
@@ -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`
|
||||
|
||||
+88
@@ -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.
|
||||
+34
-5
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+102
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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: '' };
|
||||
}
|
||||
Reference in New Issue
Block a user