Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 412e5d63e7 | |||
| d48297fe94 | |||
| a5f6e01562 | |||
| 33b4588da4 | |||
| 0c4f6b6650 | |||
| b25bafbb90 | |||
| 3de86c8be2 |
@@ -43,3 +43,6 @@ playwright/.auth/
|
||||
storybook-static
|
||||
dist-staging/
|
||||
*.ngw
|
||||
|
||||
# per-developer contract source access (never committed)
|
||||
.project/contracts.local.yaml
|
||||
|
||||
+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,36 @@
|
||||
# Inter-repo contracts. Festipod is a CONSUMER only: it publishes no interface of its own,
|
||||
# and what it lists under `publish:` is its own consumer-side DECLARATIONS — the channel a
|
||||
# provider pulls to learn what this app calls, what it needs, and what blocks it.
|
||||
#
|
||||
# Festipod's persistence sits behind interchangeable Refine backends (the backend contract
|
||||
# itself is in-code: `src/shared/refine/providers/types.ts`). Each candidate backend is a
|
||||
# separate interface, with its own folder inside `data-layer` — the concept that owns how
|
||||
# Festipod uses an external data surface.
|
||||
#
|
||||
# `@activitypods/refine-providers` publishes NO engagement: no contract document, no stated
|
||||
# change policy, not even a git tag. So its 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. Reading the
|
||||
# package's sources again at work time is not how a question gets answered — the leaf is
|
||||
# fixed or the gap goes into our declaration's `## Needs`.
|
||||
|
||||
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: activitypods-providers
|
||||
into: concepts/data-layer/activitypods-providers/
|
||||
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
|
||||
+7
-8
@@ -22,7 +22,6 @@
|
||||
"dependencies": {
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.13",
|
||||
"@ng-org/orm": "0.1.2-alpha.21",
|
||||
"@ng-org/refine-providers": "0.1.2-alpha.1",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.9",
|
||||
"@ng-org/web": "0.1.2-alpha.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@@ -42,6 +41,11 @@
|
||||
"@cucumber/cucumber": "^12.5.0",
|
||||
"@cucumber/gherkin": "^29.0.0",
|
||||
"@cucumber/messages": "^26.0.1",
|
||||
"@storybook/addon-a11y": "^10.3.5",
|
||||
"@storybook/addon-docs": "^10.3.5",
|
||||
"@storybook/addon-onboarding": "^10.3.5",
|
||||
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
|
||||
"@storybook/react-webpack5": "^10.3.5",
|
||||
"@types/bun": "latest",
|
||||
"@types/chai": "^5.2.3",
|
||||
"@types/react": "^19",
|
||||
@@ -49,15 +53,10 @@
|
||||
"chai": "^6.2.2",
|
||||
"happy-dom": "^16.6.0",
|
||||
"playwright": "^1.58.2",
|
||||
"storybook": "^10.3.5",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"storybook": "^10.3.5",
|
||||
"@storybook/react-webpack5": "^10.3.5",
|
||||
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
|
||||
"@storybook/addon-a11y": "^10.3.5",
|
||||
"@storybook/addon-docs": "^10.3.5",
|
||||
"@storybook/addon-onboarding": "^10.3.5"
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Generated
-20
@@ -14,9 +14,6 @@ importers:
|
||||
'@ng-org/orm':
|
||||
specifier: 0.1.2-alpha.21
|
||||
version: 0.1.2-alpha.21(react@19.3.0)
|
||||
'@ng-org/refine-providers':
|
||||
specifier: 0.1.2-alpha.1
|
||||
version: 0.1.2-alpha.1(@ng-org/alien-deepsignals@0.1.2-alpha.13(react@19.3.0))(@ng-org/orm@0.1.2-alpha.21(react@19.3.0))(@ng-org/shex-orm@0.1.2-alpha.9(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.14)(@refinedev/core@5.0.12(@tanstack/react-query@5.103.2(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))
|
||||
'@ng-org/shex-orm':
|
||||
specifier: 0.1.2-alpha.9
|
||||
version: 0.1.2-alpha.9(typescript@6.0.3)
|
||||
@@ -520,15 +517,6 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@ng-org/refine-providers@0.1.2-alpha.1':
|
||||
resolution: {integrity: sha512-MSaVTyCWiqbBR7jn4ESgCk8zYrxWW1EoZCquYAxIud1XBMtkZvl0X/rjIdCPG2GxAVHURC/ut3moavd5Eb+gsA==}
|
||||
peerDependencies:
|
||||
'@ng-org/alien-deepsignals': 0.1.2-alpha.13
|
||||
'@ng-org/orm': 0.1.2-alpha.21
|
||||
'@ng-org/shex-orm': 0.1.2-alpha.9
|
||||
'@ng-org/web': 0.1.2-alpha.14
|
||||
'@refinedev/core': ^5.0.12
|
||||
|
||||
'@ng-org/shex-orm@0.1.2-alpha.9':
|
||||
resolution: {integrity: sha512-M69oIHrcjl24xKXIaiZjZ64rJvispAPIqn73asLPR73GWpEMvbiot/EN0vDkcfrbRy3cMEql36A9e1Nwv2csOA==}
|
||||
hasBin: true
|
||||
@@ -3615,14 +3603,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react: 19.3.0
|
||||
|
||||
'@ng-org/refine-providers@0.1.2-alpha.1(@ng-org/alien-deepsignals@0.1.2-alpha.13(react@19.3.0))(@ng-org/orm@0.1.2-alpha.21(react@19.3.0))(@ng-org/shex-orm@0.1.2-alpha.9(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.14)(@refinedev/core@5.0.12(@tanstack/react-query@5.103.2(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))':
|
||||
dependencies:
|
||||
'@ng-org/alien-deepsignals': 0.1.2-alpha.13(react@19.3.0)
|
||||
'@ng-org/orm': 0.1.2-alpha.21(react@19.3.0)
|
||||
'@ng-org/shex-orm': 0.1.2-alpha.9(typescript@6.0.3)
|
||||
'@ng-org/web': 0.1.2-alpha.14
|
||||
'@refinedev/core': 5.0.12(@tanstack/react-query@5.103.2(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||
|
||||
'@ng-org/shex-orm@0.1.2-alpha.9(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@ldo/traverser-shexj': 1.0.0-alpha.28
|
||||
|
||||
@@ -18,9 +18,6 @@ import { useList, useCreate, useUpdate, useDelete } from '@refinedev/core';
|
||||
import { useNextGraph } from './NextGraphContext';
|
||||
import { useAccount, normalizeUsername } from './AccountContext';
|
||||
import { applyIsolation } from '../utils/isolation';
|
||||
import { ensureAccount, resolveReadGraphs, resolveWriteGraph } from '../utils/storeRegistry';
|
||||
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
|
||||
import { RESOURCE_EVENTS } from '../refine/shapeTypes';
|
||||
import { useBackend } from '../refine/RefineProvider';
|
||||
|
||||
// Multi-document mode (storeRegistry): one document per (account × scope),
|
||||
@@ -31,13 +28,22 @@ import { useBackend } from '../refine/RefineProvider';
|
||||
// custom vars, so a bare `process.env.FESTIPOD_MULTISTORE` throws
|
||||
// "process is not defined" in the browser harness. Guard it.
|
||||
const MULTISTORE = typeof process !== 'undefined' && process?.env?.FESTIPOD_MULTISTORE === '1';
|
||||
// `FpEventShapeType` is NOT imported here any more: events go through Refine, and
|
||||
// the resource → shape binding lives in refine/shapeTypes.ts.
|
||||
import {
|
||||
FpUserProfileShapeType,
|
||||
FpParticipationShapeType,
|
||||
} from '../shapes/orm/festipodShapes.shapeTypes';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
// NO ShapeType IMPORTED HERE ANY MORE: every entity reads through a Refine
|
||||
// backend, and the resource → shape binding is the backend's business.
|
||||
// The ORM generator emits BARE shape names (`Event`, `UserProfile`, …), taken from
|
||||
// each shape's IRI local name. They are aliased back to the `Fp*` spelling HERE,
|
||||
// at the import, so nothing downstream depends on the generator's naming: `Event`
|
||||
// is a DOM global, and the bare names collide with the app's own vocabulary
|
||||
// (`FpEventData` & co.). The prefix cannot be restored at the generator — the
|
||||
// emitted name follows the shape IRI, and those IRIs are the persisted RDF
|
||||
// classes, not ours to rename. Never edit the generated files; `build:orm`
|
||||
// overwrites them.
|
||||
import type {
|
||||
Event as FpEvent,
|
||||
MeetingPoint as FpMeetingPoint,
|
||||
Participation as FpParticipation,
|
||||
UserProfile as FpUserProfile,
|
||||
} from '../shapes/orm/festipodShapes.typings';
|
||||
import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
|
||||
import { autoSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
|
||||
|
||||
@@ -97,13 +103,6 @@ function nextId(prefix: string): string {
|
||||
return `${prefix}-${++idCounter}`;
|
||||
}
|
||||
|
||||
function findNg<T extends { "@id": string }>(set: Set<T>, predicate: (item: T) => boolean): T | undefined {
|
||||
for (const item of set) {
|
||||
if (predicate(item)) return item;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// EVENTS are read and written through the selected Refine BACKEND, so the record
|
||||
// shape, the vocabulary and the two identifiers are its business, not this file's
|
||||
// — see refine/providers/types.ts. The binding is read from `useBackend()` inside
|
||||
@@ -112,26 +111,43 @@ function findNg<T extends { "@id": string }>(set: Set<T>, predicate: (item: T) =
|
||||
// backend unswitchable. Everything else in this file still speaks `fp:` directly,
|
||||
// because profiles and participations have not moved to Refine yet.
|
||||
|
||||
const mapUser = (u: FpUserProfile): FpUserData => ({
|
||||
id: u["@id"],
|
||||
name: u.name,
|
||||
initials: u.initials,
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
isPublic: u.isPublic,
|
||||
});
|
||||
|
||||
const mapParticipation = (p: FpParticipation): FpParticipationData => ({
|
||||
id: p["@id"],
|
||||
eventId: p.event,
|
||||
userId: p.user,
|
||||
isConfirmed: p.isConfirmed,
|
||||
});
|
||||
// NO mapUser / mapParticipation HERE ANY MORE. Reading a backend record into a
|
||||
// domain type is the backend's binding (refine/providers/types.ts): the record
|
||||
// shape is its vocabulary, not this file's.
|
||||
|
||||
// ============================================================================
|
||||
// Shared queries builder — same logic for both local and NG modes
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* The participant count, DERIVED from the participations rather than read off the
|
||||
* event.
|
||||
*
|
||||
* Every Participation is readable by everyone — only the link to a person is
|
||||
* protected, so counting them needs no privileged view and no compensation
|
||||
* (concept `data-layer`, brief_2026-07-20_attendance-set-model).
|
||||
*
|
||||
* WHY NOT THE STORED FIELD. It used to be incremented by whoever joined: read the
|
||||
* counter out of the list, add one, write it back. Every write then derived from a
|
||||
* read of the previous one, which races itself the moment the read is not
|
||||
* instantaneous — measured here as a count stuck on whatever the seed had written,
|
||||
* with no error anywhere. The project's own brief declares that mutated counter
|
||||
* superseded. Derived, it cannot drift: it is a function of the rows.
|
||||
*
|
||||
* `fp:participantCount` stays in the shape and in the fixtures, kept in step, but
|
||||
* nothing reads it any more.
|
||||
*/
|
||||
function withDerivedCounts(
|
||||
events: FpEventData[],
|
||||
participations: FpParticipationData[],
|
||||
): FpEventData[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const p of participations) {
|
||||
counts.set(p.eventId, (counts.get(p.eventId) ?? 0) + 1);
|
||||
}
|
||||
return events.map(e => ({ ...e, participantCount: counts.get(e.id) ?? 0 }));
|
||||
}
|
||||
|
||||
function buildQueries(
|
||||
events: FpEventData[],
|
||||
users: FpUserData[],
|
||||
@@ -182,9 +198,11 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>(empty ? '' : 'event-1');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
|
||||
const events = empty ? [] : seedEvents;
|
||||
const users = empty ? [] : seedUsers;
|
||||
const participations = empty ? [] : seedParticipations;
|
||||
// Derived here too, so demo mode and connected mode cannot disagree about what
|
||||
// a count means.
|
||||
const events = empty ? [] : withDerivedCounts(seedEvents, participations);
|
||||
const meetingPoints = empty ? [] : seedMeetingPoints;
|
||||
const friendships = empty ? [] : seedFriendships;
|
||||
|
||||
@@ -252,7 +270,8 @@ function useNgData(): FestipodDataContextValue {
|
||||
const { username } = useAccount();
|
||||
// The selected backend's binding for events — read here, so a switch re-renders
|
||||
// with the new one instead of keeping whichever was current at import time.
|
||||
const bindings = useBackend().bindings;
|
||||
// Non-null: `FestipodDataProvider` only mounts this when a backend exists.
|
||||
const bindings = useBackend()!.bindings;
|
||||
const eventBinding = bindings.event;
|
||||
const profileBinding = bindings.profile;
|
||||
const participationBinding = bindings.participation;
|
||||
@@ -260,40 +279,17 @@ function useNgData(): FestipodDataContextValue {
|
||||
// repo in the verifier (enables reads + writes), per the data-layer rule.
|
||||
const privateNuri = session ? `did:ng:${session.private_store_id}` : undefined;
|
||||
|
||||
// Multi-document state (storeRegistry): read fan-out (all accounts' docs per
|
||||
// scope) and the current account's write docs. Populated by the effect below.
|
||||
const [readGraphs, setReadGraphs] = useState<{ protected: string[] }>({ protected: [] });
|
||||
const [writeGraphs, setWriteGraphs] = useState<{ protected?: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!MULTISTORE || !privateNuri || !username) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await ensureAccount(username); // create this account's index docs on first sight
|
||||
// Protected = grouped in each account's protected index document.
|
||||
// NO public fan-out any more: events are a Refine resource, and the data
|
||||
// provider addresses them by shape across every graph it can see — it
|
||||
// never takes a graph list, so the public index has no reader left.
|
||||
const [prot, wProt] = await Promise.all([
|
||||
resolveReadGraphs('protected'),
|
||||
resolveWriteGraph(username, 'protected'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setReadGraphs({ protected: prot });
|
||||
setWriteGraphs({ protected: wProt });
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] storeRegistry init failed:', err);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [privateNuri, username]);
|
||||
|
||||
// Scope for profiles + participations, which still read through the ORM.
|
||||
// Mono-store mode collapses them to the private store.
|
||||
const protectedScope: ShapeScope = MULTISTORE
|
||||
? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined)
|
||||
: privateNuri;
|
||||
// NO SCOPE WIRING LEFT HERE. The shared-wallet shim's fan-out (readGraphs /
|
||||
// writeGraphs / protectedScope, and the effect that filled them from
|
||||
// storeRegistry) fed the ORM subscriptions this provider used to open. Every
|
||||
// entity now reads through a Refine backend, which takes no graph list, so all
|
||||
// of it became unreachable and is gone. `storeRegistry` itself stays — the
|
||||
// @data multistore scenarios still drive it directly.
|
||||
//
|
||||
// What this COSTS, stated rather than buried: placement by scope no longer
|
||||
// narrows what the app reads. The backend's `visibility` declaration
|
||||
// (refine/providers/types.ts) is where that belongs, and no backend honours it
|
||||
// yet; `applyIsolation` below is the only narrowing still applied.
|
||||
|
||||
// --- Events: read through REFINE -----------------------------------------
|
||||
// `useList` goes to `@ng-org/refine-providers`, which resolves the resource
|
||||
@@ -301,7 +297,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// verifier holds. Pagination is off: the screens show the whole list and the
|
||||
// provider does not paginate at the source anyway.
|
||||
const { result: eventsResult, query: eventsQuery } = useList<FpEvent>({
|
||||
resource: RESOURCE_EVENTS,
|
||||
resource: eventBinding.resource,
|
||||
pagination: { mode: 'off' },
|
||||
});
|
||||
// The rows carry BOTH ids: Refine's `id` (the DOCUMENT NURI, which is what its
|
||||
@@ -331,9 +327,9 @@ function useNgData(): FestipodDataContextValue {
|
||||
const profileRows = profilesResult?.data ?? [];
|
||||
const participationRows = participationsResult?.data ?? [];
|
||||
|
||||
const events = eventRows.map(eventBinding.toDomain);
|
||||
const users = profileRows.map(profileBinding.toDomain);
|
||||
const participations = participationRows.map(participationBinding.toDomain);
|
||||
const events = withDerivedCounts(eventRows.map(eventBinding.toDomain), participations);
|
||||
|
||||
// --- Writes: every entity through REFINE ----------------------------------
|
||||
const { mutateAsync: createEventRow } = useCreate<FpEvent>();
|
||||
@@ -375,7 +371,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
const createEventDoc = useCallback(
|
||||
async (event: Omit<FpEventData, 'id'>): Promise<FpEvent | undefined> => {
|
||||
const created = await createEventRow({
|
||||
resource: RESOURCE_EVENTS,
|
||||
resource: eventBinding.resource,
|
||||
values: eventBinding.toValues(event),
|
||||
});
|
||||
return created?.data;
|
||||
@@ -406,7 +402,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
? eventBinding.toValues({ ...eventBinding.toDomain(row), ...updates })
|
||||
: eventBinding.toPatch(updates);
|
||||
await updateEventRow({
|
||||
resource: RESOURCE_EVENTS,
|
||||
resource: eventBinding.resource,
|
||||
id: eventBinding.recordKey(row),
|
||||
values,
|
||||
});
|
||||
@@ -498,10 +494,9 @@ function useNgData(): FestipodDataContextValue {
|
||||
'| users:', users.length, '| participations:', participations.length,
|
||||
'| selectedEvent:', selectedEvent?.title ?? '(none)');
|
||||
|
||||
// --- Mutations (NG) ---
|
||||
// Participations stay GROUPED in the account's protected index document.
|
||||
// Mono-store mode collapses everything to the private store.
|
||||
const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || privateNuri || '';
|
||||
// --- Mutations: every entity through REFINE ------------------------------
|
||||
// No write graph to resolve any more: the backend creates a document per
|
||||
// record, so nothing here names where a write lands.
|
||||
|
||||
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
|
||||
console.log('[FestipodData] createEvent (Refine):', event.title);
|
||||
@@ -528,14 +523,11 @@ function useNgData(): FestipodDataContextValue {
|
||||
console.log('[FestipodData] Already participating, skipping');
|
||||
return;
|
||||
}
|
||||
// NOTHING ELSE. The count is derived from the participations, so writing the
|
||||
// participation IS moving the count — there is no second write to keep in
|
||||
// step, and no counter to race.
|
||||
await createParticipation(eventId, uid);
|
||||
// The count lives on the event, which is now a Refine record: bumping it is a
|
||||
// full record write, not the in-place ORM mutation it used to be.
|
||||
const current = eventRows.find(r => eventBinding.domainId(r) === eventId);
|
||||
if (current) {
|
||||
await writeEvent(eventId, { participantCount: current.participantCount + 1 });
|
||||
}
|
||||
}, [participations, createParticipation, eventRows, eventBinding, writeEvent, currentUserId]);
|
||||
}, [participations, createParticipation, currentUserId]);
|
||||
|
||||
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
@@ -547,16 +539,13 @@ function useNgData(): FestipodDataContextValue {
|
||||
return p.eventId === eventId && p.userId === uid;
|
||||
});
|
||||
if (row) {
|
||||
// Removing the participation IS moving the count — see joinEvent.
|
||||
await deleteParticipationRow({
|
||||
resource: participationBinding.resource,
|
||||
id: participationBinding.recordKey(row),
|
||||
});
|
||||
const current = eventRows.find(r => eventBinding.domainId(r) === eventId);
|
||||
if (current) {
|
||||
await writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) });
|
||||
}
|
||||
}
|
||||
}, [participationRows, participationBinding, deleteParticipationRow, eventRows, eventBinding, writeEvent, currentUserId]);
|
||||
}, [participationRows, participationBinding, deleteParticipationRow, currentUserId]);
|
||||
|
||||
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
|
||||
@@ -637,8 +626,18 @@ function NgDataProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
export function FestipodDataProvider({ children }: { children: ReactNode }) {
|
||||
const { status } = useNextGraph();
|
||||
console.log('[FestipodData] Provider — NG status:', status);
|
||||
const backend = useBackend();
|
||||
console.log('[FestipodData] Provider — NG status:', status, '| backend:', backend?.name ?? '(none)');
|
||||
|
||||
// CONNECTED BUT NO BACKEND REGISTERED → demo mode, and said out loud. The
|
||||
// connected path reads and writes through a Refine backend; with none there is
|
||||
// nothing to read a wallet through, and mounting it anyway would hand every
|
||||
// screen empty lists that look exactly like an empty wallet. See
|
||||
// refine/providers/index.ts for why the registry is empty today.
|
||||
if (status === 'connected' && !backend) {
|
||||
console.warn('[FestipodData] Connected, but no Refine backend is registered — falling back to demo data');
|
||||
return <LocalDataProvider>{children}</LocalDataProvider>;
|
||||
}
|
||||
if (status === 'connected') {
|
||||
return <NgDataProvider>{children}</NgDataProvider>;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export const seedEvents: FpEventData[] = [
|
||||
endTime: '18:00',
|
||||
location: 'Le Revel, Rogues (30)',
|
||||
distance: 142,
|
||||
participantCount: 24,
|
||||
participantCount: 5,
|
||||
description: 'Une semaine collaborative pour se rencontrer, co-créer et faire avancer le projet de Réseau Social Universel. Au programme : sessions plénières en intelligence collective, ateliers en forum ouvert, et randonnée au Cirque de Navacelles. Hébergement sur place au Revel, écolieu à Rogues dans le Gard.',
|
||||
hostName: 'Reconnexion',
|
||||
hostInitials: 'RC',
|
||||
@@ -143,7 +143,7 @@ export const seedEvents: FpEventData[] = [
|
||||
endTime: '17:00',
|
||||
location: 'La Maison du Vélo, Lyon',
|
||||
distance: 3,
|
||||
participantCount: 12,
|
||||
participantCount: 3,
|
||||
description: 'Un atelier pratique pour découvrir les low-tech et apprendre à fabriquer des objets du quotidien.',
|
||||
hostName: 'La Maison du Vélo',
|
||||
hostInitials: 'MV',
|
||||
@@ -158,7 +158,7 @@ export const seedEvents: FpEventData[] = [
|
||||
endTime: '18:00',
|
||||
location: "Tiers-lieu L'Hermitage",
|
||||
distance: 89,
|
||||
participantCount: 45,
|
||||
participantCount: 4,
|
||||
description: "Un forum ouvert sur la transition écologique et sociale, dans le tiers-lieu L'Hermitage.",
|
||||
hostName: "L'Hermitage",
|
||||
hostInitials: 'LH',
|
||||
@@ -173,7 +173,7 @@ export const seedEvents: FpEventData[] = [
|
||||
endTime: '17:00',
|
||||
location: 'MJC Montplaisir, Lyon',
|
||||
distance: 5,
|
||||
participantCount: 16,
|
||||
participantCount: 3,
|
||||
description: 'Initiation à la Communication Non Violente. Venez découvrir les bases de la CNV pour améliorer vos relations.',
|
||||
hostName: 'MJC Montplaisir',
|
||||
hostInitials: 'MJ',
|
||||
@@ -188,7 +188,7 @@ export const seedEvents: FpEventData[] = [
|
||||
endTime: '21:00',
|
||||
location: "La Maison de l'Environnement",
|
||||
distance: 7,
|
||||
participantCount: 30,
|
||||
participantCount: 2,
|
||||
description: "Rencontre mensuelle du groupe local des Colibris pour échanger sur les projets en cours.",
|
||||
hostName: 'Les Colibris',
|
||||
hostInitials: 'LC',
|
||||
@@ -198,14 +198,41 @@ export const seedEvents: FpEventData[] = [
|
||||
|
||||
// Participations: which users are in which events
|
||||
export const seedParticipations: FpParticipationData[] = [
|
||||
// Marie (current user) participates in events 1, 2, 3
|
||||
// THESE ARE THE SOURCE OF TRUTH for a participant count. The count is DERIVED
|
||||
// by counting an event's participations, not stored on the event: every
|
||||
// Participation is readable by everyone, only the link to a person is
|
||||
// protected, so there is nothing to compensate for. The `participantCount` on
|
||||
// `seedEvents` is kept in step with these rows and is otherwise ignored on read.
|
||||
//
|
||||
// Marie (the current user) participates in events 1, 2 and 3 — UNCHANGED, and
|
||||
// deliberately: the home screen lists "my events" from exactly these, and the
|
||||
// @ui tests render against it.
|
||||
{ id: 'part-1', eventId: 'event-1', userId: 'user-1', isConfirmed: true },
|
||||
{ id: 'part-2', eventId: 'event-2', userId: 'user-1', isConfirmed: true },
|
||||
{ id: 'part-3', eventId: 'event-3', userId: 'user-1', isConfirmed: true },
|
||||
// Jean participates in events 1
|
||||
// Everything below exists so a derived count shows a plausible number instead
|
||||
// of the near-zero the three rows above would give. Small on purpose: the point
|
||||
// is a demo that looks real, not a reproduction of the figures the fixtures
|
||||
// used to advertise (24, 12, 45…), which would take a hundred rows nobody reads.
|
||||
// event-1 — 5 participants
|
||||
{ id: 'part-4', eventId: 'event-1', userId: 'user-2', isConfirmed: true },
|
||||
// Thomas participates in event 1
|
||||
{ id: 'part-5', eventId: 'event-1', userId: 'user-7', isConfirmed: true },
|
||||
{ id: 'part-6', eventId: 'event-1', userId: 'user-3', isConfirmed: true },
|
||||
{ id: 'part-7', eventId: 'event-1', userId: 'user-9', isConfirmed: true },
|
||||
// event-2 — 3 participants
|
||||
{ id: 'part-8', eventId: 'event-2', userId: 'user-4', isConfirmed: true },
|
||||
{ id: 'part-9', eventId: 'event-2', userId: 'user-8', isConfirmed: true },
|
||||
// event-3 — 4 participants
|
||||
{ id: 'part-10', eventId: 'event-3', userId: 'user-5', isConfirmed: true },
|
||||
{ id: 'part-11', eventId: 'event-3', userId: 'user-6', isConfirmed: true },
|
||||
{ id: 'part-12', eventId: 'event-3', userId: 'user-10', isConfirmed: true },
|
||||
// event-4 — 3 participants, none of them the current user
|
||||
{ id: 'part-13', eventId: 'event-4', userId: 'user-2', isConfirmed: true },
|
||||
{ id: 'part-14', eventId: 'event-4', userId: 'user-5', isConfirmed: true },
|
||||
{ id: 'part-15', eventId: 'event-4', userId: 'user-9', isConfirmed: true },
|
||||
// event-5 — 2 participants
|
||||
{ id: 'part-16', eventId: 'event-5', userId: 'user-3', isConfirmed: true },
|
||||
{ id: 'part-17', eventId: 'event-5', userId: 'user-8', isConfirmed: true },
|
||||
];
|
||||
|
||||
export const seedMeetingPoints: FpMeetingPointData[] = [
|
||||
|
||||
@@ -27,7 +27,8 @@ import { Refine } from '@refinedev/core';
|
||||
import { getBackend, initialBackendName, resourceNames, type RefineBackend } from './providers';
|
||||
|
||||
interface BackendSelection {
|
||||
backend: RefineBackend;
|
||||
/** `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;
|
||||
}
|
||||
@@ -38,7 +39,13 @@ const BackendContext = createContext<BackendSelection | null>(null);
|
||||
* The current backend. Read it rather than importing one: a module-level import
|
||||
* of a specific backend is what makes it unswitchable.
|
||||
*/
|
||||
export function useBackend(): RefineBackend {
|
||||
/**
|
||||
* 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');
|
||||
@@ -57,11 +64,15 @@ export function useBackendSelection(): BackendSelection {
|
||||
|
||||
export function RefineProvider({ children }: { children: ReactNode }) {
|
||||
const [name, setName] = useState(initialBackendName);
|
||||
const backend = useMemo(() => getBackend(name), [name]);
|
||||
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]);
|
||||
@@ -74,6 +85,13 @@ export function RefineProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const selection = useMemo<BackendSelection>(() => ({ backend, switchTo }), [backend, switchTo]);
|
||||
|
||||
// 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 selection context is still published, so a selector can appear.
|
||||
if (!backend || !providers) {
|
||||
return <BackendContext.Provider value={selection}>{children}</BackendContext.Provider>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BackendContext.Provider value={selection}>
|
||||
<Refine
|
||||
@@ -86,6 +104,16 @@ export function RefineProvider({ children }: { children: ReactNode }) {
|
||||
// `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
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* ngAuthProvider — the `AuthProvider` that `@ng-org/refine-providers` asks to be
|
||||
* handed, backed by Festipod's own NextGraph session.
|
||||
*
|
||||
* WHY NOT THE PACKAGE'S OWN `authProvider()`. Its factory calls `init(…)` from
|
||||
* `@ng-org/web` the moment it runs, and then `initNg(ng, session)` — which is
|
||||
* exactly, line for line, what `utils/ngSession.init()` already does. Using both
|
||||
* would register TWO init callbacks against one WASM session: outside the broker
|
||||
* iframe `init` REDIRECTS the page, so the second registration is a second
|
||||
* redirect, and the two callbacks race to be the one that owns `session`.
|
||||
*
|
||||
* `DataProviderConfig` declares `authProvider` as an INJECTION POINT — the data
|
||||
* provider only ever calls `getSession()` on it, to read a `session_id` for
|
||||
* `doc_create`. So supplying Festipod's session here is using the published
|
||||
* surface as it is meant to be used, not working around it: the session stays
|
||||
* single, and `ngSession` stays the only module that calls `init`.
|
||||
*
|
||||
* The remaining `AuthProvider` members are Refine's auth contract. They are wired
|
||||
* to what Festipod actually has — the technical NextGraph login/logout — rather
|
||||
* than stubbed: `check` reports whether the wallet session is open, and nothing
|
||||
* here invents an identity the app does not hold.
|
||||
*/
|
||||
|
||||
import type { AuthProvider } from '@ng-org/refine-providers';
|
||||
import type { Session } from '@ng-org/web';
|
||||
import { session, login as ngLogin, logoutNg } from '../utils/ngSession';
|
||||
|
||||
export function createNgAuthProvider(): AuthProvider {
|
||||
return {
|
||||
/**
|
||||
* The real technical login: opening the wallet page. `ngSession.login()`
|
||||
* navigates away, so the resolved value is only reached when the broker
|
||||
* round-trip did not happen (already logged in).
|
||||
*/
|
||||
login: async () => {
|
||||
await ngLogin();
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await logoutNg();
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Authenticated = the NextGraph session is open. `AuthGate` is what actually
|
||||
* gates the screens (it reads `NextGraphContext`), so no `redirectTo` is
|
||||
* returned here: Refine has no router in this app and must not try to route.
|
||||
*/
|
||||
check: async () => ({ authenticated: session !== undefined }),
|
||||
|
||||
onError: async (error) => ({ error }),
|
||||
|
||||
/**
|
||||
* The NextGraph session carries the wallet user id under `user` when one is
|
||||
* open. Festipod's PERCEIVED login (the chosen username) lives in
|
||||
* `AccountContext`, which is React state and not reachable from here — so
|
||||
* this reports the technical identity only, and `undefined` when there is none.
|
||||
*/
|
||||
getIdentity: async () => {
|
||||
const user = session?.user;
|
||||
return typeof user === 'string' && user ? { id: user } : undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* The handle the data provider actually consumes. Festipod's
|
||||
* `NextGraphSession` is the same object `@ng-org/web` hands its `init`
|
||||
* callback; the cast only reconciles the two local declarations of it.
|
||||
*/
|
||||
getSession: () => session as Session | undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,53 +1,56 @@
|
||||
/**
|
||||
* The backend registry — which Refine backends exist, and which one starts.
|
||||
*
|
||||
* SWITCHING IS MEANT TO HAPPEN AT RUNTIME, so nothing here is resolved at module
|
||||
* scope: a module-level `const backend = …` is precisely what makes a backend
|
||||
* unswitchable. The current one lives in React state, held by `RefineProvider`,
|
||||
* and consumers read it through `useBackend()`. The build-time global below only
|
||||
* decides which one is selected FIRST.
|
||||
* 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.
|
||||
*
|
||||
* Leaving a backend loses access to its data, and that is accepted: each holds its
|
||||
* own behind its own sign-in, and nothing is migrated between them.
|
||||
* 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.
|
||||
*
|
||||
* There is no selector screen yet — that is interface work, not seam work.
|
||||
* 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.
|
||||
*
|
||||
* ADDING A BACKEND is one file implementing `RefineBackend`, plus one line here.
|
||||
* What that file must answer is not negotiable and cannot be guessed — see
|
||||
* `types.ts`: 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.
|
||||
* 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.
|
||||
*
|
||||
* `@activitypods/refine-providers` is the next candidate, and two of those four
|
||||
* are already known from its published configuration: providers take
|
||||
* `resources: { events: { shapeTreeUri } }` plus a `jsonContext`, and auth is
|
||||
* Solid-OIDC with a `clientId`. The other two — its record shape and whether
|
||||
* `update` patches — are not, and neither is the question that gates the whole
|
||||
* thing: it declares resources in ActivityStreams, while Festipod persists `fp:`
|
||||
* shapes. `as:Event` exists; a meeting point, the product's unit of value, has no
|
||||
* obvious ActivityStreams equivalent. That has to be settled before an adapter
|
||||
* can be written rather than invented.
|
||||
* 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 { nextgraphBackend } from './nextgraph';
|
||||
import type { RefineBackend } from './types';
|
||||
|
||||
export type { RecordBinding, RecordBindings, RefineBackend } from './types';
|
||||
export { resourceNames } from './types';
|
||||
|
||||
const BACKENDS: Record<string, RefineBackend> = {
|
||||
[nextgraphBackend.name]: nextgraphBackend,
|
||||
};
|
||||
const BACKENDS: Record<string, RefineBackend> = {};
|
||||
|
||||
// Build-injected, same plumbing as the other build-time globals (build.ts
|
||||
// `define`s it). Absent → the default below, so nothing has to be configured.
|
||||
// `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;
|
||||
}
|
||||
|
||||
const DEFAULT_BACKEND = nextgraphBackend.name;
|
||||
|
||||
/** Every registered backend — what a selector would offer. */
|
||||
export function listBackends(): RefineBackend[] {
|
||||
return Object.values(BACKENDS);
|
||||
@@ -61,14 +64,22 @@ export function listBackends(): RefineBackend[] {
|
||||
export function getBackend(name: string): RefineBackend {
|
||||
const backend = BACKENDS[name];
|
||||
if (!backend) {
|
||||
throw new Error(
|
||||
`Unknown Refine backend "${name}" — known: ${Object.keys(BACKENDS).join(', ')}`,
|
||||
);
|
||||
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. */
|
||||
export function initialBackendName(): string {
|
||||
return globalThis.__FESTIPOD_BACKEND__ || DEFAULT_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];
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* The NextGraph backend — `@ng-org/refine-providers` over the wallet this
|
||||
* deployment already opens.
|
||||
*
|
||||
* Everything backend-specific that used to sit inline in `FestipodDataContext`
|
||||
* lives here: the `fp:` vocabulary, the two identifiers, and the fact that this
|
||||
* provider's `update` replaces a record rather than patching it.
|
||||
*/
|
||||
|
||||
import { dataProvider, liveProvider } from '@ng-org/refine-providers';
|
||||
import type { BaseKey } from '@refinedev/core';
|
||||
import type { FpEventData, FpParticipationData, FpUserData } from '../../data/types';
|
||||
import type { FpEvent, FpParticipation, FpUserProfile } from '../../shapes/orm/festipodShapes.typings';
|
||||
import shapeTypes, {
|
||||
RESOURCE_EVENTS,
|
||||
RESOURCE_PARTICIPATIONS,
|
||||
RESOURCE_PROFILES,
|
||||
} from '../shapeTypes';
|
||||
import { createNgAuthProvider } from '../ngAuthProvider';
|
||||
import type { RecordBinding, RefineBackend } from './types';
|
||||
|
||||
/**
|
||||
* The RDF classes Festipod persists. These IRIs ARE the stored data — renaming
|
||||
* one renames what is on disk, so they are never adjusted for convenience.
|
||||
*/
|
||||
const TYPE = {
|
||||
event: 'http://festipod.org/Event',
|
||||
profile: 'http://festipod.org/UserProfile',
|
||||
participation: 'http://festipod.org/Participation',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shared by every binding here: this provider addresses a record by the DOCUMENT
|
||||
* NURI, while the app's entities reference each other by the SUBJECT IRI. One
|
||||
* document per record keeps them 1-1, but they are different strings and `update`
|
||||
* only accepts the first.
|
||||
*/
|
||||
const recordKey = (record: unknown): BaseKey =>
|
||||
(record as { '@graph': string })['@graph'] as BaseKey;
|
||||
const domainId = (record: unknown): string => (record as { '@id': string })['@id'];
|
||||
|
||||
/** This provider's `update` deletes the old object and adds one built from values. */
|
||||
const replaces = {
|
||||
updateReplaces: true as const,
|
||||
toPatch: (): never => {
|
||||
throw new Error('nextgraph: update replaces the record — send the whole of it, not a patch');
|
||||
},
|
||||
};
|
||||
|
||||
const eventBinding: RecordBinding<FpEventData> = {
|
||||
resource: RESOURCE_EVENTS,
|
||||
// Events are what other users must find — see concept `functional-domain`.
|
||||
visibility: 'public',
|
||||
recordKey,
|
||||
domainId,
|
||||
...replaces,
|
||||
|
||||
toDomain: (record) => {
|
||||
const e = record as FpEvent;
|
||||
return {
|
||||
id: e['@id'],
|
||||
title: e.title,
|
||||
description: e.description || '',
|
||||
date: e.date,
|
||||
location: e.location,
|
||||
distance: e.distance,
|
||||
participantCount: e.participantCount,
|
||||
coverImage: e.coverImage,
|
||||
hostName: e.hostName,
|
||||
hostInitials: e.hostInitials,
|
||||
};
|
||||
},
|
||||
|
||||
// `@type` IS NOT OPTIONAL. The data provider writes a hardcoded ActivityStreams
|
||||
// `Note` type — the type of the example app it was written against — and spreads
|
||||
// the caller's values AFTER it, so passing `@type` is what makes the record a
|
||||
// Festipod Event. Drop it and the shape stops matching on read.
|
||||
toValues: (data) => ({
|
||||
'@type': TYPE.event,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
date: data.date,
|
||||
location: data.location,
|
||||
distance: data.distance,
|
||||
participantCount: data.participantCount,
|
||||
coverImage: data.coverImage,
|
||||
hostName: data.hostName,
|
||||
hostInitials: data.hostInitials,
|
||||
}),
|
||||
};
|
||||
|
||||
const profileBinding: RecordBinding<FpUserData> = {
|
||||
resource: RESOURCE_PROFILES,
|
||||
// A profile is readable by one's network, not by everyone.
|
||||
visibility: 'protected',
|
||||
recordKey,
|
||||
domainId,
|
||||
...replaces,
|
||||
|
||||
toDomain: (record) => {
|
||||
const u = record as FpUserProfile;
|
||||
return {
|
||||
id: u['@id'],
|
||||
name: u.name,
|
||||
initials: u.initials,
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
isPublic: u.isPublic,
|
||||
};
|
||||
},
|
||||
|
||||
toValues: (data) => ({
|
||||
'@type': TYPE.profile,
|
||||
name: data.name,
|
||||
initials: data.initials,
|
||||
username: data.username,
|
||||
role: data.role,
|
||||
isPublic: data.isPublic,
|
||||
}),
|
||||
};
|
||||
|
||||
const participationBinding: RecordBinding<FpParticipationData> = {
|
||||
resource: RESOURCE_PARTICIPATIONS,
|
||||
visibility: 'protected',
|
||||
recordKey,
|
||||
domainId,
|
||||
...replaces,
|
||||
|
||||
// `fp:event` and `fp:user` hold SUBJECT IRIs, which is why `domainId` above is
|
||||
// the subject and not the document: these references resolve against it.
|
||||
toDomain: (record) => {
|
||||
const p = record as FpParticipation;
|
||||
return {
|
||||
id: p['@id'],
|
||||
eventId: p.event,
|
||||
userId: p.user,
|
||||
isConfirmed: p.isConfirmed,
|
||||
};
|
||||
},
|
||||
|
||||
toValues: (data) => ({
|
||||
'@type': TYPE.participation,
|
||||
event: data.eventId,
|
||||
user: data.userId,
|
||||
isConfirmed: data.isConfirmed,
|
||||
}),
|
||||
};
|
||||
|
||||
export const nextgraphBackend: RefineBackend = {
|
||||
name: 'nextgraph',
|
||||
bindings: {
|
||||
event: eventBinding,
|
||||
profile: profileBinding,
|
||||
participation: participationBinding,
|
||||
// meetingPoint: pending its SHEX shape — see types.ts.
|
||||
},
|
||||
build: () => {
|
||||
// Festipod's own AuthProvider, not the package's — see ngAuthProvider for why
|
||||
// a second `init()` on one WASM session is a collision, not a convenience.
|
||||
const authProvider = createNgAuthProvider();
|
||||
return {
|
||||
authProvider,
|
||||
dataProvider: dataProvider({ shapeTypes, authProvider }),
|
||||
liveProvider: liveProvider({ shapeTypes }),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -111,6 +111,31 @@ export interface RefineBackend {
|
||||
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))];
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* The Refine RESOURCE MAP — the one place that says which Refine resource name
|
||||
* stands for which SHEX shape.
|
||||
*
|
||||
* `@ng-org/refine-providers` identifies data by shape type, not by endpoint: its
|
||||
* data provider looks `shapeTypes[resource]` up on every call, so a resource name
|
||||
* that is absent here silently resolves to `undefined` and the read comes back
|
||||
* empty — no error, just nothing. Every `useList` / `useOne` / `useCreate` /
|
||||
* `useUpdate` call site must therefore name a resource declared below.
|
||||
*
|
||||
* These names are the NextGraph backend's, which is why this file lives beside it
|
||||
* rather than in the app: another backend keys its own configuration by its own
|
||||
* names, and nothing says the two must agree on a spelling.
|
||||
*/
|
||||
|
||||
import type { ShapeTypes } from '@ng-org/refine-providers';
|
||||
import {
|
||||
FpEventShapeType,
|
||||
FpParticipationShapeType,
|
||||
FpUserProfileShapeType,
|
||||
} from '../shapes/orm/festipodShapes.shapeTypes';
|
||||
|
||||
/** Resource names. Import them — never retype the strings. */
|
||||
export const RESOURCE_EVENTS = 'events';
|
||||
export const RESOURCE_PROFILES = 'profiles';
|
||||
export const RESOURCE_PARTICIPATIONS = 'participations';
|
||||
|
||||
const shapeTypes: ShapeTypes = {
|
||||
[RESOURCE_EVENTS]: FpEventShapeType,
|
||||
[RESOURCE_PROFILES]: FpUserProfileShapeType,
|
||||
[RESOURCE_PARTICIPATIONS]: FpParticipationShapeType,
|
||||
};
|
||||
|
||||
export default shapeTypes;
|
||||
@@ -5,7 +5,7 @@ import type { Schema } from "@ng-org/shex-orm";
|
||||
* festipodShapesSchema: Schema for festipodShapes
|
||||
* =============================================================================
|
||||
*/
|
||||
export const festipodShapesSchema: Schema = {
|
||||
export const festipodShapesSchema = {
|
||||
"http://festipod.org/Event": {
|
||||
iri: "http://festipod.org/Event",
|
||||
predicates: [
|
||||
@@ -22,63 +22,99 @@ export const festipodShapesSchema: Schema = {
|
||||
readablePredicate: "@type",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/title",
|
||||
readablePredicate: "title",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/description",
|
||||
readablePredicate: "description",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/date",
|
||||
readablePredicate: "date",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/location",
|
||||
readablePredicate: "location",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "number" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "number",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/distance",
|
||||
readablePredicate: "distance",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "number" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "number",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/participantCount",
|
||||
readablePredicate: "participantCount",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/coverImage",
|
||||
readablePredicate: "coverImage",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/hostName",
|
||||
readablePredicate: "hostName",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/hostInitials",
|
||||
@@ -102,35 +138,55 @@ export const festipodShapesSchema: Schema = {
|
||||
readablePredicate: "@type",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/name",
|
||||
readablePredicate: "name",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/initials",
|
||||
readablePredicate: "initials",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/username",
|
||||
readablePredicate: "username",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/role",
|
||||
readablePredicate: "role",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "boolean" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "boolean",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/isPublic",
|
||||
@@ -154,21 +210,33 @@ export const festipodShapesSchema: Schema = {
|
||||
readablePredicate: "@type",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "iri" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "iri",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/event",
|
||||
readablePredicate: "event",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "iri" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "iri",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/user",
|
||||
readablePredicate: "user",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "boolean" }],
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "boolean",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/isConfirmed",
|
||||
@@ -176,4 +244,98 @@ export const festipodShapesSchema: Schema = {
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
"http://festipod.org/MeetingPoint": {
|
||||
iri: "http://festipod.org/MeetingPoint",
|
||||
predicates: [
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "iri",
|
||||
literals: ["http://festipod.org/MeetingPoint"],
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "@type",
|
||||
},
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "iri",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/event",
|
||||
readablePredicate: "event",
|
||||
},
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "iri",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/host",
|
||||
readablePredicate: "host",
|
||||
},
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/title",
|
||||
readablePredicate: "title",
|
||||
},
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/description",
|
||||
readablePredicate: "description",
|
||||
},
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/place",
|
||||
readablePredicate: "place",
|
||||
},
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/time",
|
||||
readablePredicate: "time",
|
||||
},
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "string",
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/inbox",
|
||||
readablePredicate: "inbox",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const satisfies Schema;
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import type { ShapeType } from "@ng-org/shex-orm";
|
||||
import { festipodShapesSchema } from "./festipodShapes.schema";
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from "./festipodShapes.typings";
|
||||
import { festipodShapesSchema } from "./festipodShapes.schema.ts";
|
||||
import type {
|
||||
Event,
|
||||
UserProfile,
|
||||
Participation,
|
||||
MeetingPoint,
|
||||
} from "./festipodShapes.typings.ts";
|
||||
|
||||
// ShapeTypes for festipodShapes
|
||||
export const FpEventShapeType: ShapeType<FpEvent> = {
|
||||
|
||||
export const EventShapeType: ShapeType<Event> = {
|
||||
schema: festipodShapesSchema,
|
||||
shape: "http://festipod.org/Event",
|
||||
};
|
||||
export const FpUserProfileShapeType: ShapeType<FpUserProfile> = {
|
||||
|
||||
export const UserProfileShapeType: ShapeType<UserProfile> = {
|
||||
schema: festipodShapesSchema,
|
||||
shape: "http://festipod.org/UserProfile",
|
||||
};
|
||||
export const FpParticipationShapeType: ShapeType<FpParticipation> = {
|
||||
|
||||
export const ParticipationShapeType: ShapeType<Participation> = {
|
||||
schema: festipodShapesSchema,
|
||||
shape: "http://festipod.org/Participation",
|
||||
};
|
||||
|
||||
export const MeetingPointShapeType: ShapeType<MeetingPoint> = {
|
||||
schema: festipodShapesSchema,
|
||||
shape: "http://festipod.org/MeetingPoint",
|
||||
};
|
||||
|
||||
@@ -9,9 +9,9 @@ export type IRI = string;
|
||||
/**
|
||||
* Event Type
|
||||
*/
|
||||
export interface FpEvent {
|
||||
export interface Event {
|
||||
/**
|
||||
* The graph IRI.
|
||||
* The graph NURI.
|
||||
*/
|
||||
readonly "@graph": IRI;
|
||||
/**
|
||||
@@ -81,9 +81,9 @@ export interface FpEvent {
|
||||
/**
|
||||
* UserProfile Type
|
||||
*/
|
||||
export interface FpUserProfile {
|
||||
export interface UserProfile {
|
||||
/**
|
||||
* The graph IRI.
|
||||
* The graph NURI.
|
||||
*/
|
||||
readonly "@graph": IRI;
|
||||
/**
|
||||
@@ -129,9 +129,9 @@ export interface FpUserProfile {
|
||||
/**
|
||||
* Participation Type
|
||||
*/
|
||||
export interface FpParticipation {
|
||||
export interface Participation {
|
||||
/**
|
||||
* The graph IRI.
|
||||
* The graph NURI.
|
||||
*/
|
||||
readonly "@graph": IRI;
|
||||
/**
|
||||
@@ -161,3 +161,63 @@ export interface FpParticipation {
|
||||
*/
|
||||
isConfirmed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MeetingPoint Type
|
||||
*/
|
||||
export interface MeetingPoint {
|
||||
/**
|
||||
* The graph NURI.
|
||||
*/
|
||||
readonly "@graph": IRI;
|
||||
/**
|
||||
* The subject IRI.
|
||||
*/
|
||||
readonly "@id": IRI;
|
||||
/**
|
||||
* Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type
|
||||
*/
|
||||
"@type": "http://festipod.org/MeetingPoint";
|
||||
/**
|
||||
* Reference to the public event this meeting point is anchored to
|
||||
*
|
||||
* Original IRI: http://festipod.org/event
|
||||
*/
|
||||
event: IRI;
|
||||
/**
|
||||
* Reference to the user hosting the meeting point
|
||||
*
|
||||
* Original IRI: http://festipod.org/host
|
||||
*/
|
||||
host: IRI;
|
||||
/**
|
||||
* The title of the meeting point
|
||||
*
|
||||
* Original IRI: http://festipod.org/title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* A description of the meeting point
|
||||
*
|
||||
* Original IRI: http://festipod.org/description
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Where participants gather (e.g. 'Entrée principale, sous l'horloge')
|
||||
*
|
||||
* Original IRI: http://festipod.org/place
|
||||
*/
|
||||
place?: string;
|
||||
/**
|
||||
* When participants gather (display string)
|
||||
*
|
||||
* Original IRI: http://festipod.org/time
|
||||
*/
|
||||
time?: string;
|
||||
/**
|
||||
* NURI of the meeting point's inbox (wired in T02.b/c)
|
||||
*
|
||||
* Original IRI: http://festipod.org/inbox
|
||||
*/
|
||||
inbox?: string;
|
||||
}
|
||||
|
||||
@@ -47,3 +47,21 @@ fp:Participation {
|
||||
fp:isConfirmed xsd:boolean
|
||||
// rdfs:comment "Whether the participation is confirmed" ;
|
||||
}
|
||||
|
||||
fp:MeetingPoint {
|
||||
a [fp:MeetingPoint] ;
|
||||
fp:event IRI
|
||||
// rdfs:comment "Reference to the public event this meeting point is anchored to" ;
|
||||
fp:host IRI
|
||||
// rdfs:comment "Reference to the user hosting the meeting point" ;
|
||||
fp:title xsd:string
|
||||
// rdfs:comment "The title of the meeting point" ;
|
||||
fp:description xsd:string ?
|
||||
// rdfs:comment "A description of the meeting point" ;
|
||||
fp:place xsd:string ?
|
||||
// rdfs:comment "Where participants gather (e.g. 'Entrée principale, sous l'horloge')" ;
|
||||
fp:time xsd:string ?
|
||||
// rdfs:comment "When participants gather (display string)" ;
|
||||
fp:inbox xsd:string ?
|
||||
// rdfs:comment "NURI of the meeting point's inbox (wired in T02.b/c)" ;
|
||||
}
|
||||
|
||||
@@ -10,18 +10,23 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
|
||||
import { RefineProvider } from '../refine/RefineProvider';
|
||||
import { RefineProvider, useBackend } from '../refine/RefineProvider';
|
||||
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
|
||||
import type { FpEventData, FpParticipationData, FpUserData } from '../data/types';
|
||||
import { useShapeSet, useShapeSubscription } from '../hooks/useShapeSet';
|
||||
import { ng } from '@ng-org/web';
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
import {
|
||||
FpEventShapeType,
|
||||
FpUserProfileShapeType,
|
||||
FpParticipationShapeType,
|
||||
EventShapeType as FpEventShapeType,
|
||||
ParticipationShapeType as FpParticipationShapeType,
|
||||
UserProfileShapeType as FpUserProfileShapeType,
|
||||
} from '../shapes/orm/festipodShapes.shapeTypes';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
import type {
|
||||
Event as FpEvent,
|
||||
MeetingPoint as FpMeetingPoint,
|
||||
Participation as FpParticipation,
|
||||
UserProfile as FpUserProfile,
|
||||
} from '../shapes/orm/festipodShapes.typings';
|
||||
import { seedEvents, seedUsers, seedParticipations } from '../data/seedData';
|
||||
import { ensureGraphNuri } from '../utils/ngGraph';
|
||||
|
||||
@@ -46,6 +51,18 @@ function DataHarnessNG() {
|
||||
// Wait for NG connection before exposing the test bridge
|
||||
function HarnessRouter() {
|
||||
const { status } = useNextGraph();
|
||||
const backend = useBackend();
|
||||
|
||||
// NO BACKEND, NO BRIDGE — and this refusal is the point. Without one the data
|
||||
// context falls back to demo fixtures, so a @data scenario would read seeded
|
||||
// events, assert happily on them and report GREEN while touching no wallet at
|
||||
// all. A layer that exists to test broker persistence must fail rather than
|
||||
// pass on fixtures. `reason` is published so the step's timeout is diagnosable
|
||||
// instead of mute.
|
||||
if (!backend) {
|
||||
(window as any).__testData = { ready: false, reason: 'no-refine-backend' };
|
||||
return <div id="harness-status">NO_REFINE_BACKEND</div>;
|
||||
}
|
||||
|
||||
if (status === 'connected') {
|
||||
return <ConnectedHarness />;
|
||||
@@ -107,9 +124,15 @@ function ConnectedHarness() {
|
||||
}));
|
||||
/**
|
||||
* A read-only Set-like over an app-backed array snapshot: supports
|
||||
* `[...x]`, `x.size`, and a no-op `delete`. The "empty wallet" scenario runs
|
||||
* before any seed, so there is nothing for it to delete — and a seeded
|
||||
* record is not this bridge's to remove.
|
||||
* `[...x]`, `x.size`, and `delete`.
|
||||
*
|
||||
* KNOWN DEFECT, recorded rather than left to be rediscovered: `delete` is a
|
||||
* no-op. It came from the sibling branch, where it is justified — there the
|
||||
* fixture seed is off, so the "empty wallet" scenario has nothing to delete.
|
||||
* HERE the seed writes, so that premise is false and the step which empties
|
||||
* the wallet silently does nothing. Fixing it needs a decision, because the
|
||||
* app exposes no `deleteEvent`: the bridge would have to read the app's view
|
||||
* and delete through the ORM, a hybrid worth stating before adopting.
|
||||
*/
|
||||
const setLike = <T,>(snapshot: () => T[]) => ({
|
||||
get size() { return snapshot().length; },
|
||||
|
||||
@@ -12,7 +12,12 @@ import React, { useEffect, useRef } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { deepSignal } from '@ng-org/alien-deepsignals';
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
import type {
|
||||
Event as FpEvent,
|
||||
MeetingPoint as FpMeetingPoint,
|
||||
Participation as FpParticipation,
|
||||
UserProfile as FpUserProfile,
|
||||
} from '../shapes/orm/festipodShapes.typings';
|
||||
|
||||
// ============================================================================
|
||||
// Seed data — same events/users as the app's seedData.ts
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
* module runs outside the component tree.
|
||||
*/
|
||||
|
||||
import type { FpEvent, FpUserProfile } from '../shapes/orm/festipodShapes.typings';
|
||||
import type {
|
||||
Event as FpEvent,
|
||||
UserProfile as FpUserProfile,
|
||||
} from '../shapes/orm/festipodShapes.typings';
|
||||
import type { FpEventData, FpUserData } from '../data/types';
|
||||
import { seedEvents, seedUsers } from '../data/seedData';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user