Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 412e5d63e7 | |||
| d48297fe94 | |||
| a5f6e01562 | |||
| 33b4588da4 | |||
| 0c4f6b6650 | |||
| b25bafbb90 | |||
| 3de86c8be2 | |||
| 4b4dfa6cad | |||
| 974d251079 | |||
| fd02570b8b | |||
| efdfcac4c1 | |||
| 750538bd73 | |||
| 20b3ef8712 | |||
| ff285455d2 | |||
| 35df3e9110 | |||
| db483726af | |||
| 46abbf2e64 | |||
| 753282c7b1 | |||
| d237753916 | |||
| 33d54c6e3e |
@@ -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
|
||||
@@ -142,6 +142,11 @@ const result = await Bun.build({
|
||||
"globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__": JSON.stringify(
|
||||
process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
|
||||
),
|
||||
// The app may bootstrap demo data into an empty connected wallet only when
|
||||
// FESTIPOD_AUTO_SEED is set (see src/shared/utils/autoSeed.ts). Absent → OFF.
|
||||
"globalThis.__FESTIPOD_AUTO_SEED__": JSON.stringify(
|
||||
process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
),
|
||||
},
|
||||
...cliConfig,
|
||||
});
|
||||
|
||||
+22
-15
@@ -8,26 +8,27 @@
|
||||
"start": "NODE_ENV=production bun src/index.ts",
|
||||
"build": "bun run build.ts",
|
||||
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json",
|
||||
"test:data": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json --tags @data",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
|
||||
"test:data": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json --tags @data",
|
||||
"test:auth-setup": "bun scripts/setup-test-auth.ts",
|
||||
"cucumber:report": "bun scripts/parse-test-results.ts",
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts",
|
||||
"build:orm": "rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm",
|
||||
"build:orm": "rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm",
|
||||
"build:ng": "bash scripts/build-ng-packages.sh",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
||||
"@ng-org/orm": "0.1.2-alpha.18",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.8",
|
||||
"@ng-org/web": "0.1.2-alpha.13",
|
||||
"@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",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@refinedev/core": "^5.0.12",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -40,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",
|
||||
@@ -47,14 +53,15 @@
|
||||
"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": [
|
||||
"bun"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
|
||||
}
|
||||
|
||||
Generated
+6145
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* overlay-local-checkout.ts — Reactive local overlay of a data-layer PROVIDER's checkout
|
||||
* into node_modules. One script, one provider per run; only the paths differ between them.
|
||||
*
|
||||
* PROVIDERS (first non-flag argument; defaults to `polyfill`):
|
||||
* polyfill → node_modules/@ng-eventually/polyfill override: NG_EVENTUALLY_LOCAL
|
||||
* indexing → node_modules/@ng-helpers/indexing override: NG_HELPERS_LOCAL
|
||||
*
|
||||
* WHY a copy-overlay and not a symlink (identical for every provider):
|
||||
* A committed prod dependency installs from Gitea (git+https) into pnpm's store WITHOUT
|
||||
* its own node_modules/@ng-org, so @ng-org/web resolves UP to Festipod → ONE @ng-org
|
||||
* instance (one verifier). A local CHECKOUT, however, carries its own node_modules/*
|
||||
* (links into that provider's own dev tree). Symlinking node_modules/<pkg> to the
|
||||
* checkout would put the checkout's copies in the resolution path → a SECOND @ng-org
|
||||
* (and, for `indexing`, a second @ng-eventually/polyfill) → broken SDK, two verifiers.
|
||||
* So we overlay a real directory containing ONLY the provider's source (no node_modules):
|
||||
* shared packages still resolve up to Festipod, single instance preserved.
|
||||
*
|
||||
* WHAT IT DOES:
|
||||
* 1. Replaces node_modules/<package> (the pnpm store symlink) with a real directory
|
||||
* holding the local checkout's package.json + src (NO node_modules).
|
||||
* 2. Asserts the single-instance invariant — every package this provider SHARES with
|
||||
* Festipod must resolve to the same realpath from Festipod and from the overlay —
|
||||
* and aborts if it would break.
|
||||
* 3. Watches the local checkout's src and copies each change into the overlay.
|
||||
*
|
||||
* ⚠️ RESTART `bun run dev` AFTER THIS SCRIPT WRITES — a rebuild is NOT a substitute.
|
||||
* A running dev server NEVER picks up a package refreshed inside node_modules, not even
|
||||
* across a genuine rebuild: VERIFIED in a controlled test, an application-source edit
|
||||
* produced a new bundle hash and the rebuilt bundle STILL carried the stale dependency.
|
||||
* The server's resolution of that import is pinned at process start and a rebuild does not
|
||||
* re-resolve it. Only restarting serves the fresh copy, and nothing warns you — a stale
|
||||
* server looks exactly like a current one. See
|
||||
* .project/concepts/tech-stack/caveat_polyfill-overlay-needs-a-dev-restart.md, which cost
|
||||
* an hour to learn. Watching copies the files; it does not make anything reload them.
|
||||
*
|
||||
* USAGE (reactive dev):
|
||||
* Terminal 1: pnpm run overlay:polyfill # or: pnpm run overlay:indexing
|
||||
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
|
||||
* Edit the checkout's src → it lands in node_modules → RESTART dev to pick it up.
|
||||
*
|
||||
* pnpm run overlay:indexing --once # overlay + verify, no watch (CI / one-shot)
|
||||
* Return to the committed git-installed dependencies: pnpm install
|
||||
*/
|
||||
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
|
||||
import { watch } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
interface Provider {
|
||||
/** Package as installed, e.g. "@ng-eventually/polyfill" — also its node_modules path. */
|
||||
readonly packageName: string;
|
||||
/** Local checkout used when the env override is unset. */
|
||||
readonly defaultLocal: string;
|
||||
/** Env var overriding the local checkout path. */
|
||||
readonly envOverride: string;
|
||||
/**
|
||||
* Packages this provider SHARES with Festipod and that must stay single-instance.
|
||||
* Each is resolved from Festipod and from the overlay; the realpaths must match.
|
||||
*/
|
||||
readonly singletons: readonly string[];
|
||||
}
|
||||
|
||||
const PROVIDERS: Record<string, Provider> = {
|
||||
polyfill: {
|
||||
packageName: "@ng-eventually/polyfill",
|
||||
defaultLocal: "/home/sylvain/projects/nextgraph/ng-eventually-js/packages/polyfill",
|
||||
envOverride: "NG_EVENTUALLY_LOCAL",
|
||||
singletons: ["@ng-org/web"],
|
||||
},
|
||||
indexing: {
|
||||
packageName: "@ng-helpers/indexing",
|
||||
defaultLocal: "/home/sylvain/projects/nextgraph/ng-helpers",
|
||||
envOverride: "NG_HELPERS_LOCAL",
|
||||
// Consumes the polyfill, so BOTH it and the verifier underneath must stay single.
|
||||
singletons: ["@ng-eventually/polyfill", "@ng-org/web"],
|
||||
},
|
||||
};
|
||||
|
||||
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
|
||||
const args = process.argv.slice(2);
|
||||
const ONCE = args.includes("--once");
|
||||
const KEY = args.find((a) => !a.startsWith("-")) ?? "polyfill";
|
||||
|
||||
function fail(msg: string): never {
|
||||
console.error(`✖ overlay:${KEY} — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = PROVIDERS[KEY];
|
||||
if (!provider) {
|
||||
fail(`unknown provider "${KEY}" — expected one of: ${Object.keys(PROVIDERS).join(", ")}`);
|
||||
}
|
||||
|
||||
const LOCAL = process.env[provider.envOverride] ?? provider.defaultLocal;
|
||||
const TARGET = join(FESTIPOD, "node_modules", ...provider.packageName.split("/"));
|
||||
const SRC_LOCAL = join(LOCAL, "src");
|
||||
const SRC_TARGET = join(TARGET, "src");
|
||||
|
||||
if (!existsSync(join(LOCAL, "package.json"))) {
|
||||
fail(`local checkout not found at ${LOCAL} (set ${provider.envOverride} to override)`);
|
||||
}
|
||||
if (!existsSync(SRC_LOCAL)) {
|
||||
fail(`local checkout has no src/ at ${SRC_LOCAL}`);
|
||||
}
|
||||
|
||||
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
|
||||
console.log(`→ overlaying local ${provider.packageName}: ${LOCAL}`);
|
||||
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
|
||||
rmSync(TARGET, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TARGET, { recursive: true });
|
||||
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
|
||||
const from = join(LOCAL, meta);
|
||||
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
|
||||
}
|
||||
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single instances).
|
||||
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
|
||||
|
||||
// 2. Assert the single-instance invariant for every package shared with Festipod.
|
||||
const overlayReal = realpathSync(TARGET);
|
||||
for (const spec of provider.singletons) {
|
||||
const fromFestipod = realpathSync(Bun.resolveSync(spec, FESTIPOD));
|
||||
const fromOverlay = realpathSync(Bun.resolveSync(spec, overlayReal));
|
||||
console.log(` ${spec} (Festipod): ${fromFestipod}`);
|
||||
console.log(` ${spec} (overlay) : ${fromOverlay}`);
|
||||
if (fromFestipod !== fromOverlay) {
|
||||
fail(
|
||||
`single-instance invariant BROKEN — ${spec} resolves to two different realpaths.\n` +
|
||||
" The overlay must not contain its own node_modules. Aborting.",
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(`✓ single instance preserved for: ${provider.singletons.join(", ")}`);
|
||||
|
||||
if (ONCE) {
|
||||
console.log("✓ overlay ready (--once, not watching)");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Watch and copy on change. This keeps the overlay CURRENT; it does NOT make a running
|
||||
// dev server notice — not even across a rebuild (see the header). Restart it.
|
||||
console.log(`👀 watching ${SRC_LOCAL} → ${SRC_TARGET} (Ctrl-C to stop)`);
|
||||
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
const from = join(SRC_LOCAL, filename);
|
||||
const to = join(SRC_TARGET, filename);
|
||||
try {
|
||||
if (existsSync(from)) {
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
console.log(` ↻ ${filename}`);
|
||||
} else if (existsSync(to)) {
|
||||
rmSync(to, { force: true });
|
||||
console.log(` ✗ ${filename} (removed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` ! failed to sync ${filename}:`, err);
|
||||
}
|
||||
});
|
||||
+15
-10
@@ -2,6 +2,7 @@ import { RouterProvider, useRouter } from './router';
|
||||
import { ThemeProvider } from '../shared/context/ThemeContext';
|
||||
import { NextGraphProvider } from '../shared/context/NextGraphContext';
|
||||
import { AccountProvider } from '../shared/context/AccountContext';
|
||||
import { RefineProvider } from '../shared/refine/RefineProvider';
|
||||
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { ToastContainer } from '../shared/components/sketchy';
|
||||
@@ -58,16 +59,20 @@ export function App() {
|
||||
<ThemeProvider>
|
||||
<NextGraphProvider>
|
||||
<AccountProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
{/* Refine wraps the data context: FestipodDataContext reads and writes
|
||||
events through Refine's hooks, so it must sit inside <Refine>. */}
|
||||
<RefineProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</RefineProvider>
|
||||
</AccountProvider>
|
||||
</NextGraphProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -8,10 +8,13 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
// --- Setup ---
|
||||
|
||||
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// Ensure wallet has data (seed if empty)
|
||||
await this.appFrame!.evaluate(() => {
|
||||
// Ensure wallet has data (seed if empty). AWAITED, and given a budget that
|
||||
// matches what seeding costs once every entity is its own document: the seed
|
||||
// opens five of them, and opening a document takes seconds. The previous 10 s
|
||||
// was calibrated when seeding meant adding rows to an already-open graph.
|
||||
await this.appFrame!.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
if (td.events.size === 0) td.loadTestData();
|
||||
if (td.events.size === 0) await td.loadTestData();
|
||||
});
|
||||
// Wait for event to appear
|
||||
await this.appFrame!.waitForFunction(
|
||||
@@ -20,7 +23,7 @@ Given('un événement {string} existe', async function (this: FestipodWorld, eve
|
||||
return [...td.events].some((e: any) => e.title === title);
|
||||
},
|
||||
eventTitle,
|
||||
{ timeout: 10000 },
|
||||
{ timeout: 75000 },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
seedMeetingPoints,
|
||||
seedFriendships,
|
||||
} from '../data/seedData';
|
||||
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, createEntityDoc, listEntityDocs } from '../utils/storeRegistry';
|
||||
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
|
||||
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
|
||||
@@ -28,13 +28,24 @@ import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefa
|
||||
// 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';
|
||||
import {
|
||||
FpEventShapeType,
|
||||
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';
|
||||
|
||||
// ============================================================================
|
||||
// Context interface
|
||||
@@ -66,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;
|
||||
@@ -86,47 +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,
|
||||
});
|
||||
|
||||
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[],
|
||||
@@ -177,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;
|
||||
|
||||
@@ -204,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'>) => {
|
||||
@@ -245,59 +268,147 @@ 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<{ public: string[]; protected: string[] }>({ public: [], protected: [] });
|
||||
const [writeGraphs, setWriteGraphs] = useState<{ protected?: string }>({});
|
||||
// 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.
|
||||
|
||||
useEffect(() => {
|
||||
if (!MULTISTORE || !privateNuri || !username) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await ensureAccount(username); // create this account's index docs on first sight
|
||||
// Public = per-entity documents (events/PdR) listed by the public index.
|
||||
// Protected = grouped in each account's protected index document.
|
||||
const [pub, prot, wProt] = await Promise.all([
|
||||
listEntityDocs('public'),
|
||||
resolveReadGraphs('protected'),
|
||||
resolveWriteGraph(username, 'protected'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setReadGraphs({ public: pub, protected: prot });
|
||||
setWriteGraphs({ protected: wProt });
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] storeRegistry init failed:', err);
|
||||
// --- Events: read through REFINE -----------------------------------------
|
||||
// `useList` goes to `@ng-org/refine-providers`, which resolves the resource
|
||||
// name to its SHEX shape (see refine/shapeTypes.ts) and reads every graph the
|
||||
// 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: eventBinding.resource,
|
||||
pagination: { mode: 'off' },
|
||||
});
|
||||
// The rows carry BOTH ids: Refine's `id` (the DOCUMENT NURI, which is what its
|
||||
// `update` addresses) and `@id` (the SUBJECT IRI, which is what participations
|
||||
// reference). `FpEventData.id` stays the subject IRI so `isParticipating` and
|
||||
// `getEventParticipants` keep matching; the document NURI is looked up below.
|
||||
const eventRows = eventsResult?.data ?? [];
|
||||
// "No events" and "not read yet" are different answers, and the auto-seed below
|
||||
// must not confuse them — an empty list while the query is still in flight is
|
||||
// not an empty wallet.
|
||||
const eventsRead = eventsQuery.isSuccess;
|
||||
|
||||
// --- 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 users = profileRows.map(profileBinding.toDomain);
|
||||
const participations = participationRows.map(participationBinding.toDomain);
|
||||
const events = withDerivedCounts(eventRows.map(eventBinding.toDomain), participations);
|
||||
|
||||
// --- Writes: every entity through REFINE ----------------------------------
|
||||
const { mutateAsync: createEventRow } = useCreate<FpEvent>();
|
||||
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 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: eventBinding.resource,
|
||||
values: eventBinding.toValues(event),
|
||||
});
|
||||
return created?.data;
|
||||
},
|
||||
[createEventRow, eventBinding],
|
||||
);
|
||||
|
||||
/**
|
||||
* Update an event, addressed by its APP id — which is not necessarily the key
|
||||
* Refine addresses the record by, so the binding answers both.
|
||||
*
|
||||
* 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 => eventBinding.domainId(r) === id);
|
||||
if (!row) {
|
||||
console.warn('[FestipodData] updateEvent: no event known under', id);
|
||||
return;
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [privateNuri, username]);
|
||||
|
||||
// Scope per entity: events live in the PUBLIC docs, profiles + participations
|
||||
// in the PROTECTED docs. Mono-store mode collapses all to the private store.
|
||||
const publicScope: ShapeScope = MULTISTORE
|
||||
? (readGraphs.public.length ? { graphs: readGraphs.public } : undefined)
|
||||
: privateNuri;
|
||||
const protectedScope: ShapeScope = MULTISTORE
|
||||
? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined)
|
||||
: privateNuri;
|
||||
|
||||
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
|
||||
const emptyEvents: FpEventData[] = [];
|
||||
const emptyUsers: FpUserData[] = [];
|
||||
const emptyParticipations: FpParticipationData[] = [];
|
||||
|
||||
const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true);
|
||||
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
|
||||
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
|
||||
|
||||
const events = eventsShape.items;
|
||||
const users = usersShape.items;
|
||||
const participations = participationsShape.items;
|
||||
// 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: eventBinding.resource,
|
||||
id: eventBinding.recordKey(row),
|
||||
values,
|
||||
});
|
||||
},
|
||||
[eventRows, updateEventRow, eventBinding],
|
||||
);
|
||||
|
||||
// Not in SHEX shapes yet
|
||||
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
|
||||
@@ -315,30 +426,47 @@ function useNgData(): FestipodDataContextValue {
|
||||
}, [events.length, selectedEventId]);
|
||||
|
||||
// Dev auto-seed: if the wallet is still empty 3s after the session is ready,
|
||||
// bootstrap with seed data. `bootstrapWallet()` self-checks (ngSet.size > 0
|
||||
// bootstrap with seed data. `bootstrapWallet()` self-checks (existing data
|
||||
// → skip), so this is safe even if shapes finish hydrating after the timer.
|
||||
// Gated on NODE_ENV so production users see their own (possibly empty) wallet.
|
||||
//
|
||||
// TWO readiness guards, and both are load-bearing. `eventsRead` says the Refine
|
||||
// list has actually resolved — an empty list means nothing while the query is in
|
||||
// flight, and seeding on it would write the fixtures a second time. `seedWritable`
|
||||
// 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.
|
||||
// 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(() => {
|
||||
if (process.env.NODE_ENV === 'production') return;
|
||||
// OFF unless FESTIPOD_AUTO_SEED says otherwise. Firing unconditionally in dev
|
||||
// is what bloats a persistent wallet over repeated runs — see autoSeed.ts.
|
||||
if (!autoSeedEnabled()) return;
|
||||
if (MULTISTORE) return; // seed targets the private store; multi-doc seeding is a separate concern
|
||||
if (hasTriedAutoSeed.current) return;
|
||||
if (!privateNuri) return;
|
||||
const t = setTimeout(() => {
|
||||
if (!eventsRead || !seedWritable) {
|
||||
console.log('[FestipodData] Dev auto-seed: subscriptions not ready yet — skip');
|
||||
return;
|
||||
}
|
||||
hasTriedAutoSeed.current = true;
|
||||
if (eventsShape.ngSet.size === 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(
|
||||
eventsShape.ngSet as any,
|
||||
usersShape.ngSet as any,
|
||||
participationsShape.ngSet as any,
|
||||
).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
|
||||
bootstrapWallet({
|
||||
walletHasData,
|
||||
seedEvent: createEventDoc,
|
||||
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, eventsShape.ngSet, 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);
|
||||
@@ -366,86 +494,58 @@ 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 (NG):', event.title);
|
||||
// Per-entity: in multistore each event is its OWN document. Mono-store: the
|
||||
// private store. (Multistore create reactivity is best-effort — the new doc
|
||||
// is appended to the read fan-out so it shows after re-subscribe.)
|
||||
const eventGraph = MULTISTORE
|
||||
? await createEntityDoc(username || '', 'public')
|
||||
: (privateNuri || '');
|
||||
if (MULTISTORE && eventGraph) {
|
||||
setReadGraphs(prev =>
|
||||
prev.public.includes(eventGraph) ? prev : { ...prev, public: [...prev.public, eventGraph] },
|
||||
);
|
||||
console.log('[FestipodData] createEvent (Refine):', event.title);
|
||||
// The data provider creates the document itself, one per record — so there is
|
||||
// no graph to resolve here any more, and nothing to append to a read fan-out.
|
||||
const stored = await createEventDoc({ ...event, participantCount: event.participantCount || 1 });
|
||||
const eventIri = stored?.["@id"];
|
||||
if (eventIri && currentUserId) {
|
||||
await createParticipation(eventIri, currentUserId);
|
||||
setSelectedEventId(eventIri);
|
||||
}
|
||||
eventsShape.ngSet.add({
|
||||
"@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "",
|
||||
title: event.title, description: event.description, date: event.date,
|
||||
location: event.location, distance: event.distance,
|
||||
participantCount: event.participantCount || 1,
|
||||
coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials,
|
||||
} as FpEvent);
|
||||
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
|
||||
if (addedEvent && currentUserId) {
|
||||
participationsShape.ngSet.add({
|
||||
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
|
||||
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
setSelectedEventId(addedEvent["@id"]);
|
||||
}
|
||||
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
|
||||
}, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, privateNuri, username]);
|
||||
return { ...event, id: eventIri || `ng-pending-${Date.now()}` };
|
||||
}, [createEventDoc, createParticipation, currentUserId]);
|
||||
|
||||
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
|
||||
console.log('[FestipodData] updateEvent (NG):', id, updates);
|
||||
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === id);
|
||||
if (ngEvent) {
|
||||
if (updates.title !== undefined) ngEvent.title = updates.title;
|
||||
if (updates.description !== undefined) ngEvent.description = updates.description;
|
||||
if (updates.date !== undefined) ngEvent.date = updates.date;
|
||||
if (updates.location !== undefined) ngEvent.location = updates.location;
|
||||
if (updates.distance !== undefined) ngEvent.distance = updates.distance;
|
||||
if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount;
|
||||
}
|
||||
}, [eventsShape.ngSet]);
|
||||
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
|
||||
console.log('[FestipodData] updateEvent (Refine):', 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 (NG):', eventId, 'user:', uid);
|
||||
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
|
||||
if (existing) {
|
||||
console.log('[FestipodData] joinEvent (Refine):', eventId, 'user:', uid);
|
||||
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);
|
||||
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
|
||||
if (ngEvent) {
|
||||
ngEvent.participantCount = ngEvent.participantCount + 1;
|
||||
}
|
||||
}, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, 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 (NG):', 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 ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
|
||||
if (ngEvent) {
|
||||
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
|
||||
}
|
||||
console.log('[FestipodData] leaveEvent (Refine):', eventId, 'user:', uid);
|
||||
// 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, eventsShape.ngSet, currentUserId]);
|
||||
}, [participationRows, participationBinding, deleteParticipationRow, currentUserId]);
|
||||
|
||||
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
|
||||
@@ -462,26 +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)');
|
||||
return bootstrapWallet(
|
||||
eventsShape.ngSet as any,
|
||||
usersShape.ngSet as any,
|
||||
participationsShape.ngSet as any,
|
||||
);
|
||||
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
|
||||
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({
|
||||
walletHasData: eventRows.length > 0 || profileRows.length > 0,
|
||||
seedEvent: createEventDoc,
|
||||
seedProfile: createProfileDoc,
|
||||
});
|
||||
}, [eventRows.length, profileRows.length, createEventDoc, createProfileDoc]);
|
||||
|
||||
return {
|
||||
currentUserId, currentUser,
|
||||
@@ -513,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[] = [
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* useShapeSet — `useShape` narrowed back down to "just the set", plus the one
|
||||
* fact the new contract forces callers to know: whether it has loaded.
|
||||
*
|
||||
* WHY THIS EXISTS. `@ng-org/orm` changed `useShape`'s contract between alpha.18
|
||||
* and alpha.21: it used to RETURN the `DeepSignalSet`, and now returns a result
|
||||
* object that carries it under `data`, alongside `isLoading`, `promise`,
|
||||
* `subscription` and the paging controls. Every Festipod call site wants the set,
|
||||
* so the whole adaptation lives HERE, in one place, rather than being spelled out
|
||||
* at each `useShape(...)`.
|
||||
*
|
||||
* AND THE BUMP IS NOT OPTIONAL. `@ng-org/refine-providers` imports
|
||||
* `RdfOrmSubscription` from `@ng-org/orm`; alpha.18 published that class under
|
||||
* the name `OrmSubscription` and only alpha.21 exports `RdfOrmSubscription`. So
|
||||
* using the providers requires alpha.21, which requires absorbing this break.
|
||||
*
|
||||
* WHAT CHANGES FOR WRITERS. Under alpha.18 a defined scope handed back a WRITABLE
|
||||
* set straight away, so a caller could `add()` the moment it had the reference.
|
||||
* Under alpha.21 `data` is `undefined` until the subscription resolves — there is
|
||||
* genuinely no set to write to yet. So a writer must WAIT, and `ready` is what
|
||||
* lets it: `useShapeWithDefaults` surfaces it, and the seeding path gates on it.
|
||||
* (Only a scope of `undefined` never becomes ready — that is the "no scope"
|
||||
* case, which alpha.18 also refused writes on.)
|
||||
*
|
||||
* The placeholder returned meanwhile refuses writes rather than accepting them
|
||||
* into a detached `Set`, because a silently dropped seed is far harder to notice
|
||||
* than a thrown one — it looks exactly like a wallet that persisted nothing.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useShape } from '@ng-org/orm/react';
|
||||
import type { ShapeType, BaseType } from '@ng-org/shex-orm';
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
|
||||
/**
|
||||
* `scope` is either a single store/document NURI (mono-store mode) or a
|
||||
* `{ graphs }` set of document NURIs (multi-document mode — storeRegistry).
|
||||
* `useShape` accepts both natively.
|
||||
*/
|
||||
export type ShapeScope = string | { graphs: string[] } | undefined;
|
||||
|
||||
export interface ShapeSubscription<NgT extends BaseType> {
|
||||
/** The live set, or a write-refusing placeholder until `ready`. */
|
||||
set: DeepSignalSet<NgT>;
|
||||
/** `true` once the subscription has produced its set — a write may land. */
|
||||
ready: boolean;
|
||||
/**
|
||||
* Resolves when the subscription has loaded — the ORM's own readiness signal,
|
||||
* so a writer AWAITS it instead of re-reading in a loop (`rule_no-broker-polling`).
|
||||
* `undefined` when the scope is `undefined`: there is nothing to wait for.
|
||||
*/
|
||||
promise: Promise<unknown> | undefined;
|
||||
}
|
||||
|
||||
/** An empty set that names the problem instead of swallowing a write. */
|
||||
function notLoadedSet<T>(): DeepSignalSet<T> {
|
||||
const refuse = (): never => {
|
||||
throw new Error(
|
||||
'useShapeSet: the shape subscription has not loaded yet — await `ready` before writing',
|
||||
);
|
||||
};
|
||||
return Object.assign(new Set<T>(), { add: refuse, delete: refuse }) as unknown as DeepSignalSet<T>;
|
||||
}
|
||||
|
||||
/** The set plus its readiness — for callers that WRITE. */
|
||||
export function useShapeSubscription<NgT extends BaseType>(
|
||||
shapeType: ShapeType<NgT>,
|
||||
scope: ShapeScope,
|
||||
): ShapeSubscription<NgT> {
|
||||
// Memoised so the placeholder keeps ONE identity for this hook instance: it
|
||||
// lands in `useCallback` dependency arrays, and a fresh set on every render
|
||||
// would rebuild every mutation callback for as long as the load takes.
|
||||
const placeholder = useMemo(() => notLoadedSet<NgT>(), []);
|
||||
const result = useShape(shapeType as never, scope as never) as unknown as {
|
||||
data?: DeepSignalSet<NgT>;
|
||||
promise?: Promise<unknown>;
|
||||
};
|
||||
const data = result?.data;
|
||||
return { set: data ?? placeholder, ready: data !== undefined, promise: result?.promise };
|
||||
}
|
||||
|
||||
/** The set alone — for callers that only READ. */
|
||||
export function useShapeSet<NgT extends BaseType>(
|
||||
shapeType: ShapeType<NgT>,
|
||||
scope: ShapeScope,
|
||||
): DeepSignalSet<NgT> {
|
||||
return useShapeSubscription(shapeType, scope).set;
|
||||
}
|
||||
@@ -8,22 +8,28 @@
|
||||
* Must only be called when NG is connected (inside NgDataProvider).
|
||||
*/
|
||||
|
||||
import { useShape } from '@ng-org/orm/react';
|
||||
import type { ShapeType, BaseType } from '@ng-org/shex-orm';
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
import { useShapeSubscription, type ShapeScope } from './useShapeSet';
|
||||
|
||||
export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
|
||||
/** Mapped items from NG store */
|
||||
items: AppT[];
|
||||
/** Raw NG signal set for mutations */
|
||||
ngSet: DeepSignalSet<NgT>;
|
||||
/**
|
||||
* `true` once the subscription holds a real set. A WRITER must check this:
|
||||
* since @ng-org/orm alpha.21 the set does not exist until the subscription
|
||||
* resolves, so an `ngSet.add(…)` issued before then has nowhere to land.
|
||||
*/
|
||||
ready: boolean;
|
||||
/** Resolves once the subscription has loaded — await it before writing. */
|
||||
loaded: Promise<unknown> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `scope` is either a single store/document NURI (mono-store mode) or a
|
||||
* `{ graphs }` set of document NURIs (multi-document mode — storeRegistry).
|
||||
* `useShape` accepts both natively.
|
||||
*/
|
||||
export type ShapeScope = string | { graphs: string[] } | undefined;
|
||||
// `ShapeScope` now belongs to useShapeSet (the module that talks to the ORM);
|
||||
// it is re-exported here so the existing importers stay unchanged.
|
||||
export type { ShapeScope };
|
||||
|
||||
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
|
||||
shapeType: ShapeType<NgT>,
|
||||
@@ -34,9 +40,10 @@ export function useShapeWithDefaults<NgT extends BaseType, AppT>(
|
||||
): ShapeWithDefaults<NgT, AppT> {
|
||||
// Mono-store: a single store NURI opens the repo in the verifier (enables
|
||||
// writes). Multi-document: a { graphs } scope subscribes to several docs.
|
||||
const ngSet = useShape(shapeType, storeNuri as any) as DeepSignalSet<NgT>;
|
||||
// `useShapeSubscription` is what unwraps the ORM's result object — see that module.
|
||||
const { set: ngSet, ready, promise: loaded } = useShapeSubscription(shapeType, storeNuri);
|
||||
const usingDefaults = !shapesReady;
|
||||
const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT));
|
||||
|
||||
return { items, ngSet };
|
||||
return { items, ngSet, ready, loaded };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* RefineProvider — mounts Refine over the SELECTED backend, and owns the selection.
|
||||
*
|
||||
* This is the app's data entry point for the Events vertical: screens keep calling
|
||||
* `useFestipodData()`, and `FestipodDataContext` reads and writes events through
|
||||
* Refine's hooks, which land on whichever backend is current.
|
||||
*
|
||||
* SWITCHING AT RUNTIME is what shapes this file. The selection is React state, and
|
||||
* `<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.
|
||||
*/
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Refine } from '@refinedev/core';
|
||||
import { getBackend, initialBackendName, resourceNames, type RefineBackend } from './providers';
|
||||
|
||||
interface BackendSelection {
|
||||
/** `undefined` when no backend is registered — see providers/index.ts. */
|
||||
backend: RefineBackend | undefined;
|
||||
/** Switch backends. The data of the one being left becomes unreachable. */
|
||||
switchTo(name: string): void;
|
||||
}
|
||||
|
||||
const BackendContext = createContext<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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -325,15 +403,33 @@ async function ensureAuth(): Promise<void> {
|
||||
BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
|
||||
console.log('Starting Festipod BDD tests...');
|
||||
|
||||
// `--format=iife` IS LOad-BEARING, not tidiness. Both bundles are loaded as
|
||||
// CLASSIC scripts (`<script src="/harness.js">` for the NG harness,
|
||||
// `addScriptTag({path})` for the mock one), so every top-level declaration in
|
||||
// an unwrapped bundle becomes a GLOBAL binding. `@tanstack/query-core` — pulled
|
||||
// in by Refine — exports a class named `MutationObserver`, which then shadows
|
||||
// the DOM's for anything that runs afterwards in that page. Playwright's own
|
||||
// injected script does `new MutationObserver(...)`, so it got TanStack's and
|
||||
// every `waitForFunction` died with "defaultMutationOptions is not a function".
|
||||
// The IIFE wrapper keeps the bundle's declarations to itself; `window.__testData`
|
||||
// is still published explicitly, so the bridge is unaffected.
|
||||
|
||||
// Build the mock harness (always needed for non-@data or fallback)
|
||||
execSync(`bun build ${HARNESS_ENTRY} --outfile ${HARNESS_OUT} --bundle`, { stdio: 'pipe' });
|
||||
execSync(`bun build ${HARNESS_ENTRY} --outfile ${HARNESS_OUT} --bundle --format=iife`, { stdio: 'pipe' });
|
||||
console.log(`[Harness] Built mock (${(fs.statSync(HARNESS_OUT).size / 1024).toFixed(0)} KB)`);
|
||||
|
||||
// Try to build the real broker harness
|
||||
try {
|
||||
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle`, { stdio: 'pipe' });
|
||||
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,21 +7,27 @@
|
||||
*
|
||||
* 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, useBackend } from '../refine/RefineProvider';
|
||||
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
|
||||
import { useShape } from '@ng-org/orm/react';
|
||||
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 { bootstrapWallet } from '../utils/ngBootstrap';
|
||||
import { ensureGraphNuri } from '../utils/ngGraph';
|
||||
|
||||
// ============================================================================
|
||||
@@ -31,9 +37,13 @@ import { ensureGraphNuri } from '../utils/ngGraph';
|
||||
function DataHarnessNG() {
|
||||
return (
|
||||
<NextGraphProvider>
|
||||
<FestipodDataProvider>
|
||||
<HarnessRouter />
|
||||
</FestipodDataProvider>
|
||||
{/* Same order as the real app (app/App.tsx): the data context reads events
|
||||
through Refine's hooks, so it cannot mount outside <Refine>. */}
|
||||
<RefineProvider>
|
||||
<FestipodDataProvider>
|
||||
<HarnessRouter />
|
||||
</FestipodDataProvider>
|
||||
</RefineProvider>
|
||||
</NextGraphProvider>
|
||||
);
|
||||
}
|
||||
@@ -41,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 />;
|
||||
@@ -63,9 +85,15 @@ function ConnectedHarness() {
|
||||
|
||||
// Use private store NURI as scope (opens the repo for reads AND writes)
|
||||
const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
|
||||
const events = useShape(FpEventShapeType, privateNuri) as DeepSignalSet<FpEvent>;
|
||||
const users = useShape(FpUserProfileShapeType, privateNuri) as DeepSignalSet<FpUserProfile>;
|
||||
const participations = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet<FpParticipation>;
|
||||
const events = useShapeSet(FpEventShapeType, privateNuri);
|
||||
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,
|
||||
@@ -79,21 +107,54 @@ function ConnectedHarness() {
|
||||
const timer = setTimeout(() => {
|
||||
const session = ngCtx.session!;
|
||||
|
||||
// Get current user ID
|
||||
let currentUserId = '';
|
||||
const existingUsers = [...users];
|
||||
if (existingUsers.length > 0) {
|
||||
currentUserId = existingUsers[0]['@id'];
|
||||
}
|
||||
// Who the tests act as. The APP's own answer comes first — the bridge
|
||||
// should not invent an identity the screens would not use — and the first
|
||||
// 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,
|
||||
|
||||
@@ -101,56 +162,46 @@ 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);
|
||||
},
|
||||
|
||||
/** Load the app's default seed data into the wallet */
|
||||
/**
|
||||
* Load the app's default seed data into the wallet.
|
||||
*
|
||||
* Delegates to the data context instead of calling `bootstrapWallet`
|
||||
* directly: seeding events now goes through Refine's `create` (one
|
||||
* document per event), and the callback that does it lives in the
|
||||
* context. Calling the bootstrap from here with raw ORM sets would seed
|
||||
* by a DIFFERENT path than the app's — which is the divergence this
|
||||
* harness exists to avoid.
|
||||
*/
|
||||
loadTestData() {
|
||||
return bootstrapWallet(events as any, users as any, participations as any);
|
||||
return appData.loadTestData();
|
||||
},
|
||||
|
||||
// --- Stopgap multi-store validation (see brief_2026-06-15_shared-wallet-shim) ---
|
||||
@@ -226,7 +277,7 @@ function ConnectedHarness() {
|
||||
// ============================================================================
|
||||
|
||||
function FanoutProbe({ graphs }: { graphs: string[] }) {
|
||||
const set = useShape(FpEventShapeType, { graphs } as any) as DeepSignalSet<FpEvent>;
|
||||
const set = useShapeSet(FpEventShapeType, { graphs });
|
||||
useEffect(() => {
|
||||
(window as any).__fanout = {
|
||||
ready: true,
|
||||
@@ -253,13 +304,25 @@ function FanoutProbe({ graphs }: { graphs: string[] }) {
|
||||
// ============================================================================
|
||||
|
||||
function SmokeProbe({ docNuri }: { docNuri: string }) {
|
||||
const set = useShape(FpParticipationShapeType, { graphs: [docNuri] } as any) as DeepSignalSet<FpParticipation>;
|
||||
// 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': '',
|
||||
@@ -268,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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, test, afterEach } from 'bun:test';
|
||||
import { autoSeedEnabled, shouldAutoSeed } from './autoSeed';
|
||||
|
||||
// The gate is driven by the build/runtime-injected global `__FESTIPOD_AUTO_SEED__`
|
||||
// (see autoSeed.ts / build.ts / frontend.tsx). Drive it directly here.
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
afterEach(() => {
|
||||
delete g['__FESTIPOD_AUTO_SEED__'];
|
||||
});
|
||||
|
||||
test('seed-gate DEFAULT (env var absent) → auto-seed disabled', () => {
|
||||
delete g['__FESTIPOD_AUTO_SEED__'];
|
||||
expect(autoSeedEnabled()).toBe(false);
|
||||
// Even on a genuinely EMPTY wallet, the gate must NOT trigger a seed.
|
||||
expect(shouldAutoSeed(false)).toBe(false);
|
||||
});
|
||||
|
||||
test('seed-gate empty string (env unset → "") → disabled', () => {
|
||||
g['__FESTIPOD_AUTO_SEED__'] = '';
|
||||
expect(autoSeedEnabled()).toBe(false);
|
||||
expect(shouldAutoSeed(false)).toBe(false);
|
||||
});
|
||||
|
||||
test('seed-gate ENABLED ("1") on empty wallet → seed triggered', () => {
|
||||
g['__FESTIPOD_AUTO_SEED__'] = '1';
|
||||
expect(autoSeedEnabled()).toBe(true);
|
||||
// Empty wallet + gate on → seed.
|
||||
expect(shouldAutoSeed(false)).toBe(true);
|
||||
});
|
||||
|
||||
test('seed-gate accepts "true" and boolean true', () => {
|
||||
g['__FESTIPOD_AUTO_SEED__'] = 'true';
|
||||
expect(autoSeedEnabled()).toBe(true);
|
||||
g['__FESTIPOD_AUTO_SEED__'] = true;
|
||||
expect(autoSeedEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('seed-gate ENABLED but wallet already has data → NO seed', () => {
|
||||
g['__FESTIPOD_AUTO_SEED__'] = '1';
|
||||
// Non-empty wallet must never be re-seeded, even with the gate on.
|
||||
expect(shouldAutoSeed(true)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Auto-seed gate.
|
||||
*
|
||||
* On a genuinely-empty wallet the app CAN bootstrap demo data (see
|
||||
* ngBootstrap.bootstrapWallet, driven by the auto-seed effect in
|
||||
* FestipodDataContext). This used to fire unconditionally in dev, which bloats a
|
||||
* persistent wallet over repeated runs. It is now OFF BY DEFAULT and only runs
|
||||
* when explicitly enabled via the env var `FESTIPOD_AUTO_SEED` (= "1").
|
||||
*
|
||||
* Same compile-time plumbing as the shared-wallet password (sharedWallet.ts):
|
||||
* `build.ts` `define`s the global from the env var. Absent env → global
|
||||
* undefined → seed OFF, which is the whole point: OFF needs no plumbing at all.
|
||||
*
|
||||
* RECOVERED FROM THE ng-eventually BRANCH, with two differences that branch does
|
||||
* not have.
|
||||
*
|
||||
* TURNING IT ON ONLY REACHES A BUILT BUNDLE. `bun run dev` and `bun run start`
|
||||
* both serve from `src/` (`src/index.ts` imports `index.html`), so the `define`
|
||||
* in `build.ts` never reaches them — only `dist-staging/`, which the @e2e harness
|
||||
* builds, is affected. ng-eventually closes that gap with a
|
||||
* `/festipod-config.json` route read by `frontend.tsx` before the app loads;
|
||||
* recovering it here means making the app bootstrap async, which is not needed
|
||||
* for the gate's purpose (stopping the dev seed from bloating a persistent
|
||||
* wallet) and is therefore left out until someone actually wants to opt in.
|
||||
*
|
||||
* `fixtureSeedEnabled()` BELOW IS DELIBERATELY NOT ENFORCED in `bootstrapWallet`
|
||||
* here, and it is not a decision left pending: the intent behind it — stated
|
||||
* 2026-09-21 — was to stop the dev app from creating fixtures BY ITSELF, and
|
||||
* using fixtures in TESTS was never a problem. `autoSeedEnabled()` above is
|
||||
* exactly that intent. The master switch overshoots it, because it also silences
|
||||
* an explicit `loadTestData()`; on ng-eventually that cost the @data suite its
|
||||
* fixtures (a caveat still open there). So the gate that governs the automatic
|
||||
* seed is the one wired, and an explicit seed still writes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* MASTER SWITCH — may a fixture seed be written into the connected wallet AT ALL?
|
||||
*
|
||||
* Currently OFF, a product decision: no fixture reaches the connected wallet by
|
||||
* any route — neither the opt-in auto-seed (`FESTIPOD_AUTO_SEED`, whose gate
|
||||
* below is subordinate to this one) nor an explicit `loadTestData()`.
|
||||
*
|
||||
* OFF, not deleted. The fixtures (`data/seedData.ts`) and the seeding code stay,
|
||||
* because two paths still need them and NEITHER writes to a wallet: the
|
||||
* demo/disconnected provider (`LocalDataProvider`) reads the fixtures straight
|
||||
* into React state, and the rendering-layer tests read them directly too. Only
|
||||
* the connected wallet is closed off.
|
||||
*
|
||||
* Enforced at the single place fixtures enter a wallet — `bootstrapWallet`
|
||||
* (`utils/ngBootstrap.ts`) — so no caller can bypass it by construction; the
|
||||
* call sites consult it too, only so they neither log nor await work that will
|
||||
* not happen.
|
||||
*/
|
||||
export const fixtureSeedEnabled = (): boolean => false;
|
||||
|
||||
// Build-injected global (not `process.env`, absent in the browser); any path that
|
||||
// doesn't inject it reads `undefined` → false safely (no ReferenceError).
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __FESTIPOD_AUTO_SEED__: string | boolean | undefined;
|
||||
}
|
||||
|
||||
/** Truthy value of the auto-seed flag ("1"/"true"/true → enabled). */
|
||||
function readFlag(): boolean {
|
||||
const v = globalThis.__FESTIPOD_AUTO_SEED__;
|
||||
if (v === true) return true;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim().toLowerCase();
|
||||
return s === '1' || s === 'true';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app's AUTOMATIC empty-wallet seed is enabled. Read at call time so
|
||||
* the runtime config set by frontend.tsx (dev/start) is picked up even though it
|
||||
* lands after this module first evaluates.
|
||||
*/
|
||||
export const autoSeedEnabled = (): boolean => readFlag();
|
||||
|
||||
/**
|
||||
* The seed-gate decision: should the app AUTO-SEED demo data right now? True ONLY
|
||||
* when the gate is enabled (`FESTIPOD_AUTO_SEED`) AND the wallet is genuinely empty
|
||||
* (no data to preserve). `walletHasData` is the caller's "synced and non-empty"
|
||||
* signal. Extracted as a pure predicate so the gate is unit-testable in isolation
|
||||
* from the React effect that consumes it.
|
||||
*/
|
||||
export function shouldAutoSeed(walletHasData: boolean): boolean {
|
||||
if (!autoSeedEnabled()) return false;
|
||||
return !walletHasData;
|
||||
}
|
||||
@@ -3,16 +3,23 @@
|
||||
*
|
||||
* Called once after NG connection + shapes ready. If the wallet already
|
||||
* has events/users, it's a returning user — skip seeding.
|
||||
*
|
||||
* EVENTS ARE SEEDED THROUGH REFINE, not through an ORM set like users and
|
||||
* participations. They have to be: the Refine data provider derives a record's
|
||||
* `id` from its `@graph`, and it creates a fresh document on every `create`.
|
||||
* Seeding events the old way — all of them into one shared graph — would give
|
||||
* every seeded event the SAME Refine id, so the list would collide on itself and
|
||||
* an update would address the whole document instead of one event. The seeder is
|
||||
* injected rather than imported because `useCreate` is a React hook and this
|
||||
* 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 { 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;
|
||||
@@ -20,96 +27,78 @@ export interface BootstrapResult {
|
||||
eventIdMap: Map<string, string>;
|
||||
}
|
||||
|
||||
/** Writes ONE entity and returns the stored record (or nothing). */
|
||||
export type Seeder<T, R> = (entity: T) => Promise<R | undefined>;
|
||||
|
||||
export interface BootstrapDeps {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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(
|
||||
ngEvents: DeepSignalSet<FpEvent>,
|
||||
ngUsers: DeepSignalSet<FpUserProfile>,
|
||||
ngParticipations: DeepSignalSet<FpParticipation>,
|
||||
{ walletHasData, seedEvent, seedProfile }: BootstrapDeps,
|
||||
): Promise<BootstrapResult> {
|
||||
// Already has data → returning user, nothing to seed
|
||||
if (ngEvents.size > 0 || ngUsers.size > 0) {
|
||||
console.log('[Bootstrap] Wallet already has data — events:', ngEvents.size,
|
||||
'users:', ngUsers.size, '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 our data.
|
||||
// The ORM requires a real document NURI as @graph, not the store ID.
|
||||
const graph = await ensureGraphNuri(ngEvents, ngUsers, ngParticipations);
|
||||
console.log('[Bootstrap] Using graph NURI:', graph);
|
||||
|
||||
// Seed users — one at a time with ORM flush between each
|
||||
// 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) {
|
||||
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);
|
||||
for (const u of seedUsers.slice(0, SEEDED_PROFILES)) {
|
||||
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 with ORM flush between each
|
||||
// All events — the scenarios look them up by title.
|
||||
const eventIdMap = new Map<string, string>();
|
||||
for (const e of seedEvents) {
|
||||
ngEvents.add({
|
||||
"@graph": graph,
|
||||
"@type": "http://festipod.org/Event",
|
||||
"@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,
|
||||
} as FpEvent);
|
||||
const stored = await seedEvent(e);
|
||||
await flushAndWait();
|
||||
const added = [...ngEvents].find(ne => ne.title === e.title);
|
||||
if (added) eventIdMap.set(e.id, added["@id"]);
|
||||
const iri = stored?.['@id'];
|
||||
if (iri) {
|
||||
eventIdMap.set(e.id, iri);
|
||||
} else {
|
||||
console.warn('[Bootstrap] Event seeded without a subject IRI:', e.title);
|
||||
}
|
||||
}
|
||||
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events');
|
||||
|
||||
// Seed participations with mapped IDs — one at a time
|
||||
let partCount = 0;
|
||||
for (const p of seedParticipations) {
|
||||
const eventIri = eventIdMap.get(p.eventId) || p.eventId;
|
||||
const userIri = userIdMap.get(p.userId) || p.userId;
|
||||
ngParticipations.add({
|
||||
"@graph": graph,
|
||||
"@type": "http://festipod.org/Participation",
|
||||
"@id": "",
|
||||
event: eventIri,
|
||||
user: userIri,
|
||||
isConfirmed: p.isConfirmed,
|
||||
} as FpParticipation);
|
||||
await flushAndWait();
|
||||
partCount++;
|
||||
}
|
||||
console.log('[Bootstrap] Seeded', partCount, 'participations');
|
||||
// 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 and read back from
|
||||
// it, never counted from Participation records.
|
||||
|
||||
return { seeded: true, userIdMap, eventIdMap };
|
||||
}
|
||||
|
||||
@@ -70,7 +70,9 @@ export async function logoutNg(): Promise<void> {
|
||||
}
|
||||
|
||||
export interface NextGraphSession {
|
||||
ng: typeof NG;
|
||||
// `NG` IS the type of the SDK object, not a value whose type we could take.
|
||||
// `typeof NG` was a type-level error that the broken typecheck gate hid.
|
||||
ng: NG;
|
||||
session_id: string;
|
||||
protected_store_id: string;
|
||||
private_store_id: string;
|
||||
|
||||
+4
-1
@@ -21,7 +21,10 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
// No `baseUrl`: deprecated in TypeScript 6, where it is reported as an ERROR
|
||||
// that aborts the whole compilation — `tsc --noEmit` then exits 0 having
|
||||
// checked nothing, so the typecheck gate silently passes everything. `paths`
|
||||
// resolves relative to this file since TS 4.4, so dropping it changes nothing.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user