Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 412e5d63e7 | |||
| d48297fe94 | |||
| a5f6e01562 | |||
| 33b4588da4 | |||
| 0c4f6b6650 | |||
| b25bafbb90 | |||
| 3de86c8be2 | |||
| 4b4dfa6cad | |||
| 974d251079 | |||
| fd02570b8b | |||
| efdfcac4c1 |
@@ -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
|
||||
|
||||
@@ -40,10 +40,13 @@ Given('le portefeuille contient déjà des événements', async function (this:
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
});
|
||||
// Wait for data to propagate
|
||||
// Wait for data to propagate. `waitForFunction(fn, arg, options)` — the
|
||||
// timeout goes in the THIRD slot; passed second it is silently taken as the
|
||||
// predicate's argument and the wait runs on the 30s default instead.
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__testData.events.size > 0,
|
||||
{ timeout: 10000 },
|
||||
undefined,
|
||||
{ timeout: 75000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -58,22 +61,37 @@ When('je charge les données de test', async function (this: FestipodWorld) {
|
||||
});
|
||||
(this as any)._eventCountBefore = countBefore;
|
||||
|
||||
await this.appFrame!.evaluate(() => {
|
||||
// AWAIT the seed's own promise (loadTestData returns a BootstrapResult promise)
|
||||
// and record whether it actually seeded — so the propagation wait below can tell
|
||||
// a genuinely-populated wallet (nothing to appear) from an empty one that must
|
||||
// seed. Fire-and-forget here would let the assertions race the async seed.
|
||||
const seededResult = await this.appFrame!.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
const r = await td.loadTestData();
|
||||
return { seeded: r?.seeded ?? false };
|
||||
});
|
||||
(this as any)._loadSeeded = seededResult.seeded;
|
||||
|
||||
// Wait for data to propagate (if wallet was empty, data should appear)
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
// Either data was already there, or it should appear after loading
|
||||
return td.events.size > 0 || td._loadResult?.seeded === false;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
).catch(() => {
|
||||
// Timeout is OK if wallet was already populated (idempotent case)
|
||||
});
|
||||
// Wait for data to propagate, and for BOTH events and users — not just events.
|
||||
// They arrive by two independent paths here: events through the Refine read,
|
||||
// the seeded profiles through the ORM subscription on the protected scope. If
|
||||
// only events are awaited, `contient des utilisateurs` asserts before the
|
||||
// profile read lands and flakes to users:0. On a wallet that already had data
|
||||
// (seeded === false) there is nothing to wait for. The assertions still verify
|
||||
// the real counts; this only synchronizes.
|
||||
if (seededResult.seeded) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size > 0 && td.users.size > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 75000 },
|
||||
).catch(() => {
|
||||
// Timeout tolerated — the assertions below surface the real failure with a
|
||||
// clearer message than a raw waitForFunction timeout.
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Assertions ---
|
||||
|
||||
@@ -14,13 +14,11 @@ import {
|
||||
seedMeetingPoints,
|
||||
seedFriendships,
|
||||
} from '../data/seedData';
|
||||
import { useList, useCreate, useUpdate } from '@refinedev/core';
|
||||
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),
|
||||
// mirroring the target per-user stores. Default OFF — the validated mono-store
|
||||
@@ -30,13 +28,22 @@ import { RESOURCE_EVENTS } from '../refine/shapeTypes';
|
||||
// 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';
|
||||
|
||||
@@ -70,9 +77,15 @@ interface FestipodDataContextValue {
|
||||
selectedUser: FpUserData | undefined;
|
||||
|
||||
createEvent(event: Omit<FpEventData, 'id'>): Promise<FpEventData>;
|
||||
updateEvent(id: string, updates: Partial<FpEventData>): void;
|
||||
joinEvent(eventId: string, userId?: string): void;
|
||||
leaveEvent(eventId: string, userId?: string): void;
|
||||
/**
|
||||
* AWAITABLE, and that matters: a caller that needs the write to have landed —
|
||||
* a test driving the app's own path, a screen confirming — has to be able to
|
||||
* wait for it. Returning `void` made the write fire-and-forget, so an assertion
|
||||
* right after it read the value from before.
|
||||
*/
|
||||
updateEvent(id: string, updates: Partial<FpEventData>): Promise<void>;
|
||||
joinEvent(eventId: string, userId?: string): Promise<void>;
|
||||
leaveEvent(eventId: string, userId?: string): Promise<void>;
|
||||
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
|
||||
addFriend(friendId: string): void;
|
||||
updateProfile(updates: Partial<FpUserData>): void;
|
||||
@@ -90,64 +103,51 @@ 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
|
||||
// the provider below, NOT captured here: a module-level constant would pin the app
|
||||
// to whichever backend was current at import time, which is exactly what makes a
|
||||
// backend unswitchable. Everything else in this file still speaks `fp:` directly,
|
||||
// because profiles and participations have not moved to Refine yet.
|
||||
|
||||
// NG shape → app type mappers
|
||||
const mapEvent = (e: FpEvent): FpEventData => ({
|
||||
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,
|
||||
});
|
||||
|
||||
/**
|
||||
* Every persisted field of an event, in the form the Refine data provider takes.
|
||||
* `@type` is carried explicitly — see `createEventDoc` for why it must be.
|
||||
*/
|
||||
const eventFields = (e: Omit<FpEventData, 'id'>) => ({
|
||||
"@type": "http://festipod.org/Event",
|
||||
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,
|
||||
});
|
||||
|
||||
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[],
|
||||
@@ -198,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;
|
||||
|
||||
@@ -225,13 +227,13 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
console.log('[FestipodData] createEvent (local, no-op):', event.title);
|
||||
return { ...event, id: nextId('event') };
|
||||
}, []);
|
||||
const updateEvent = useCallback((_id: string, _updates: Partial<FpEventData>) => {
|
||||
const updateEvent = useCallback(async (_id: string, _updates: Partial<FpEventData>) => {
|
||||
console.log('[FestipodData] updateEvent (local, no-op)');
|
||||
}, []);
|
||||
const joinEvent = useCallback((_eventId: string) => {
|
||||
const joinEvent = useCallback(async (_eventId: string) => {
|
||||
console.log('[FestipodData] joinEvent (local, no-op)');
|
||||
}, []);
|
||||
const leaveEvent = useCallback((_eventId: string) => {
|
||||
const leaveEvent = useCallback(async (_eventId: string) => {
|
||||
console.log('[FestipodData] leaveEvent (local, no-op)');
|
||||
}, []);
|
||||
const addMeetingPoint = useCallback((_mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
@@ -266,44 +268,28 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
function useNgData(): FestipodDataContextValue {
|
||||
const { session } = useNextGraph();
|
||||
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.
|
||||
// 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;
|
||||
// Mono-store fallback scope: the shared wallet's private store NURI. Opens the
|
||||
// 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
|
||||
@@ -311,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
|
||||
@@ -324,65 +310,104 @@ function useNgData(): FestipodDataContextValue {
|
||||
// not an empty wallet.
|
||||
const eventsRead = eventsQuery.isSuccess;
|
||||
|
||||
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
|
||||
const emptyUsers: FpUserData[] = [];
|
||||
const emptyParticipations: FpParticipationData[] = [];
|
||||
// --- Profiles and participations: read through REFINE too -----------------
|
||||
// Nothing in this provider talks to the ORM any more. What that costs, stated
|
||||
// rather than hidden: these two used to read from the PROTECTED scope, and this
|
||||
// data provider reads across every graph it can see. The narrowing that still
|
||||
// happens is `applyIsolation` below, at the app level — the backend's
|
||||
// `visibility` declaration is where closing it properly belongs.
|
||||
const { result: profilesResult, query: profilesQuery } = useList<FpUserProfile>({
|
||||
resource: profileBinding.resource,
|
||||
pagination: { mode: 'off' },
|
||||
});
|
||||
const { result: participationsResult } = useList<FpParticipation>({
|
||||
resource: participationBinding.resource,
|
||||
pagination: { mode: 'off' },
|
||||
});
|
||||
const profileRows = profilesResult?.data ?? [];
|
||||
const participationRows = participationsResult?.data ?? [];
|
||||
|
||||
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
|
||||
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
|
||||
const users = profileRows.map(profileBinding.toDomain);
|
||||
const participations = participationRows.map(participationBinding.toDomain);
|
||||
const events = withDerivedCounts(eventRows.map(eventBinding.toDomain), participations);
|
||||
|
||||
const events = eventRows.map(mapEvent);
|
||||
const users = usersShape.items;
|
||||
const participations = participationsShape.items;
|
||||
|
||||
// --- Events: write through REFINE ----------------------------------------
|
||||
// --- Writes: every entity through REFINE ----------------------------------
|
||||
const { mutateAsync: createEventRow } = useCreate<FpEvent>();
|
||||
const { mutateAsync: updateEventRow } = useUpdate<FpEvent>();
|
||||
const { mutateAsync: createProfileRow } = useCreate<FpUserProfile>();
|
||||
const { mutateAsync: updateProfileRow } = useUpdate<FpUserProfile>();
|
||||
const { mutateAsync: createParticipationRow } = useCreate<FpParticipation>();
|
||||
const { mutateAsync: deleteParticipationRow } = useDelete<FpParticipation>();
|
||||
|
||||
/** Sign one user up to one event — a Participation record of its own. */
|
||||
const createParticipation = useCallback(
|
||||
async (eventId: string, userId: string): Promise<void> => {
|
||||
await createParticipationRow({
|
||||
resource: participationBinding.resource,
|
||||
values: participationBinding.toValues({ eventId, userId, isConfirmed: true }),
|
||||
});
|
||||
},
|
||||
[createParticipationRow, participationBinding],
|
||||
);
|
||||
|
||||
/** Create ONE profile and return the stored record — used by the seed. */
|
||||
const createProfileDoc = useCallback(
|
||||
async (profile: Omit<FpUserData, 'id'>): Promise<FpUserProfile | undefined> => {
|
||||
const created = await createProfileRow({
|
||||
resource: profileBinding.resource,
|
||||
values: profileBinding.toValues(profile),
|
||||
});
|
||||
return created?.data;
|
||||
},
|
||||
[createProfileRow, profileBinding],
|
||||
);
|
||||
|
||||
|
||||
/**
|
||||
* Create ONE event as its own document. Returns the stored row, whose `@id`
|
||||
* is the subject IRI the participations reference.
|
||||
*
|
||||
* `@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 rather than a Note. Drop it and the shape stops matching on
|
||||
* read, and the event disappears.
|
||||
* Create ONE event and return the stored record. The backend's binding supplies
|
||||
* the values, including whatever type marker that backend needs — see its
|
||||
* `toValues`, and `providers/types.ts` for why that is not this file's business.
|
||||
*/
|
||||
const createEventDoc = useCallback(
|
||||
async (event: Omit<FpEventData, 'id'>): Promise<FpEvent | undefined> => {
|
||||
const created = await createEventRow({
|
||||
resource: RESOURCE_EVENTS,
|
||||
values: eventFields(event),
|
||||
resource: eventBinding.resource,
|
||||
values: eventBinding.toValues(event),
|
||||
});
|
||||
return created?.data;
|
||||
},
|
||||
[createEventRow],
|
||||
[createEventRow, eventBinding],
|
||||
);
|
||||
|
||||
/**
|
||||
* Overwrite an event, addressed by its app id (the subject IRI).
|
||||
* Update an event, addressed by its APP id — which is not necessarily the key
|
||||
* Refine addresses the record by, so the binding answers both.
|
||||
*
|
||||
* The provider's `update` REPLACES the record — it deletes the old object and
|
||||
* adds a new one built from `values` — so every persisted field has to be sent.
|
||||
* A partial write here would silently drop whatever it left out, which is why
|
||||
* the current row is read back and merged first.
|
||||
* When the backend's `update` REPLACES the record, every persisted field has to
|
||||
* be sent or whatever is omitted is silently dropped; that is why the current
|
||||
* record is read back and merged first. A backend that patches does not need
|
||||
* the merge, so the binding says which it is rather than this file assuming.
|
||||
*/
|
||||
const writeEvent = useCallback(
|
||||
async (id: string, updates: Partial<FpEventData>): Promise<void> => {
|
||||
const row = eventRows.find(r => r['@id'] === id);
|
||||
const row = eventRows.find(r => eventBinding.domainId(r) === id);
|
||||
if (!row) {
|
||||
console.warn('[FestipodData] updateEvent: no event known under', id);
|
||||
return;
|
||||
}
|
||||
// Replacing → send the merged WHOLE record. Patching → send only what
|
||||
// changed, so a concurrent edit to another field is not overwritten by a
|
||||
// value this render happened to be holding.
|
||||
const values = eventBinding.updateReplaces
|
||||
? eventBinding.toValues({ ...eventBinding.toDomain(row), ...updates })
|
||||
: eventBinding.toPatch(updates);
|
||||
await updateEventRow({
|
||||
resource: RESOURCE_EVENTS,
|
||||
// Refine addresses the record by its DOCUMENT NURI, not its subject IRI.
|
||||
id: row['@graph'],
|
||||
values: eventFields({ ...mapEvent(row), ...updates }),
|
||||
resource: eventBinding.resource,
|
||||
id: eventBinding.recordKey(row),
|
||||
values,
|
||||
});
|
||||
},
|
||||
[eventRows, updateEventRow],
|
||||
[eventRows, updateEventRow, eventBinding],
|
||||
);
|
||||
|
||||
// Not in SHEX shapes yet
|
||||
@@ -411,7 +436,9 @@ function useNgData(): FestipodDataContextValue {
|
||||
// says the ORM subscriptions the seed WRITES INTO exist: since @ng-org/orm
|
||||
// alpha.21 the set is `undefined` until the subscription resolves, so an add
|
||||
// issued before then has nowhere to land and throws.
|
||||
const seedWritable = usersShape.ready && participationsShape.ready;
|
||||
// Readiness is now the Refine query's, not an ORM subscription's: an empty list
|
||||
// means nothing until that query has actually succeeded.
|
||||
const seedWritable = profilesQuery.isSuccess;
|
||||
const hasTriedAutoSeed = useRef(false);
|
||||
useEffect(() => {
|
||||
// OFF unless FESTIPOD_AUTO_SEED says otherwise. Firing unconditionally in dev
|
||||
@@ -426,22 +453,20 @@ function useNgData(): FestipodDataContextValue {
|
||||
return;
|
||||
}
|
||||
hasTriedAutoSeed.current = true;
|
||||
const walletHasData = eventRows.length > 0 || usersShape.ngSet.size > 0;
|
||||
const walletHasData = eventRows.length > 0 || profileRows.length > 0;
|
||||
if (shouldAutoSeed(walletHasData)) {
|
||||
console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…');
|
||||
bootstrapWallet({
|
||||
eventCount: eventRows.length,
|
||||
userCount: usersShape.ngSet.size,
|
||||
walletHasData,
|
||||
seedEvent: createEventDoc,
|
||||
ngUsers: usersShape.ngSet as any,
|
||||
ngParticipations: participationsShape.ngSet as any,
|
||||
seedProfile: createProfileDoc,
|
||||
}).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
|
||||
} else {
|
||||
console.log('[FestipodData] Dev auto-seed: wallet already has data — skip');
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [privateNuri, eventsRead, seedWritable, eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]);
|
||||
}, [privateNuri, eventsRead, seedWritable, eventRows.length, profileRows.length, createEventDoc, createProfileDoc]);
|
||||
|
||||
// --- Derived ---
|
||||
// Resolve current user from the chosen account username (the perceived login);
|
||||
@@ -469,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);
|
||||
@@ -481,53 +505,47 @@ function useNgData(): FestipodDataContextValue {
|
||||
const stored = await createEventDoc({ ...event, participantCount: event.participantCount || 1 });
|
||||
const eventIri = stored?.["@id"];
|
||||
if (eventIri && currentUserId) {
|
||||
participationsShape.ngSet.add({
|
||||
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
|
||||
event: eventIri, user: currentUserId, isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
await createParticipation(eventIri, currentUserId);
|
||||
setSelectedEventId(eventIri);
|
||||
}
|
||||
return { ...event, id: eventIri || `ng-pending-${Date.now()}` };
|
||||
}, [protectedGraph, createEventDoc, participationsShape.ngSet, currentUserId]);
|
||||
}, [createEventDoc, createParticipation, currentUserId]);
|
||||
|
||||
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
|
||||
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
|
||||
console.log('[FestipodData] updateEvent (Refine):', id, updates);
|
||||
void writeEvent(id, updates);
|
||||
await writeEvent(id, updates);
|
||||
}, [writeEvent]);
|
||||
|
||||
const joinEvent = useCallback((eventId: string, userId?: string) => {
|
||||
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
console.log('[FestipodData] joinEvent (Refine):', eventId, 'user:', uid);
|
||||
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
|
||||
if (existing) {
|
||||
if (participations.some(p => p.eventId === eventId && p.userId === uid)) {
|
||||
console.log('[FestipodData] Already participating, skipping');
|
||||
return;
|
||||
}
|
||||
participationsShape.ngSet.add({
|
||||
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
|
||||
event: eventId, user: uid, isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
// 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 => r["@id"] === eventId);
|
||||
if (current) {
|
||||
void writeEvent(eventId, { participantCount: current.participantCount + 1 });
|
||||
}
|
||||
}, [protectedGraph, participationsShape.ngSet, eventRows, writeEvent, currentUserId]);
|
||||
// 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);
|
||||
}, [participations, createParticipation, currentUserId]);
|
||||
|
||||
const leaveEvent = useCallback((eventId: string, userId?: string) => {
|
||||
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
console.log('[FestipodData] leaveEvent (Refine):', eventId, 'user:', uid);
|
||||
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
|
||||
if (ngPart) {
|
||||
console.log('[FestipodData] Deleting participation via ngSet.delete():', ngPart["@id"]);
|
||||
participationsShape.ngSet.delete(ngPart);
|
||||
const current = eventRows.find(r => r["@id"] === eventId);
|
||||
if (current) {
|
||||
void writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) });
|
||||
}
|
||||
// Addressed by the RECORD key, not the domain id — the binding answers both,
|
||||
// and `deleteOne` only accepts the first.
|
||||
const row = participationRows.find(r => {
|
||||
const p = participationBinding.toDomain(r);
|
||||
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),
|
||||
});
|
||||
}
|
||||
}, [participationsShape.ngSet, eventRows, writeEvent, currentUserId]);
|
||||
}, [participationRows, participationBinding, deleteParticipationRow, currentUserId]);
|
||||
|
||||
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
|
||||
@@ -544,47 +562,39 @@ function useNgData(): FestipodDataContextValue {
|
||||
}, [currentUserId]);
|
||||
|
||||
const updateProfile = useCallback((updates: Partial<FpUserData>) => {
|
||||
console.log('[FestipodData] updateProfile (NG):', updates);
|
||||
const ngUser = findNg(usersShape.ngSet as any as Set<FpUserProfile>, u => u.username === '@mariedupont')
|
||||
|| [...usersShape.ngSet][0];
|
||||
if (ngUser) {
|
||||
if (updates.name !== undefined) ngUser.name = updates.name;
|
||||
if (updates.initials !== undefined) ngUser.initials = updates.initials;
|
||||
if (updates.username !== undefined) ngUser.username = updates.username;
|
||||
if (updates.role !== undefined) ngUser.role = updates.role;
|
||||
if (updates.isPublic !== undefined) ngUser.isPublic = updates.isPublic;
|
||||
console.log('[FestipodData] updateProfile (Refine):', updates);
|
||||
// "My profile" is the one the current user resolves to, with the historical
|
||||
// fallback kept: the account layer may not have resolved yet.
|
||||
const row =
|
||||
profileRows.find(r => profileBinding.domainId(r) === currentUserId)
|
||||
?? profileRows.find(r => profileBinding.toDomain(r).username === '@mariedupont')
|
||||
?? profileRows[0];
|
||||
if (!row) {
|
||||
console.warn('[FestipodData] updateProfile: no profile to write');
|
||||
return;
|
||||
}
|
||||
}, [usersShape.ngSet]);
|
||||
void updateProfileRow({
|
||||
resource: profileBinding.resource,
|
||||
id: profileBinding.recordKey(row),
|
||||
values: profileBinding.updateReplaces
|
||||
? profileBinding.toValues({ ...profileBinding.toDomain(row), ...updates })
|
||||
: profileBinding.toPatch(updates),
|
||||
});
|
||||
}, [profileRows, profileBinding, updateProfileRow, currentUserId]);
|
||||
|
||||
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
|
||||
console.log('[FestipodData] loadTestData (NG)');
|
||||
// The seed WRITES into the ORM subscriptions, which since @ng-org/orm
|
||||
// alpha.21 do not exist until they have loaded. Await the ORM's own
|
||||
// readiness signal first — a `bootstrapWallet` started too early throws on
|
||||
// its very first `add`, and callers see a wallet that seeded nothing.
|
||||
//
|
||||
// The RESOLVED VALUE is the set. Re-reading `usersShape.ngSet` after the
|
||||
// await would hand over the placeholder captured by this closure, since the
|
||||
// subscription resolving does not retroactively change the render that built
|
||||
// this callback. `?? ngSet` covers the no-scope case, where there is no
|
||||
// promise and the placeholder is the honest answer.
|
||||
// Snapshot the "is the wallet empty?" answer BEFORE awaiting. The await lets
|
||||
// the subscription re-sync, so afterwards the set holds whatever the broker
|
||||
// still has — including profiles a caller has just deleted. Deciding on the
|
||||
// post-await view means never seeding again.
|
||||
const userCount = usersShape.ngSet.size;
|
||||
const [loadedUsers, loadedParticipations] = await Promise.all([
|
||||
usersShape.loaded,
|
||||
participationsShape.loaded,
|
||||
]);
|
||||
console.log('[FestipodData] loadTestData (Refine)');
|
||||
// The emptiness answer comes from what this render has ALREADY read. The
|
||||
// previous version awaited an ORM subscription, which re-synced in the
|
||||
// meantime and reported back the very profiles a caller had just deleted — so
|
||||
// the seed declined and never ran again. There is no subscription left to
|
||||
// await: the Refine queries have already resolved by the time this is called.
|
||||
return bootstrapWallet({
|
||||
eventCount: eventRows.length,
|
||||
userCount,
|
||||
walletHasData: eventRows.length > 0 || profileRows.length > 0,
|
||||
seedEvent: createEventDoc,
|
||||
ngUsers: (loadedUsers ?? usersShape.ngSet) as any,
|
||||
ngParticipations: (loadedParticipations ?? participationsShape.ngSet) as any,
|
||||
seedProfile: createProfileDoc,
|
||||
});
|
||||
}, [eventRows.length, createEventDoc, usersShape, participationsShape]);
|
||||
}, [eventRows.length, profileRows.length, createEventDoc, createProfileDoc]);
|
||||
|
||||
return {
|
||||
currentUserId, currentUser,
|
||||
@@ -616,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[] = [
|
||||
|
||||
@@ -1,49 +1,128 @@
|
||||
/**
|
||||
* RefineProvider — mounts Refine over the NextGraph providers.
|
||||
* RefineProvider — mounts Refine over the SELECTED backend, and owns the selection.
|
||||
*
|
||||
* This is the app's new data entry point for the Events vertical: screens keep
|
||||
* calling `useFestipodData()`, and `FestipodDataContext` now reads and writes
|
||||
* events through Refine's hooks, which land on `@ng-org/refine-providers`.
|
||||
* This is the app's data entry point for the Events vertical: screens keep calling
|
||||
* `useFestipodData()`, and `FestipodDataContext` reads and writes events through
|
||||
* Refine's hooks, which land on whichever backend is current.
|
||||
*
|
||||
* SWITCHING AT RUNTIME is what shapes this file. The selection is React state, and
|
||||
* `<Refine>` carries the backend name as its `key`, so switching REMOUNTS it:
|
||||
* Refine builds its own query client inside, and remounting is what discards the
|
||||
* cache of the backend being left. Without that key the new backend would serve
|
||||
* its first reads out of the old one's cached rows.
|
||||
*
|
||||
* Leaving a backend loses access to its data — accepted, and nothing is migrated.
|
||||
* What is NOT solved here is the sign-in: `AuthGate` still gates on the NextGraph
|
||||
* session, so a second backend needs its own barrier before it can actually be
|
||||
* reached. That is the next piece, not this one.
|
||||
*
|
||||
* NO ROUTER PROVIDER, deliberately. Festipod has its own path-based router
|
||||
* (`app/router.tsx`); handing Refine a second one would give the app two actors
|
||||
* navigating the same history. Refine works without it — only its navigation
|
||||
* helpers (which this app does not call) go dormant.
|
||||
*
|
||||
* The providers are built ONCE at module scope, as the package's README does.
|
||||
* Rebuilding the data provider on a render would hand Refine a new provider
|
||||
* identity and re-run every query behind it.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Refine } from '@refinedev/core';
|
||||
import { dataProvider, liveProvider } from '@ng-org/refine-providers';
|
||||
import shapeTypes, { RESOURCE_EVENTS } from './shapeTypes';
|
||||
import { createNgAuthProvider } from './ngAuthProvider';
|
||||
import { getBackend, initialBackendName, resourceNames, type RefineBackend } from './providers';
|
||||
|
||||
const ngAuthProvider = createNgAuthProvider();
|
||||
const ngDataProvider = dataProvider({ shapeTypes, authProvider: ngAuthProvider });
|
||||
const ngLiveProvider = liveProvider({ shapeTypes });
|
||||
interface BackendSelection {
|
||||
/** `undefined` when no backend is registered — see providers/index.ts. */
|
||||
backend: RefineBackend | undefined;
|
||||
/** Switch backends. The data of the one being left becomes unreachable. */
|
||||
switchTo(name: string): void;
|
||||
}
|
||||
|
||||
const BackendContext = createContext<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.
|
||||
*/
|
||||
/**
|
||||
* The current backend, or `undefined` when none is registered. Callers that READ
|
||||
* or WRITE data must handle the undefined case — there is nothing to read a
|
||||
* connected wallet through, and pretending otherwise returns empty lists that
|
||||
* look like an empty wallet.
|
||||
*/
|
||||
export function useBackend(): RefineBackend | undefined {
|
||||
const selection = useContext(BackendContext);
|
||||
if (!selection) {
|
||||
throw new Error('useBackend must be used within a RefineProvider');
|
||||
}
|
||||
return selection.backend;
|
||||
}
|
||||
|
||||
/** The selection itself, for whatever eventually offers the choice. */
|
||||
export function useBackendSelection(): BackendSelection {
|
||||
const selection = useContext(BackendContext);
|
||||
if (!selection) {
|
||||
throw new Error('useBackendSelection must be used within a RefineProvider');
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
export function RefineProvider({ children }: { children: ReactNode }) {
|
||||
const [name, setName] = useState(initialBackendName);
|
||||
const backend = useMemo(() => (name ? getBackend(name) : undefined), [name]);
|
||||
|
||||
// Built once PER BACKEND, not per render: a fresh provider identity makes Refine
|
||||
// re-run every query behind it.
|
||||
const providers = useMemo(() => {
|
||||
if (!backend) {
|
||||
console.log('[refine] no backend registered — the app runs in demo mode');
|
||||
return undefined;
|
||||
}
|
||||
console.log(`[refine] backend: ${backend.name}`);
|
||||
return backend.build();
|
||||
}, [backend]);
|
||||
|
||||
const switchTo = useCallback((next: string) => {
|
||||
// Resolved here so an unknown name fails at the call, not on the next render.
|
||||
getBackend(next);
|
||||
setName(next);
|
||||
}, []);
|
||||
|
||||
const selection = useMemo<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 (
|
||||
<Refine
|
||||
dataProvider={ngDataProvider}
|
||||
liveProvider={ngLiveProvider}
|
||||
authProvider={ngAuthProvider}
|
||||
resources={[{ name: RESOURCE_EVENTS }]}
|
||||
options={{
|
||||
// `auto` is what makes the live provider actually subscribe behind every
|
||||
// query — without it the ORM pushes changes and no hook is listening.
|
||||
liveMode: 'auto',
|
||||
// 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 value={selection}>
|
||||
<Refine
|
||||
key={backend.name}
|
||||
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 ORM pushes changes and no hook is listening.
|
||||
liveMode: 'auto',
|
||||
// A write must be VISIBLE to the read that follows it. Before Refine the
|
||||
// app mutated the ORM object in place, so a screen — and a test — saw the
|
||||
// new value at once. Through a data provider the write goes out and the
|
||||
// list is re-read, and the re-read can still carry the old value: the
|
||||
// measured symptom was a participant count stuck on what the seed wrote,
|
||||
// with no error anywhere. `optimistic` puts the change into the cache
|
||||
// straight away and rolls it back if the write fails, which is the
|
||||
// behaviour that was lost — obtained from the framework instead of being
|
||||
// rebuilt by hand.
|
||||
mutationMode: 'optimistic',
|
||||
// Nothing about this app's usage is sent anywhere.
|
||||
disableTelemetry: true,
|
||||
// The app has no router, so Refine's unsaved-changes prompt (which hooks
|
||||
// navigation) has nothing to hook and would only warn.
|
||||
warnWhenUnsavedChanges: false,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Refine>
|
||||
</BackendContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* The backend registry — which Refine backends exist, and which one starts.
|
||||
*
|
||||
* IT IS EMPTY ON PURPOSE, and that is the current state of the work rather than
|
||||
* an oversight. Festipod must end up supporting SEVERAL Refine backends, and the
|
||||
* seam for that is what this directory is: the contract in `types.ts`, the
|
||||
* selection below, `RefineProvider`'s runtime switch. What is missing is backends.
|
||||
*
|
||||
* WHY THE FIRST ONE WAS WITHDRAWN. `@ng-org/refine-providers` was wired and
|
||||
* measured, and it works as plain CRUD over SHEX shapes. It was withdrawn because
|
||||
* it is not the target: what is coming is a Refine provider over NextGraph *plus
|
||||
* the polyfill*, and hardening this one meanwhile buys nothing.
|
||||
*
|
||||
* NOT because it was incapable — an earlier version of this comment said so, and
|
||||
* was wrong. It blamed the absence of an inbox for the participant count, having
|
||||
* misread the model: every Participation is readable by everyone, only the link
|
||||
* to a person is protected, so counting them is ordinary CRUD. See `types.ts`.
|
||||
* What the measurements did establish is narrower and still worth knowing: the
|
||||
* count must not be an incremented field, because each write would derive from a
|
||||
* read of the previous one and race itself.
|
||||
*
|
||||
* The worked implementation is in this branch's history (`providers/nextgraph.ts`,
|
||||
* up to the commit that withdrew it) and is the reference for the next one's shape.
|
||||
*
|
||||
* WHAT COMES NEXT, and why the seam stays: that NextGraph+polyfill provider, and
|
||||
* `@activitypods/refine-providers`. Festipod has to support both, so the structure
|
||||
* is kept and only the entry is gone.
|
||||
*
|
||||
* ADDING ONE is a file implementing `RefineBackend` plus a line here. What that
|
||||
* file must answer is in `types.ts` and cannot be guessed: how its providers are
|
||||
* configured, what its records look like, which key addresses a record versus
|
||||
* which id the app references, whether its `update` replaces or patches, and
|
||||
* whether it can derive the participant count.
|
||||
*
|
||||
* WITH NO BACKEND the app runs in demo mode: `FestipodDataProvider` has nothing
|
||||
* to read a connected wallet through, and says so rather than mounting a data
|
||||
* path that would return nothing.
|
||||
*/
|
||||
|
||||
import type { RefineBackend } from './types';
|
||||
|
||||
export type { RecordBinding, RecordBindings, RefineBackend } from './types';
|
||||
export { resourceNames } from './types';
|
||||
|
||||
const BACKENDS: Record<string, RefineBackend> = {};
|
||||
|
||||
// Build-injected, same plumbing as the other build-time globals (build.ts
|
||||
// `define`s it). Absent → the first registered backend, if there is one.
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __FESTIPOD_BACKEND__: string | undefined;
|
||||
}
|
||||
|
||||
/** Every registered backend — what a selector would offer. */
|
||||
export function listBackends(): RefineBackend[] {
|
||||
return Object.values(BACKENDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look a backend up by name. An unknown name throws rather than falling back:
|
||||
* running against a different store than the one asked for is worse than
|
||||
* failing, because nothing downstream would look wrong.
|
||||
*/
|
||||
export function getBackend(name: string): RefineBackend {
|
||||
const backend = BACKENDS[name];
|
||||
if (!backend) {
|
||||
const known = Object.keys(BACKENDS).join(', ') || '(none registered)';
|
||||
throw new Error(`Unknown Refine backend "${name}" — known: ${known}`);
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which backend is selected when the app starts, or `undefined` when none is
|
||||
* registered. A requested name that does not exist still throws — asking for a
|
||||
* specific backend and silently getting another is the failure worth avoiding.
|
||||
*/
|
||||
export function initialBackendName(): string | undefined {
|
||||
const requested = globalThis.__FESTIPOD_BACKEND__;
|
||||
if (requested) {
|
||||
getBackend(requested);
|
||||
return requested;
|
||||
}
|
||||
return Object.keys(BACKENDS)[0];
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* The contract a Refine BACKEND must satisfy to serve Festipod.
|
||||
*
|
||||
* Refine's own `DataProvider` / `AuthProvider` / `LiveProvider` already abstract
|
||||
* the transport, so they are not what this file adds. What they do NOT abstract
|
||||
* is everything around them, and each item below exists because the Events
|
||||
* vertical ran into it against `@ng-org/refine-providers` — none of it is
|
||||
* anticipated:
|
||||
*
|
||||
* - **Configuration.** `@ng-org` takes SHEX shape types; `@activitypods` takes
|
||||
* `resources: { events: { shapeTreeUri } }` plus a `jsonContext`. There is no
|
||||
* common shape, so building the providers belongs to the backend.
|
||||
* - **Record shape.** A record's fields are the backend's vocabulary. `@ng-org`
|
||||
* yields `fp:` predicates with `@id` / `@graph` / `@type`; ActivityPods yields
|
||||
* ActivityStreams JSON-LD. So domain mapping belongs to the backend too.
|
||||
* - **Two identifiers.** Refine addresses a record by one id, and the app's
|
||||
* entities reference each other by another. Under `@ng-org` those differ: the
|
||||
* address is the document NURI (`@graph`), while a participation points at the
|
||||
* subject IRI (`@id`). Collapsing them silently breaks the references.
|
||||
* - **Update semantics.** `@ng-org`'s `update` REPLACES the record, so a partial
|
||||
* write drops every field it omits. A backend that patches would not need the
|
||||
* read-merge the app does today, so the app has to be told which it is.
|
||||
*
|
||||
* A backend that cannot answer one of these cannot be added by guessing: the
|
||||
* answer is a fact about that backend, to be read from its contract or measured.
|
||||
*/
|
||||
|
||||
import type { AuthProvider, BaseKey, DataProvider, LiveProvider } from '@refinedev/core';
|
||||
import type {
|
||||
FpEventData,
|
||||
FpMeetingPointData,
|
||||
FpParticipationData,
|
||||
FpUserData,
|
||||
} from '../../data/types';
|
||||
|
||||
/** How one entity's records translate to and from the app's domain type. */
|
||||
export interface RecordBinding<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.
|
||||
*
|
||||
* `meetingPoint` is OPTIONAL only because its SHEX shape does not exist on this
|
||||
* branch yet — adding it means regenerating the ORM bindings, which is its own
|
||||
* step with its own failure mode (the generator drops the `Fp` prefix, since the
|
||||
* emitted name follows the shape IRI). Once that lands it becomes required like
|
||||
* the others. `Friendship` is deliberately absent: it has no shape and no
|
||||
* persistence anywhere in this project.
|
||||
*/
|
||||
export interface RecordBindings {
|
||||
event: RecordBinding<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 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,25 +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. Every `useList` / `useOne` / `useCreate` / `useUpdate` call site must
|
||||
* therefore name a resource declared below.
|
||||
*
|
||||
* Only `events` is wired: the Events vertical is what goes through Refine —
|
||||
* profiles and participations still read through `useShapeWithDefaults`.
|
||||
*/
|
||||
|
||||
import type { ShapeTypes } from '@ng-org/refine-providers';
|
||||
import { FpEventShapeType } from '../shapes/orm/festipodShapes.shapeTypes';
|
||||
|
||||
/** Refine resource name for Festipod events. Import it — never retype the string. */
|
||||
export const RESOURCE_EVENTS = 'events';
|
||||
|
||||
const shapeTypes: ShapeTypes = {
|
||||
[RESOURCE_EVENTS]: FpEventShapeType,
|
||||
};
|
||||
|
||||
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)" ;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Before, After, BeforeAll, AfterAll, Status, setDefaultTimeout } from '@cucumber/cucumber';
|
||||
import { chromium, type Browser, type BrowserContext, type Page, type Frame } from 'playwright';
|
||||
import { execSync, spawn, type ChildProcess } from 'child_process';
|
||||
import { execSync, spawn, spawnSync, type ChildProcess } from 'child_process';
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
@@ -37,6 +37,84 @@ const HARNESS_NG_OUT = path.join('dist', 'test-harness-ng.js');
|
||||
// Persistent Chromium profile for NG wallet (not the user's daily browser)
|
||||
const PLAYWRIGHT_PROFILE = path.resolve('.playwright-profile');
|
||||
|
||||
/**
|
||||
* Rotate the profile once it exceeds this many MB. Override with
|
||||
* FESTIPOD_PROFILE_BLOAT_MB; 0 disables the rotation entirely.
|
||||
*/
|
||||
const BLOAT_THRESHOLD_MB = Number(process.env.FESTIPOD_PROFILE_BLOAT_MB ?? 50);
|
||||
|
||||
function dirSizeMB(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
try {
|
||||
const result = spawnSync('du', ['-sm', dir], { encoding: 'utf-8' });
|
||||
const line = result.stdout.trim().split('\n')[0] ?? '';
|
||||
return parseInt(line.split('\t')[0] ?? '0', 10);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any stale Chromium singleton files from the profile. Chromium refuses to
|
||||
* launch (ProcessSingleton error) if SingletonLock, SingletonCookie or
|
||||
* SingletonSocket are left over from a previous crashed run. Idempotent.
|
||||
*/
|
||||
function cleanSingletons(profilePath: string): void {
|
||||
if (!fs.existsSync(profilePath)) return;
|
||||
for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
|
||||
const f = path.join(profilePath, name);
|
||||
if (!fs.existsSync(f)) continue;
|
||||
try {
|
||||
fs.rmSync(f, { force: true });
|
||||
console.log(`[rotate] removed stale ${name}.`);
|
||||
} catch {
|
||||
// Non-fatal: if it cannot be removed, the launch fails with a clear error.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ROTATE THE TEST PROFILE when it has grown past the threshold.
|
||||
*
|
||||
* WHY THIS RUNS UNATTENDED. The @data suite writes into one wallet that nothing
|
||||
* empties, so every run leaves more behind. Past some volume the broker stops
|
||||
* answering in time and scenarios die on their step timeout — SILENTLY, nothing
|
||||
* in the console. That failure looks exactly like a bug in whatever was last
|
||||
* edited, and gets diagnosed as one: it happened twice in a single session before
|
||||
* anybody measured the profile. A guard nobody has to remember is the point.
|
||||
*
|
||||
* On THIS branch the rotation resets the DATA, not just the browser state:
|
||||
* nothing here serves a shared wallet file (`ngSession` configures none, and the
|
||||
* `.ngw` is used only by the @e2e staging build), so the @data wallet is the
|
||||
* broker-side one reached through this profile's storage. Removing the profile
|
||||
* takes the `.wallet-ready` marker with it and `ensureAuth` creates a fresh
|
||||
* wallet. The sibling branch documents the opposite, correctly for itself —
|
||||
* there the data lives in the served file and survives this.
|
||||
*
|
||||
* NOT free, and not quiet about it: the new wallet is created by driving the real
|
||||
* broker's sign-up, so each rotation leaves an abandoned wallet behind on it.
|
||||
* That is the price of a measurable suite; `FESTIPOD_PROFILE_BLOAT_MB=0` opts out.
|
||||
*/
|
||||
function rotateProfileIfBloated(): void {
|
||||
if (BLOAT_THRESHOLD_MB <= 0) {
|
||||
console.log('[rotate] disabled (FESTIPOD_PROFILE_BLOAT_MB=0)');
|
||||
return;
|
||||
}
|
||||
const sizeMB = dirSizeMB(PLAYWRIGHT_PROFILE);
|
||||
if (sizeMB > BLOAT_THRESHOLD_MB) {
|
||||
console.log(`[rotate] profile ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating (a NEW broker wallet will be created)`);
|
||||
try {
|
||||
fs.rmSync(PLAYWRIGHT_PROFILE, { recursive: true, force: true });
|
||||
console.log('[rotate] profile removed — it will be recreated, with empty data.');
|
||||
} catch (e) {
|
||||
console.warn(`[rotate] failed to remove the profile: ${e}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
cleanSingletons(PLAYWRIGHT_PROFILE);
|
||||
console.log(`[rotate] profile ${sizeMB}MB — below threshold, keeping it.`);
|
||||
}
|
||||
|
||||
let harnessServer: http.Server | null = null;
|
||||
let harnessPort = 0;
|
||||
let useRealBroker = false;
|
||||
@@ -345,6 +423,13 @@ BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
|
||||
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle --format=iife`, { stdio: 'pipe' });
|
||||
console.log(`[Harness] Built NG (${(fs.statSync(HARNESS_NG_OUT).size / 1024).toFixed(0)} KB)`);
|
||||
|
||||
// BEFORE touching the wallet: rotate the profile if it has bloated past the
|
||||
// point where the broker stops answering in time. Here and not earlier in
|
||||
// BeforeAll, because this is the branch that actually uses the profile — the
|
||||
// mock fallback below never opens it, and rotating it would cost a broker
|
||||
// sign-up for nothing.
|
||||
rotateProfileIfBloated();
|
||||
|
||||
// Ensure wallet exists in persistent profile (opens browser if needed)
|
||||
await ensureAuth();
|
||||
useRealBroker = true;
|
||||
|
||||
@@ -7,20 +7,26 @@
|
||||
*
|
||||
* Exposes window.__testData for Playwright-driven Cucumber steps.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 { useShapeSet } from '../hooks/useShapeSet';
|
||||
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';
|
||||
|
||||
@@ -45,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 />;
|
||||
@@ -71,6 +89,12 @@ function ConnectedHarness() {
|
||||
const users = useShapeSet(FpUserProfileShapeType, privateNuri);
|
||||
const participations = useShapeSet(FpParticipationShapeType, privateNuri);
|
||||
|
||||
// The bridge getters must read the LIVE appData. Captured once, a snapshot goes
|
||||
// stale after a seed or a reactive re-render: the step reads 0 while the screens
|
||||
// already show the data. A ref updated every render is what keeps them honest.
|
||||
const appDataRef = useRef(appData);
|
||||
appDataRef.current = appData;
|
||||
|
||||
const [bridgeReady, setBridgeReady] = useState(false);
|
||||
// Stopgap multi-store validation: a doc created on demand via doc_create,
|
||||
// mounted into a real useShape({graphs}) by <SmokeProbe>.
|
||||
@@ -88,14 +112,49 @@ function ConnectedHarness() {
|
||||
// profile in the set is the fallback while the context is still resolving.
|
||||
const currentUserId = appData.currentUserId || [...users][0]?.['@id'] || '';
|
||||
|
||||
// Always read the LIVE appData through the ref — see appDataRef above.
|
||||
const AD = () => appDataRef.current;
|
||||
const eventAdapter = () =>
|
||||
AD().events.map((e: FpEventData) => ({ '@id': e.id, title: e.title, participantCount: e.participantCount }));
|
||||
const userAdapter = () =>
|
||||
AD().users.map((u: FpUserData) => ({ '@id': u.id, username: u.username, name: u.name }));
|
||||
const partAdapter = () =>
|
||||
AD().participations.map((p: FpParticipationData) => ({
|
||||
'@id': p.id, event: p.eventId, user: p.userId, isConfirmed: p.isConfirmed,
|
||||
}));
|
||||
/**
|
||||
* A read-only Set-like over an app-backed array snapshot: supports
|
||||
* `[...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; },
|
||||
[Symbol.iterator]() { return snapshot()[Symbol.iterator](); },
|
||||
delete(_item: T) { /* app-backed: seeded records aren't deletable here */ },
|
||||
});
|
||||
|
||||
// Expose the test bridge
|
||||
(window as any).__testData = {
|
||||
ready: true,
|
||||
|
||||
// --- Raw DeepSignalSets (backward compatible with existing tests) ---
|
||||
events,
|
||||
users,
|
||||
participations,
|
||||
// --- App-backed entity views (the path the screens actually use) -----
|
||||
// These used to be the raw ORM sets. They cannot be, now that the app
|
||||
// reads every entity through Refine: the set and the app's list no longer
|
||||
// hold the same thing, and a step that observes one while the app decides
|
||||
// on the other races itself — an explicit "load test data" seeded a second
|
||||
// time because the set still read empty. The step contract
|
||||
// (`[...td.events]` with `@id`/`title`/`participantCount`, `.size`,
|
||||
// `p.user`/`p.event`) is preserved by mapping the app types to that shape.
|
||||
get events() { return setLike(eventAdapter); },
|
||||
get users() { return setLike(userAdapter); },
|
||||
get participations() { return setLike(partAdapter); },
|
||||
currentUserId,
|
||||
session,
|
||||
|
||||
@@ -103,51 +162,32 @@ function ConnectedHarness() {
|
||||
appData,
|
||||
ngStatus: ngCtx.status,
|
||||
|
||||
// --- Query helpers ---
|
||||
// --- Query helpers (app-backed) ---
|
||||
getEvent(id: string) {
|
||||
return [...events].find(e => e['@id'] === id);
|
||||
return eventAdapter().find((e) => e['@id'] === id);
|
||||
},
|
||||
getEventByTitle(title: string) {
|
||||
return [...events].find(e => e.title === title);
|
||||
return eventAdapter().find((e) => e.title === title);
|
||||
},
|
||||
isParticipating(eventId: string, userId: string) {
|
||||
return [...participations].some(p => p.event === eventId && p.user === userId);
|
||||
return AD().isParticipating(eventId, userId);
|
||||
},
|
||||
getEventParticipants(eventId: string) {
|
||||
return [...participations].filter(p => p.event === eventId);
|
||||
return partAdapter().filter((p) => p.event === eventId);
|
||||
},
|
||||
|
||||
// --- Mutations (direct ngSet access) ---
|
||||
// --- Mutations (the APP path, not a bridge shortcut) -----------------
|
||||
// Delegated to the data context so a @data scenario faces the same writes
|
||||
// the screens drive. A bridge that mutated an ORM set directly would be
|
||||
// testing the bridge.
|
||||
async joinEvent(eventId: string, userId: string) {
|
||||
const already = [...participations].some(p => p.event === eventId && p.user === userId);
|
||||
if (already) return;
|
||||
const graph = await ensureGraphNuri(events as any, users as any, participations as any);
|
||||
participations.add({
|
||||
'@graph': graph,
|
||||
'@type': 'http://festipod.org/Participation',
|
||||
'@id': '',
|
||||
event: eventId,
|
||||
user: userId,
|
||||
isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
const ev = [...events].find(e => e['@id'] === eventId);
|
||||
if (ev) ev.participantCount = ev.participantCount + 1;
|
||||
await AD().joinEvent(eventId, userId);
|
||||
},
|
||||
leaveEvent(eventId: string, userId: string) {
|
||||
const part = [...participations].find(p => p.event === eventId && p.user === userId);
|
||||
if (!part) return;
|
||||
participations.delete(part);
|
||||
const ev = [...events].find(e => e['@id'] === eventId);
|
||||
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
|
||||
async leaveEvent(eventId: string, userId: string) {
|
||||
await AD().leaveEvent(eventId, userId);
|
||||
},
|
||||
updateEvent(eventId: string, updates: Record<string, any>) {
|
||||
const ev = [...events].find(e => e['@id'] === eventId);
|
||||
if (!ev) return;
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (key !== '@id' && key !== '@graph' && key !== '@type') {
|
||||
(ev as any)[key] = value;
|
||||
}
|
||||
}
|
||||
async updateEvent(eventId: string, updates: Record<string, any>) {
|
||||
await AD().updateEvent(eventId, updates);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -264,13 +304,25 @@ function FanoutProbe({ graphs }: { graphs: string[] }) {
|
||||
// ============================================================================
|
||||
|
||||
function SmokeProbe({ docNuri }: { docNuri: string }) {
|
||||
const set = useShapeSet(FpParticipationShapeType, { graphs: [docNuri] });
|
||||
// The SET and its READINESS. This probe WRITES, and since @ng-org/orm alpha.21
|
||||
// the set does not exist until the subscription resolves — so `ready` is not
|
||||
// decoration: `add()` before it refuses, loudly, rather than dropping the write.
|
||||
// `__smoke.ready` is what the step waits on, so it must mean "writable", not
|
||||
// merely "mounted".
|
||||
const { set, ready, promise } = useShapeSubscription(FpParticipationShapeType, { graphs: [docNuri] });
|
||||
// Read through a ref, for the same reason the bridge does: the effect's closure
|
||||
// would otherwise hand the step the write-refusing placeholder captured on the
|
||||
// first render, and `count()` would keep reporting that empty set.
|
||||
const subRef = useRef({ set, promise });
|
||||
subRef.current = { set, promise };
|
||||
useEffect(() => {
|
||||
(window as any).__smoke = {
|
||||
ready: true,
|
||||
ready,
|
||||
docNuri,
|
||||
add() {
|
||||
set.add({
|
||||
/** Awaits the subscription, then writes — the step may call it either side of `ready`. */
|
||||
async add() {
|
||||
await subRef.current.promise;
|
||||
subRef.current.set.add({
|
||||
'@graph': docNuri,
|
||||
'@type': 'http://festipod.org/Participation',
|
||||
'@id': '',
|
||||
@@ -279,12 +331,12 @@ function SmokeProbe({ docNuri }: { docNuri: string }) {
|
||||
isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
},
|
||||
count() { return set.size; },
|
||||
count() { return subRef.current.set.size; },
|
||||
items() {
|
||||
return [...set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
|
||||
return [...subRef.current.set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
|
||||
},
|
||||
};
|
||||
}, [set, docNuri]);
|
||||
}, [docNuri, ready]);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 +14,12 @@
|
||||
* module runs outside the component tree.
|
||||
*/
|
||||
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
import type { FpEventData } from '../data/types';
|
||||
import { ensureGraphNuri } from './ngGraph';
|
||||
import {
|
||||
seedEvents,
|
||||
seedUsers,
|
||||
seedParticipations,
|
||||
} from '../data/seedData';
|
||||
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';
|
||||
|
||||
export interface BootstrapResult {
|
||||
seeded: boolean;
|
||||
@@ -30,114 +27,78 @@ export interface BootstrapResult {
|
||||
eventIdMap: Map<string, string>;
|
||||
}
|
||||
|
||||
/** Writes ONE event as its own document and returns the stored row (or nothing). */
|
||||
export type EventSeeder = (event: Omit<FpEventData, 'id'>) => Promise<FpEvent | undefined>;
|
||||
/** Writes ONE entity and returns the stored record (or nothing). */
|
||||
export type Seeder<T, R> = (entity: T) => Promise<R | undefined>;
|
||||
|
||||
export interface BootstrapDeps {
|
||||
/**
|
||||
* How many events the Refine-backed read already sees. This is the
|
||||
* "returning user" check that `ngEvents.size` used to answer — the caller owns
|
||||
* it now, because the events no longer come from a set this module can count.
|
||||
* Whether the wallet already carries entities — a returning user, nothing to
|
||||
* seed. The CALLER computes it, from what it has already read: this module
|
||||
* cannot, now that nothing here holds a live set. It must also be computed
|
||||
* BEFORE any await, or a subscription that re-syncs in the meantime reports
|
||||
* data the caller has just deleted and the seed never runs again.
|
||||
*/
|
||||
eventCount: number;
|
||||
/**
|
||||
* How many profiles the caller saw WHEN IT DECIDED TO SEED — not when the
|
||||
* write finally starts. The two differ now that seeding awaits the ORM
|
||||
* subscription: a caller that just emptied the wallet hands over 0, while the
|
||||
* set it awaits has meanwhile re-synced whatever the broker still holds.
|
||||
* Reading `ngUsers.size` here would consult the second and never seed again.
|
||||
*/
|
||||
userCount: number;
|
||||
seedEvent: EventSeeder;
|
||||
ngUsers: DeepSignalSet<FpUserProfile>;
|
||||
ngParticipations: DeepSignalSet<FpParticipation>;
|
||||
walletHasData: boolean;
|
||||
seedEvent: Seeder<Omit<FpEventData, 'id'>, FpEvent>;
|
||||
seedProfile: Seeder<Omit<FpUserData, 'id'>, FpUserProfile>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush ORM microtask batch and give the broker time to process.
|
||||
* Flush and give the broker time to settle between two document creations.
|
||||
*
|
||||
* The ORM batches signal mutations into microtasks. A `Promise.resolve()`
|
||||
* flushes the pending batch to the NG engine. The short delay lets the
|
||||
* broker create the new repo/document before the next add.
|
||||
* The seed is LINEAR IN DOCUMENTS — one document per entity, each a serial round
|
||||
* trip — so it writes only what is needed, and the delay between writes is not
|
||||
* padding: each creation opens a repo.
|
||||
*/
|
||||
async function flushAndWait(ms = 100): Promise<void> {
|
||||
await Promise.resolve(); // flush ORM microtask batch
|
||||
await new Promise(r => setTimeout(r, ms)); // let broker process
|
||||
await Promise.resolve();
|
||||
await new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export async function bootstrapWallet(
|
||||
{ eventCount, userCount, seedEvent, ngUsers, ngParticipations }: BootstrapDeps,
|
||||
{ walletHasData, seedEvent, seedProfile }: BootstrapDeps,
|
||||
): Promise<BootstrapResult> {
|
||||
// Already has data → returning user, nothing to seed. Both counts come from
|
||||
// the CALLER's view at decision time — see `userCount`.
|
||||
if (eventCount > 0 || userCount > 0) {
|
||||
console.log('[Bootstrap] Wallet already has data — events:', eventCount,
|
||||
'users:', userCount, 'participations:', ngParticipations.size);
|
||||
if (walletHasData) {
|
||||
console.log('[Bootstrap] Wallet already has data — skipping seed');
|
||||
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
|
||||
}
|
||||
|
||||
console.log('[Bootstrap] First time for this wallet — seeding default data...');
|
||||
console.log('[Bootstrap] First time for this wallet — seeding per-entity records...');
|
||||
|
||||
// Create (or get) a document in the private store for users + participations.
|
||||
// The ORM requires a real document NURI as @graph, not the store ID. Events
|
||||
// are NOT placed here: each one gets its own document, created by `seedEvent`.
|
||||
const graph = await ensureGraphNuri(ngUsers, ngParticipations);
|
||||
console.log('[Bootstrap] Using graph NURI:', graph);
|
||||
|
||||
// Seed users — one at a time with ORM flush between each.
|
||||
//
|
||||
// ONLY A FEW, not all fourteen. The connected seed is LINEAR in the number of
|
||||
// documents (one document per entity, each a serial round trip), so it writes
|
||||
// only what is needed. Nothing in the @data layer looks a seeded profile up by
|
||||
// name; what it needs is that SOME profile exists — one to answer
|
||||
// `currentUserId`, and a non-empty set for "the wallet contains users". The
|
||||
// first three are kept rather than one, so a participants list has somebody to
|
||||
// show, and `@mariedupont` is among them because `FestipodDataContext` falls
|
||||
// back to her when the account layer has not resolved.
|
||||
// ONLY A FEW PROFILES, not all fourteen. Nothing in the @data layer looks a
|
||||
// seeded profile up by name; what it needs is that SOME profile exists — one to
|
||||
// answer `currentUserId`, and a non-empty set for "the wallet contains users".
|
||||
// Three are kept rather than one so a participants list has somebody to show,
|
||||
// and `@mariedupont` is among them because the data context falls back to her
|
||||
// when the account layer has not resolved.
|
||||
const SEEDED_PROFILES = 3;
|
||||
const userIdMap = new Map<string, string>();
|
||||
for (const u of seedUsers.slice(0, SEEDED_PROFILES)) {
|
||||
ngUsers.add({
|
||||
"@graph": graph,
|
||||
"@type": "http://festipod.org/UserProfile",
|
||||
"@id": "",
|
||||
name: u.name,
|
||||
initials: u.initials,
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
isPublic: u.isPublic,
|
||||
} as FpUserProfile);
|
||||
const stored = await seedProfile(u);
|
||||
await flushAndWait();
|
||||
const added = [...ngUsers].find(nu => nu.username === u.username);
|
||||
if (added) userIdMap.set(u.id, added["@id"]);
|
||||
const iri = stored?.['@id'];
|
||||
if (iri) userIdMap.set(u.id, iri);
|
||||
}
|
||||
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
|
||||
console.log('[Bootstrap] Seeded', userIdMap.size, 'profiles');
|
||||
|
||||
// Seed events — one at a time, each into its OWN document via Refine.
|
||||
// The map is keyed on the seed id and holds the SUBJECT IRI, because that is
|
||||
// what the participations below reference (`fp:event`).
|
||||
// All events — the scenarios look them up by title.
|
||||
const eventIdMap = new Map<string, string>();
|
||||
for (const e of seedEvents) {
|
||||
const stored = await seedEvent(e);
|
||||
await flushAndWait();
|
||||
const iri = stored?.["@id"];
|
||||
const iri = stored?.['@id'];
|
||||
if (iri) {
|
||||
eventIdMap.set(e.id, iri);
|
||||
} else {
|
||||
// Loud, not silent: without a subject IRI the participations that point at
|
||||
// this event would be written against the SEED id, which nothing resolves.
|
||||
console.warn('[Bootstrap] Event seeded without a subject IRI — participations '
|
||||
+ 'referencing it will not resolve:', e.title);
|
||||
console.warn('[Bootstrap] Event seeded without a subject IRI:', e.title);
|
||||
}
|
||||
}
|
||||
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events');
|
||||
|
||||
// NO PARTICIPATIONS. The sign-up scenarios create theirs live, which is the
|
||||
// behaviour they exist to exercise, and nothing asserts on a seeded one: a
|
||||
// scenario's starting participant count is SET on the event
|
||||
// (`updateEvent({ participantCount })`) and read back from it, never counted
|
||||
// from Participation documents. Writing five here would cost five more serial
|
||||
// round trips to furnish something no test looks at.
|
||||
// scenario's starting participant count is SET on the event and read back from
|
||||
// it, never counted from Participation records.
|
||||
|
||||
return { seeded: true, userIdMap, eventIdMap };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user