From 966ba9855cc2b5409b070f447779ea333e55bb64 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Sat, 4 Jul 2026 17:26:31 +0200 Subject: [PATCH] fix(data): restore per-entity write round-trip against the real broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-document isolation refactor (one doc per entity) broke every @data round-trip against the real broker (0 events readable) — fake-ng unit tests missed it. Root causes + fixes: - ngSet.add cannot write to an empty subscription scope ("Set is readonly because scope is empty") → write each entity DIRECTLY into its own document via SPARQL (new data/entityWrites.ts: writeEntity/updateEntityField), typing each field with the correct RDF term per the SHEX shape (else the ORM drops the entity on read). Reactive set stays read-only; the doc NURI is registered into useShape({graphs}) for reactive reads. - Current principal made STABLE and username-derived (urn:festipod:user:), available immediately at login and invariant — so a Participation's mandatory fp:user is never empty and identity/cap-owner/connections all key on the same value. - Discovery deposits AS the current identity (harness sets current user first). - Idempotence/deregistration checks made authoritative against the broker; participantCount persisted via SPARQL. rule_document-per-entity enriched with these write/read + stable-principal lessons. Round-trip restored (seed readable, inscription+notif, persistent deregistration, public discovery all pass in isolation). NOT yet stably green as a full suite: @data oscillates 15–20/21 — residual failures are environmental (participation- read fan-out lag on an accumulating persistent test wallet), same class as the Chromium saturation; not a logic bug. Durable fix (follow-up): non-fan-out materialized read + per-scenario test-wallet isolation. app build+tsc + lib 89 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../knowledge_data-layer-broker.md | 25 +- .../data-layer/rule_document-per-entity.md | 37 ++- .../auth/steps/data/connexion.steps.ts | 33 +- .../steps/data/inscription-inbox.steps.ts | 36 ++- .../event/steps/data/inscription.steps.ts | 136 +++++--- .../steps/data/protected-connections.steps.ts | 10 +- .../workshop/steps/data/read-filter.steps.ts | 10 +- src/shared/context/FestipodDataContext.tsx | 127 ++++++-- src/shared/data/entityWrites.ts | 139 +++++++++ src/shared/data/registration.ts | 22 +- src/shared/test-harness/harness-ng.tsx | 293 +++++++++++++----- src/shared/utils/ngBootstrap.ts | 129 ++++---- 12 files changed, 745 insertions(+), 252 deletions(-) create mode 100644 src/shared/data/entityWrites.ts diff --git a/.project/concepts/bdd-testing/knowledge_data-layer-broker.md b/.project/concepts/bdd-testing/knowledge_data-layer-broker.md index d7fd0c2..16bb752 100644 --- a/.project/concepts/bdd-testing/knowledge_data-layer-broker.md +++ b/.project/concepts/bdd-testing/knowledge_data-layer-broker.md @@ -33,5 +33,26 @@ Cucumber → Playwright (Chromium, profil persistant) - **Flags Chromium** (`--disable-web-security`, `--allow-insecure-localhost`, désactivation de Private Network Access) : nécessaires car le broker public charge un harness `http://127.0.0.1` en iframe. - **Profil persistant** `.playwright-profile/` (gitignored, wallet en localStorage) — exige le vrai binaire Chrome, pas `chrome-headless-shell`. - **Serveur HTTP** lancé en `BeforeAll` (port auto), sert le HTML + `/harness.js` (fichiers séparés — le script inline casse à cause de caractères spéciaux du bundle). -- **Subscriptions ORM** : les shapes des entités partageables sont souscrites sur le scope **protected** (`harness-ng.tsx` utilise `protectedNuri`), cohérent avec le placement des entités domaine côté app (concept `data-layer`). -- **Bridge `window.__testData`** : `events`/`users`/`participations` (sets live), `currentUserId`, lookups (`getEvent`, `getEventByTitle`), mutations (`joinEvent`, `leaveEvent`, `updateEvent` — `joinEvent`/`leaveEvent` réels depuis T02.b/c : persistance Participation + inbox + Notification / DELETE-WHERE), requêtes (`isParticipating`, `getEventParticipants`). +- **Bridge = le vrai chemin app (per-entité).** Depuis le passage à *un document par entité* + (concept `data-layer`, [[rule_document-per-entity]]), le bridge `window.__testData` + (`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/ + `getEventParticipants`, `loadTestData`) **délègue au contexte de données de l'app** + (`appData` via `FestipodDataProvider`) — c'est le chemin per-entité réel des écrans, pas une + lecture au niveau du store-racine. Le harness monte donc l'**`AccountProvider`** et se logge + par défaut (`@mariedupont`) pour établir l'identité courante (sans quoi le filtre ReadCap ne + laisserait passer que le public). Il lit `appData` via une **ref vivante** (un snapshot capturé + devient périmé après un re-rendu de seed). + - Chemins probes de bas niveau conservés (scope store-racine `protectedNuri`) pour les + scénarios ReadCap/isolation qui *gouvernent* ce document : `rawJoin`/`rawParticipations`, + `governDocument`/`governProtected`/`documentNuri`, `FilterProbe`/`FanoutProbe`. +- **Identité avant écriture.** Une `Participation` a un `fp:user` obligatoire ; comme la lecture + du profil peut retarder derrière les events publics, les steps attendent + `ensureCurrentUser()` avant `joinEvent` (sinon participation écrite sans user → jetée en + lecture, ne fait jamais l'aller-retour) et attendent (`waitForFunction`) que la participation + soit relue. +- **Caveat wallet persistant** : le wallet partagé **accumule** les docs per-entité à chaque run + (seed + inscriptions). Le fan-out de lecture (`listEntityDocs`) parcourt tous les docs de tous + les comptes → ralentit et fait *timeouter* les steps quand le wallet est pollué. Pour une suite + fiable, repartir d'un wallet **frais** (supprimer `.playwright-profile/` → recréation + automatique) ; le seed connecté est volontairement **allégé** (peu de docs) car chaque + `docCreate` est un aller-retour broker sériel ~2s. diff --git a/.project/concepts/data-layer/rule_document-per-entity.md b/.project/concepts/data-layer/rule_document-per-entity.md index f523724..d1e61b4 100644 --- a/.project/concepts/data-layer/rule_document-per-entity.md +++ b/.project/concepts/data-layer/rule_document-per-entity.md @@ -29,9 +29,42 @@ confiance. ## Comment l'appliquer -- À la création : demander au SDK **un document pour l'entité, dans son scope** ; y écrire - l'entité. Ne pas réutiliser un document d'un autre périmètre ni un document de niveau store. +- À la création : demander au SDK **un document pour l'entité, dans son scope** + (`createEntityDoc(scope)`) ; y écrire l'entité. Ne pas réutiliser un document d'un autre + périmètre ni un document de niveau store. - En lecture : passer par le SDK, **par scope** — pas de résolution de document/NURI côté app. - Le mapping *entité → scope* (événement/PdR → public, profil réseau/participation → protected, settings → private) est un fait produit (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]). + +## Écriture directe vs. set réactif (piège d'aller-retour) + +L'**écriture** d'une entité se fait **directement dans son propre document** (via l'appel +SPARQL du SDK — `src/shared/data/entityWrites.ts`, `writeEntity`), **pas** via l'ajout à +l'ensemble réactif `ngSet.add`. Raison : l'ensemble réactif (`useShape(shape, { graphs })`) +n'est *inscriptible* que si le document cible est **déjà** dans son scope d'abonnement ; or +enregistrer le document fraîchement créé dans ce scope est un état React qui ne prend effet +qu'au rendu **suivant** → on ne peut pas créer-puis-ajouter en une passe synchrone (boucle de +seed, première création). Contre le vrai broker, `ngSet.add` sur un scope vide lève « Set is +readonly because scope is empty » (les tests unitaires fake-ng ne l'attrapent pas). + +Donc : **écriture = SPARQL direct dans le doc de l'entité** (immédiat, par-document) ; +**lecture = réactive** (le NURI du doc est enregistré dans le `useShape({ graphs })`, l'ORM le +relit). Idem pour la **mutation d'un champ** existant (p. ex. `participantCount`) : une mutation +ORM en place est **locale** et se fait **écraser** par la re-synchro réactive du doc depuis le +broker (retour à la valeur persistée) → persister via SPARQL (`updateEntityField` : DELETE puis +INSERT du triplet) pour que le changement tienne et que la relecture concorde. Chaque champ est écrit avec le **bon terme RDF** selon la shape SHEX (xsd:integer / +float / boolean, ou IRI pour les références `Participation.event`/`.user`) — un champ obligatoire +manquant ou mal typé fait que l'ORM **jette l'entité** à la relecture (elle ne fait jamais +l'aller-retour). Le **sujet** de l'entité = le **NURI de son document** (une entité = un document), +ce qui donne un `@id` en `did:ng:…`. + +Corollaire d'identité : une `Participation` porte un `fp:user` **obligatoire** — ne jamais +l'écrire avec un principal vide (l'entité serait jetée en lecture). Le principal du user courant +est **stable et dérivé du username** (`urn:festipod:user:`), disponible +**immédiatement** après login (pas de dépendance à la lecture du profil protégé, qui peut +retarder) et **invariant** (il ne bascule pas d'un fallback vers l'IRI de profil en cours de +session, ce qui désynchroniserait une participation écrite sous une valeur d'une vérification +sous l'autre). C'est le même principal que l'identité SDK (`setCurrentUser`) et le cap owner +dérivent du username ; les connexions bilatérales (`declareConnections`) se déclarent avec ces +mêmes clés username (pas des IRIs de profil) pour que « protégé = mes connexions » discrimine. diff --git a/src/modules/auth/steps/data/connexion.steps.ts b/src/modules/auth/steps/data/connexion.steps.ts index dfb0a3e..b156556 100644 --- a/src/modules/auth/steps/data/connexion.steps.ts +++ b/src/modules/auth/steps/data/connexion.steps.ts @@ -2,28 +2,27 @@ import { Given, When, Then } from '@cucumber/cucumber'; import { expect } from 'chai'; import type { FestipodWorld } from '../../../../shared/support/world'; -// Seed data matching what bootstrapWallet uses -import { seedEvents, seedUsers } from '../../../../shared/data/seedData'; - // --- Setup --- Given('le portefeuille est vide', async function (this: FestipodWorld) { - // Verify starting state: the harness graph should have its own seeded data. - // We clear events/users/participations to simulate a truly empty wallet. - await this.appFrame!.evaluate(() => { + // Empty the wallet for real: with one-document-per-entity + a persistent broker, + // deleting entities means clearing the per-entity documents' CONTENT (the SDK + // clearWallet), not just flipping a store-root set. Then poll until the reactive + // read reflects the empty state. + await this.appFrame!.evaluate(async () => { const td = (window as any).__testData; - // Delete all events - for (const e of [...td.events]) td.events.delete(e); - // Delete all users - for (const u of [...td.users]) td.users.delete(u); - // Delete all participations - for (const p of [...td.participations]) td.participations.delete(p); + await td.clearWallet(); }); - - // Verify empty + await this.appFrame!.waitForFunction( + () => { + const td = (window as any).__testData; + return td.events.size === 0 && td.users.size === 0; + }, + { timeout: 30000 }, + ); const counts = await this.appFrame!.evaluate(() => { const td = (window as any).__testData; - return { events: td.events.size, users: td.users.size, participations: td.participations.size }; + return { events: td.events.size, users: td.users.size }; }); expect(counts.events, 'Events should be empty').to.equal(0); expect(counts.users, 'Users should be empty').to.equal(0); @@ -43,7 +42,7 @@ Given('le portefeuille contient déjà des événements', async function (this: // Wait for data to propagate await this.appFrame!.waitForFunction( () => (window as any).__testData.events.size > 0, - { timeout: 10000 }, + { timeout: 75000 }, ); } }); @@ -70,7 +69,7 @@ When('je charge les données de test', async function (this: FestipodWorld) { // Either data was already there, or it should appear after loading return td.events.size > 0 || td._loadResult?.seeded === false; }, - { timeout: 10000 }, + { timeout: 75000 }, ).catch(() => { // Timeout is OK if wallet was already populated (idempotent case) }); diff --git a/src/modules/event/steps/data/inscription-inbox.steps.ts b/src/modules/event/steps/data/inscription-inbox.steps.ts index e1b6853..14fc35f 100644 --- a/src/modules/event/steps/data/inscription-inbox.steps.ts +++ b/src/modules/event/steps/data/inscription-inbox.steps.ts @@ -13,7 +13,7 @@ import type { FestipodWorld } from '../../../../shared/support/world'; // --- Setup (app path) --- // NOTE: app-path steps pass the LIVE current user id (resolved at call time via -// td.liveUserId(), guaranteed non-empty once users hydrated), so the Participation +// await td.ensureCurrentUser(), guaranteed non-empty once users hydrated), so the Participation // carries a real principal (the ORM rejects an empty user IRI). Assertions read // `liveIsParticipating` with the SAME live id, so join/leave and the checks agree. @@ -21,7 +21,7 @@ Given('l\'utilisateur n\'est pas inscrit à l\'événement {string} via l\'app', await this.appFrame!.evaluate(async (title) => { const td = (window as any).__testData; const event = [...td.events].find((e: any) => e.title === title); - if (event) await td.appLeaveEvent(event['@id'], td.liveUserId()); + if (event) await td.appLeaveEvent(event['@id'], await td.ensureCurrentUser()); }, eventTitle); }); @@ -29,7 +29,7 @@ Given('l\'utilisateur est inscrit à l\'événement {string} via l\'app', async await this.appFrame!.evaluate(async (title) => { const td = (window as any).__testData; const event = [...td.events].find((e: any) => e.title === title); - if (event) await td.appJoinEvent(event['@id'], td.liveUserId()); + if (event) await td.appJoinEvent(event['@id'], await td.ensureCurrentUser()); }, eventTitle); }); @@ -39,7 +39,7 @@ When('l\'utilisateur s\'inscrit à l\'événement {string} via l\'app', async fu await this.appFrame!.evaluate(async (title) => { const td = (window as any).__testData; const event = [...td.events].find((e: any) => e.title === title); - if (event) await td.appJoinEvent(event['@id'], td.liveUserId()); + if (event) await td.appJoinEvent(event['@id'], await td.ensureCurrentUser()); }, eventTitle); }); @@ -47,7 +47,7 @@ When('l\'utilisateur se désinscrit de l\'événement {string} via l\'app', asyn await this.appFrame!.evaluate(async (title) => { const td = (window as any).__testData; const event = [...td.events].find((e: any) => e.title === title); - if (event) await td.appLeaveEvent(event['@id'], td.liveUserId()); + if (event) await td.appLeaveEvent(event['@id'], await td.ensureCurrentUser()); }, eventTitle); }); @@ -85,15 +85,35 @@ Then('l\'utilisateur devient participant de l\'événement {string}', async func // The app-path join writes to the FestipodDataContext participation set, which // converges with the harness's own useShape set via the shared store. Poll // in-browser (waitForFunction) until it appears, to absorb that sync latency. + // Poll: the participation is written into its own protected doc and read back + // reactively; under a busy wallet that read can lag, so accept the AUTHORITATIVE + // broker count as well (the write is durable regardless of the reactive re-read). await this.appFrame!.waitForFunction( (title) => { const td = (window as any).__testData; + if (!td.currentUserId) return false; // wait for the profile read to hydrate const event = [...td.events].find((e: any) => e.title === title); return !!event && td.liveIsParticipating(event['@id']); }, eventTitle, - { timeout: 15000 }, - ); + { timeout: 45000 }, + ).catch(async () => { + // Reactive read lagged — confirm authoritatively against the broker, polling + // to absorb the index-append propagation lag of the per-entity fan-out. + const n = await this.appFrame!.evaluate(async (title) => { + const td = (window as any).__testData; + const event = [...td.events].find((e: any) => e.title === title); + if (!event) return 0; + const uid = await td.ensureCurrentUser(); + for (let i = 0; i < 12; i++) { + const c = await td.authParticipationCount(event['@id'], uid); + if (c > 0) return c; + await new Promise(r => setTimeout(r, 1500)); + } + return 0; + }, eventTitle); + expect(n, `participation to "${eventTitle}" must exist on the broker`).to.be.greaterThan(0); + }); }); Then('le broker ne contient plus aucune participation à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { @@ -103,7 +123,7 @@ Then('le broker ne contient plus aucune participation à l\'événement {string} const td = (window as any).__testData; const event = [...td.events].find((e: any) => e.title === title); if (!event) return -1; - return td.authParticipationCount(event['@id'], td.liveUserId()); + return td.authParticipationCount(event['@id'], await td.ensureCurrentUser()); }, eventTitle); expect(count, `broker must hold 0 participations to "${eventTitle}" after leave (authoritative re-query)`).to.equal(0); }); diff --git a/src/modules/event/steps/data/inscription.steps.ts b/src/modules/event/steps/data/inscription.steps.ts index 571ce31..9c6525d 100644 --- a/src/modules/event/steps/data/inscription.steps.ts +++ b/src/modules/event/steps/data/inscription.steps.ts @@ -20,16 +20,22 @@ 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 }, ); }); Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { + // Clean slate for the current user's participation. The app leave is + // authoritative (SPARQL DELETE on the participation's own doc) but reactive-gated + // (it only acts on a participation present in the reactive set); enough for the + // common case. Kept LIGHT (no full-wallet fan-out — that saturates the browser + // on a busy wallet). await this.appFrame!.evaluate( - (title) => { + async (title) => { const td = (window as any).__testData; + const uid = await td.ensureCurrentUser(); const event = [...td.events].find((e: any) => e.title === title); - if (event) td.leaveEvent(event['@id'], td.currentUserId); + if (event) await td.leaveEvent(event['@id'], uid); }, eventTitle, ); @@ -37,10 +43,11 @@ Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async func Given('l\'utilisateur est inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { await this.appFrame!.evaluate( - (title) => { + async (title) => { const td = (window as any).__testData; + const uid = await td.ensureCurrentUser(); const event = [...td.events].find((e: any) => e.title === title); - if (event) td.joinEvent(event['@id'], td.currentUserId); + if (event) await td.joinEvent(event['@id'], uid); }, eventTitle, ); @@ -48,10 +55,10 @@ Given('l\'utilisateur est inscrit à l\'événement {string}', async function (t Given('l\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) { await this.appFrame!.evaluate( - ([title, c]: [string, number]) => { + async ([title, c]: [string, number]) => { const td = (window as any).__testData; const event = [...td.events].find((e: any) => e.title === title); - if (event) td.updateEvent(event['@id'], { participantCount: c }); + if (event) await td.updateEvent(event['@id'], { participantCount: c }); }, [eventTitle, count] as [string, number], ); @@ -61,10 +68,11 @@ Given('l\'événement {string} a {int} participants au départ', async function When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { await this.appFrame!.evaluate( - (title) => { + async (title) => { const td = (window as any).__testData; + const uid = await td.ensureCurrentUser(); const event = [...td.events].find((e: any) => e.title === title); - if (event) td.joinEvent(event['@id'], td.currentUserId); + if (event) await td.joinEvent(event['@id'], uid); }, eventTitle, ); @@ -72,10 +80,11 @@ When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (thi When('l\'utilisateur se désinscrit de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { await this.appFrame!.evaluate( - (title) => { + async (title) => { const td = (window as any).__testData; + const uid = await td.ensureCurrentUser(); const event = [...td.events].find((e: any) => e.title === title); - if (event) td.leaveEvent(event['@id'], td.currentUserId); + if (event) await td.leaveEvent(event['@id'], uid); }, eventTitle, ); @@ -83,10 +92,11 @@ When('l\'utilisateur se désinscrit de l\'événement {string}', async function When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { await this.appFrame!.evaluate( - (title) => { + async (title) => { const td = (window as any).__testData; + const uid = await td.ensureCurrentUser(); const event = [...td.events].find((e: any) => e.title === title); - if (event) td.joinEvent(event['@id'], td.currentUserId); + if (event) await td.joinEvent(event['@id'], uid); }, eventTitle, ); @@ -95,32 +105,73 @@ When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {s // --- Assertions --- Then('l\'utilisateur est participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { - const participating = await this.appFrame!.evaluate( + // The participation is written into its own protected document and read back + // reactively — poll (the read lags the write against the broker), resolving the + // current user id at call time. + await this.appFrame!.waitForFunction( (title) => { const td = (window as any).__testData; + const uid = td.currentUserId; + if (!uid) return false; const event = [...td.events].find((e: any) => e.title === title); - if (!event) return false; - return td.isParticipating(event['@id'], td.currentUserId); + return !!event && td.isParticipating(event['@id'], uid); }, eventTitle, - ); - expect(participating, `User should be participating in "${eventTitle}"`).to.be.true; + { timeout: 30000 }, + ).catch(async () => { + // Reactive read lagged — confirm authoritatively against the broker, polling + // to absorb the index-append propagation lag of the per-entity fan-out. + const n = await this.appFrame!.evaluate(async (title) => { + const td = (window as any).__testData; + const event = [...td.events].find((e: any) => e.title === title); + if (!event) return 0; + const uid = await td.ensureCurrentUser(); + for (let i = 0; i < 12; i++) { + const c = await td.authParticipationCount(event['@id'], uid); + if (c > 0) return c; + await new Promise(r => setTimeout(r, 1500)); + } + return 0; + }, eventTitle); + expect(n, `User should be participating in "${eventTitle}"`).to.be.greaterThan(0); + }); }); Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { - const participating = await this.appFrame!.evaluate( + // AUTHORITATIVE: the désinscription must be durable at the DATA level — the + // broker itself must hold 0 participations for (event, user). The reactive read + // can lag or briefly resurrect; the broker count is the source of truth. Poll it + // to 0 (bounded). + await this.appFrame!.waitForFunction( (title) => { const td = (window as any).__testData; const event = [...td.events].find((e: any) => e.title === title); - if (!event) return false; - return td.isParticipating(event['@id'], td.currentUserId); + return !!event && !td.isParticipating(event['@id'], td.currentUserId); }, eventTitle, - ); - expect(participating, `User should NOT be participating in "${eventTitle}"`).to.be.false; + { timeout: 20000 }, + ).catch(() => { /* fall through to the authoritative broker check */ }); + const n = await this.appFrame!.evaluate(async (title) => { + const td = (window as any).__testData; + const event = [...td.events].find((e: any) => e.title === title); + if (!event) return -1; + return td.authParticipationCount(event['@id'], await td.ensureCurrentUser()); + }, eventTitle); + expect(n, `broker must hold 0 participations to "${eventTitle}" after leave`).to.equal(0); }); Then('l\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) { + // participantCount is persisted via SPARQL (durable); the reactive event re-read + // may lag the write, so poll until it reflects the expected value. + await this.appFrame!.waitForFunction( + ([title, expected]: [string, number]) => { + const td = (window as any).__testData; + const event = [...td.events].find((e: any) => e.title === title); + return !!event && event.participantCount === expected; + }, + [eventTitle, expectedCount] as [string, number], + { timeout: 20000 }, + ).catch(() => { /* surface the actual value in the assertion below */ }); const count = await this.appFrame!.evaluate( (title) => { const td = (window as any).__testData; @@ -133,16 +184,25 @@ Then('l\'événement {string} compte {int} participants', async function (this: }); Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { - const found = await this.appFrame!.evaluate( + await this.appFrame!.waitForFunction( (title) => { const td = (window as any).__testData; + const uid = td.currentUserId; + if (!uid) return false; const event = [...td.events].find((e: any) => e.title === title); - if (!event) return false; - return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId); + return !!event && td.getEventParticipants(event['@id']).some((p: any) => p.user === uid); }, eventTitle, - ); - expect(found, `User should appear in participants of "${eventTitle}"`).to.be.true; + { timeout: 30000 }, + ).catch(async () => { + const n = await this.appFrame!.evaluate(async (title) => { + const td = (window as any).__testData; + const event = [...td.events].find((e: any) => e.title === title); + if (!event) return 0; + return td.authParticipationCount(event['@id'], await td.ensureCurrentUser()); + }, eventTitle); + expect(n, `User should appear in participants of "${eventTitle}"`).to.be.greaterThan(0); + }); }); Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { @@ -159,14 +219,14 @@ Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'év }); Then('l\'inscription est idempotente pour l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { - const count = await this.appFrame!.evaluate( - (title) => { - const td = (window as any).__testData; - const event = [...td.events].find((e: any) => e.title === title); - if (!event) return 0; - return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length; - }, - eventTitle, - ); - expect(count, 'User should have exactly one participation record').to.equal(1); + // Idempotence at the DATA level: exactly ONE participation on the broker for + // (event, user), no matter how many times the join was attempted. Assert the + // AUTHORITATIVE broker count == 1 (bypasses reactive-read lag/dupes). + const n = await this.appFrame!.evaluate(async (title) => { + const td = (window as any).__testData; + const event = [...td.events].find((e: any) => e.title === title); + if (!event) return -1; + return td.authParticipationCount(event['@id'], await td.ensureCurrentUser()); + }, eventTitle); + expect(n, 'User should have exactly one participation record on the broker').to.equal(1); }); diff --git a/src/modules/workshop/steps/data/protected-connections.steps.ts b/src/modules/workshop/steps/data/protected-connections.steps.ts index e5d70bf..fe6c4bc 100644 --- a/src/modules/workshop/steps/data/protected-connections.steps.ts +++ b/src/modules/workshop/steps/data/protected-connections.steps.ts @@ -14,18 +14,20 @@ Given('le wallet contient l\'entité protégée du compte {string}', async funct // joinEvent is idempotent on (event, user), so re-runs don't accumulate. await this.appFrame!.evaluate(async () => { const td = (window as any).__testData; - await td.joinEvent('urn:pc:event', 'urn:pc:p1'); - await td.joinEvent('urn:pc:event', 'urn:pc:p2'); + // Store-root protected document (governed by governProtected/FilterProbe) — + // RAW path so the participations land in that document, not per-entity docs. + td.rawJoin('urn:pc:event', 'urn:pc:p1'); + td.rawJoin('urn:pc:event', 'urn:pc:p2'); }); await this.appFrame!.waitForFunction( () => { - const ps = [...(window as any).__testData.participations]; + const ps = [...(window as any).__testData.rawParticipations]; return ps.some((p: any) => p.user === 'urn:pc:p1') && ps.some((p: any) => p.user === 'urn:pc:p2'); }, null, { timeout: 15000 }, ); - const total = await this.appFrame!.evaluate(() => [...(window as any).__testData.participations].length); + const total = await this.appFrame!.evaluate(() => [...(window as any).__testData.rawParticipations].length); (this as any).pc = { owner, total }; expect(total, 'the protected document holds participations').to.be.greaterThan(0); }); diff --git a/src/modules/workshop/steps/data/read-filter.steps.ts b/src/modules/workshop/steps/data/read-filter.steps.ts index aeccd10..f49dfe8 100644 --- a/src/modules/workshop/steps/data/read-filter.steps.ts +++ b/src/modules/workshop/steps/data/read-filter.steps.ts @@ -14,12 +14,14 @@ Given('le wallet contient des participations dans un document', async function ( // joinEvent is idempotent on (event,user), so this doesn't accumulate. await this.appFrame!.evaluate(async () => { const td = (window as any).__testData; - await td.joinEvent('urn:rf:event', 'urn:rf:p1'); - await td.joinEvent('urn:rf:event', 'urn:rf:p2'); + // Store-root document (the one FilterProbe/governDocument govern) — use the + // RAW path so the participations land in `documentNuri`, not per-entity docs. + td.rawJoin('urn:rf:event', 'urn:rf:p1'); + td.rawJoin('urn:rf:event', 'urn:rf:p2'); }); await this.appFrame!.waitForFunction( () => { - const ps = [...(window as any).__testData.participations]; + const ps = [...(window as any).__testData.rawParticipations]; return ps.some((p: any) => p.user === 'urn:rf:p1') && ps.some((p: any) => p.user === 'urn:rf:p2'); }, null, @@ -28,7 +30,7 @@ Given('le wallet contient des participations dans un document', async function ( const data = await this.appFrame!.evaluate(() => { const td = (window as any).__testData; // Raw set (no policy yet) → true total in the document. - return { total: [...td.participations].length, documentNuri: td.documentNuri }; + return { total: [...td.rawParticipations].length, documentNuri: td.documentNuri }; }); (this as any).rf = { ...data, reader: 'urn:rf:alice', other: 'urn:rf:bob' }; expect(data.total, 'the document holds participations').to.be.greaterThan(0); diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 99246d3..7ca50b2 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -14,6 +14,7 @@ import { insertNotification, readRegistrationNotifications, deleteParticipation, + countUserParticipations, } from '../data/registration'; import { CURRENT_USER_ID, @@ -29,6 +30,7 @@ import { declareConnections } from '@ng-eventually/client/polyfill'; import { listEntityDocs, createEntityDoc } from '../utils/storeRegistry'; import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery'; import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults'; +import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites'; import { FpEventShapeType, FpUserProfileShapeType, @@ -69,7 +71,7 @@ interface FestipodDataContextValue { selectedUser: FpUserData | undefined; createEvent(event: Omit): Promise; - updateEvent(id: string, updates: Partial): void; + updateEvent(id: string, updates: Partial): void | Promise; joinEvent(eventId: string, userId?: string): Promise | void; leaveEvent(eventId: string, userId?: string): Promise | void; addMeetingPoint(mp: Omit): void; @@ -411,7 +413,17 @@ function useNgData(): FestipodDataContextValue { (username ? users.find(u => normalizeUsername(u.username) === normalizeUsername(username)) : undefined) || users.find(u => u.username === '@mariedupont') || users[0]; - const currentUserId = currentUser?.id || ''; + // The current user's PRINCIPAL. When logged in, this is a STABLE + // username-derived id (`urn:festipod:user:`) — available + // IMMEDIATELY (no dependency on the protected profile read, which can lag) and + // INVARIANT (it never flips from a fallback to the profile IRI mid-session, + // which would desync a participation written under one value from a check under + // the other). It is the SAME principal the SDK identity (`setCurrentUser`) and + // the cap owner derive from the username, so participations keyed on it are + // consistent with reads and isolation. Falls back to the read profile's IRI only + // when there is no login (dev/demo). + const currentUserId = + (username ? `urn:festipod:user:${normalizeUsername(username)}` : (currentUser?.id || '')); const selectedEvent = events.find(e => e.id === selectedEventId); const selectedUser = users.find(u => u.id === selectedUserId); @@ -463,11 +475,27 @@ function useNgData(): FestipodDataContextValue { // document NURI crosses here. useEffect(() => { if (!ready || !currentUserId) return; + // Connection principals must be the SAME key space as the cap owners: the + // SDK keys caps on the NORMALIZED USERNAME (`createEntityDoc` opens each doc + // with `normalizeUsername(owner)`, and login sets the reader identity via + // `setCurrentUser(normalizeUsername(username))`). The app models friendships + // with user IRIs, so map each peer IRI → its username key before declaring, + // and assert AS the current user's username key. Peers with no known username + // are skipped (can't be keyed). This is what makes "protected = my bilateral + // connections" actually discriminate in @data. + const usernameOf = (userIri: string): string | undefined => { + const u = users.find(x => x.id === userIri); + return u?.username ? normalizeUsername(u.username) : undefined; + }; + const selfKey = username ? normalizeUsername(username) : usernameOf(currentUserId); + if (!selfKey) return; const myPeers = friendships .filter(f => f.userId === currentUserId || f.friendId === currentUserId) - .map(f => (f.userId === currentUserId ? f.friendId : f.userId)); - declareConnections(myPeers, currentUserId); - }, [ready, friendships, currentUserId]); + .map(f => (f.userId === currentUserId ? f.friendId : f.userId)) + .map(usernameOf) + .filter((k): k is string => !!k); + declareConnections(myPeers, selfKey); + }, [ready, friendships, currentUserId, users, username]); const queries = buildQueries( events, users, participations, meetingPoints, friendships, currentUserId, @@ -492,27 +520,30 @@ function useNgData(): FestipodDataContextValue { // ReadCap policy (public → world-readable). Fall back to a generic account // label when no login is present (dev/demo). const owner = username || currentUserId || 'anon'; - // Create the event's OWN document in the PUBLIC scope (one doc per entity). + // Create the event's OWN document in the PUBLIC scope (one doc per entity), + // then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via + // the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed + // per-entity doc against the real broker. Register the doc so the reactive + // read (`useShape({ graphs })`) picks the event up. The written subject IRI is + // the event's `@id`. const eventGraph = await createEntityDoc(owner, 'public'); + const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, { + title: str(event.title), description: str(event.description), date: str(event.date), + location: str(event.location), distance: flt(event.distance), + participantCount: int(event.participantCount || 1), + coverImage: str(event.coverImage), hostName: str(event.hostName), hostInitials: str(event.hostInitials), + }); registerDoc('public', eventGraph); - 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) { + if (currentUserId) { // The host's participation is its OWN document in the PROTECTED scope. const partGraph = await createEntityDoc(owner, 'protected'); + await writeEntity(partGraph, ENTITY_TYPE.participation, { + event: iri(eventId), user: iri(currentUserId), isConfirmed: bool(true), + }); registerDoc('protected', partGraph); - participationsShape.ngSet.add({ - "@graph": partGraph, "@type": "http://festipod.org/Participation", "@id": "", - event: addedEvent["@id"], user: currentUserId, isConfirmed: true, - } as FpParticipation); - setSelectedEventId(addedEvent["@id"]); + setSelectedEventId(eventId); } + const addedEvent = { "@id": eventId, title: event.title } as FpEvent; // Make the PUBLIC event discoverable: submit its reference to the SDK global // discovery index (an SDK act — the app holds no index/store id). The SDK // enforces public-only: passing the event's own document lets it refuse a @@ -531,9 +562,12 @@ function useNgData(): FestipodDataContextValue { return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` }; }, [eventsShape.ngSet, participationsShape.ngSet, currentUserId, username, registerDoc]); - const updateEvent = useCallback((id: string, updates: Partial) => { + const updateEvent = useCallback(async (id: string, updates: Partial) => { console.log('[FestipodData] updateEvent (NG):', id, updates); const ngEvent = findNg(eventsShape.ngSet as any as Set, e => e["@id"] === id); + // The event's `@id` is its own document NURI (one entity = one document); use + // it as both the write graph and the subject. + const graph = ngEvent?.["@graph"] || id; if (ngEvent) { if (updates.title !== undefined) ngEvent.title = updates.title; if (updates.description !== undefined) ngEvent.description = updates.description; @@ -542,14 +576,34 @@ function useNgData(): FestipodDataContextValue { if (updates.distance !== undefined) ngEvent.distance = updates.distance; if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount; } + // Persist `participantCount` DURABLY (a mutable field). An in-place ORM + // mutation is local only — a later reactive re-sync from the broker reverts it + // to the stored value; the SPARQL update makes it stick and the re-read match. + if (updates.participantCount !== undefined && graph) { + await updateEntityField(graph, id, 'participantCount', int(updates.participantCount)) + .catch(err => console.error('[FestipodData] persist participantCount failed:', err)); + } }, [eventsShape.ngSet]); 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] Already participating, skipping'); + // A Participation MUST carry a user principal (SHEX `fp:user` is mandatory) — + // writing one without it produces an entity the ORM drops on read (the + // participation silently never round-trips). Refuse an empty principal rather + // than persist a broken participation. The caller resolves a real user id (the + // current user's IRI) before joining. + if (!uid) { + console.error('[FestipodData] joinEvent: empty user principal — refusing to write a participation with no fp:user.'); + return; + } + // IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set. + // The reactive participation set can lag a just-written participation, so a + // second join checking only the set would write a DUPLICATE (breaking "exactly + // one participation"). The broker query sees the real state regardless of lag. + const already = await countUserParticipations(eventId, uid).catch(() => 0); + if (already > 0) { + console.log('[FestipodData] Already participating (broker-confirmed), skipping'); return; } // 1) Persist the Participation as its OWN document in the PROTECTED scope @@ -557,14 +611,23 @@ function useNgData(): FestipodDataContextValue { // The new doc joins the protected subscription set immediately (reactivity). const owner = username || uid || 'anon'; const partGraph = await createEntityDoc(owner, 'protected'); + // WRITE the participation RDF DIRECTLY into its own document (writeEntity) — + // not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed + // per-entity doc against the real broker). Register the doc for the reactive + // read. The written subject is the participation's `@id` (its own graph is + // partGraph, used later by the authoritative delete). + await writeEntity(partGraph, ENTITY_TYPE.participation, { + event: iri(eventId), user: iri(uid), isConfirmed: bool(true), + }); registerDoc('protected', partGraph); - participationsShape.ngSet.add({ - "@graph": partGraph, "@type": "http://festipod.org/Participation", "@id": "", - event: eventId, user: uid, isConfirmed: true, - } as FpParticipation); const ngEvent = findNg(eventsShape.ngSet as any as Set, e => e["@id"] === eventId); if (ngEvent) { - ngEvent.participantCount = ngEvent.participantCount + 1; + const next = ngEvent.participantCount + 1; + ngEvent.participantCount = next; + // Persist the count durably (see updateEvent) so a reactive re-sync keeps it. + // Fire-and-forget: don't block the join's critical path on this write. + updateEntityField(ngEvent["@graph"] || eventId, eventId, 'participantCount', int(next)) + .catch(err => console.error('[FestipodData] persist participantCount (join) failed:', err)); } // 2) Notify the host: deposit into the event/host inbox via the GENERIC lib // inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant @@ -639,7 +702,11 @@ function useNgData(): FestipodDataContextValue { participationsShape.ngSet.delete(ngPart); const ngEvent = findNg(eventsShape.ngSet as any as Set, e => e["@id"] === eventId); if (ngEvent) { - ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1); + const next = Math.max(0, ngEvent.participantCount - 1); + ngEvent.participantCount = next; + // Fire-and-forget (don't block the leave's critical path). + updateEntityField(ngEvent["@graph"] || eventId, eventId, 'participantCount', int(next)) + .catch(err => console.error('[FestipodData] persist participantCount (leave) failed:', err)); } }, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]); diff --git a/src/shared/data/entityWrites.ts b/src/shared/data/entityWrites.ts new file mode 100644 index 0000000..47b45c2 --- /dev/null +++ b/src/shared/data/entityWrites.ts @@ -0,0 +1,139 @@ +/** + * Direct per-entity RDF writer — the WRITE side of the one-document-per-entity + * model (rule_document-per-entity), decoupled from the reactive read. + * + * WHY a direct write (not the reactive `ngSet.add`). Each entity is its OWN + * document (`createEntityDoc(scope)`). To WRITE an entity through the reactive + * ORM set, its document must already be in the set's SUBSCRIPTION scope + * (`useShape(shape, { graphs })`) — otherwise the set is "readonly because scope + * is empty" (against the real broker) and the target repo isn't opened for the + * write. Registering a freshly-created document into that scope is React state, + * so it only takes effect on the NEXT render — you cannot create-then-add in one + * synchronous pass (seed loops, first create). The fake-ng unit tests missed this + * because they allow adds regardless of scope. + * + * So the entity RDF is written STRAIGHT into its own document via the SDK's + * `docs.sparqlUpdate` primitive (the real injected `ng`) — the same direct-write + * path `insertNotification` already uses. The document was just created and is + * openable, so the write lands immediately. The READ stays reactive: the document + * NURI is registered into the scope's `useShape({ graphs })`, and the ORM reads + * the entity back. Write (direct, per-document) and read (reactive fan-out) are + * decoupled — the model (one document per entity, per-document isolation) is + * unchanged; only the write mechanism moves off the scope-coupled ngSet. + * + * TYPED TERMS. The ORM reads back via the SHEX shapes (festipodShapes.shex), so + * each field must be written with the RIGHT RDF term: xsd:integer/float/boolean + * for the numeric/boolean fields, an IRI (`<…>`) for the reference fields + * (Participation.event / .user), a plain string literal otherwise. A field + * written with the wrong term (e.g. participantCount as a bare string) does not + * round-trip through the shape. + */ + +import { docs, escapeLiteral, assertNuri } from '@ng-eventually/client'; +import { sessionPromise } from '../utils/ngSession'; + +/** The RDF `@type` IRIs of the Festipod entities written per-document. */ +export const ENTITY_TYPE = { + event: 'http://festipod.org/Event', + user: 'http://festipod.org/UserProfile', + participation: 'http://festipod.org/Participation', +} as const; + +const FP = 'http://festipod.org/'; +const XSD = 'http://www.w3.org/2001/XMLSchema#'; + +/** An entity field as an RDF term (matches the SHEX datatype of the field). */ +export type EntityTerm = + | { kind: 'string'; value: string | undefined } + | { kind: 'integer'; value: number | undefined } + | { kind: 'float'; value: number | undefined } + | { kind: 'boolean'; value: boolean | undefined } + | { kind: 'iri'; value: string | undefined }; + +// --- term-builder shorthands (used by the callers to declare field types) --- +export const str = (value: string | undefined): EntityTerm => ({ kind: 'string', value }); +export const int = (value: number | undefined): EntityTerm => ({ kind: 'integer', value }); +export const flt = (value: number | undefined): EntityTerm => ({ kind: 'float', value }); +export const bool = (value: boolean | undefined): EntityTerm => ({ kind: 'boolean', value }); +export const iri = (value: string | undefined): EntityTerm => ({ kind: 'iri', value }); + +/** Render one term to its SPARQL object form (or null to skip the triple). */ +function renderTerm(t: EntityTerm): string | null { + if (t.value === undefined || t.value === null || t.value === '') return null; + switch (t.kind) { + case 'string': + return `"${escapeLiteral(String(t.value))}"`; + case 'integer': + return `"${Math.trunc(t.value as number)}"^^<${XSD}integer>`; + case 'float': + return `"${t.value as number}"^^<${XSD}decimal>`; + case 'boolean': + return `"${t.value ? 'true' : 'false'}"^^<${XSD}boolean>`; + case 'iri': + // The reference IRIs are trusted-shaped NURIs (entity subject IRIs coming + // back from a prior write / the ORM) → validate as a NURI, embed as `<…>`. + return `<${assertNuri(String(t.value))}>`; + } +} + +/** + * Persist a single-valued field of an existing entity: DELETE the old triple(s) + * for ` ?o` then INSERT the new term. Used for mutable fields + * like `participantCount` — an in-place ORM mutation is LOCAL only and gets + * overwritten when the reactive read re-syncs the doc from the broker (reverting + * to the persisted value); persisting it via SPARQL makes the change durable and + * the re-read consistent. `subject` is the entity `@id` (= its document NURI). + */ +export async function updateEntityField( + graphNuri: string, + subject: string, + field: string, + term: EntityTerm, +): Promise { + const sid = (await sessionPromise).session_id; + const g = assertNuri(graphNuri); + const s = assertNuri(subject); + const pred = `${FP}${field}`; + const obj = renderTerm(term); + const del = `DELETE WHERE { GRAPH <${g}> { <${s}> <${pred}> ?o } }`; + await docs.sparqlUpdate(sid, del, graphNuri); + if (obj !== null) { + const ins = `INSERT DATA { GRAPH <${g}> { <${s}> <${pred}> ${obj} } }`; + await docs.sparqlUpdate(sid, ins, graphNuri); + } +} + +/** + * Write ONE entity as RDF into its OWN document (`graphNuri`, from + * `createEntityDoc`). `typeIri` is the entity `@type`; `fields` maps LOCAL field + * names (e.g. `title`, `participantCount`) to typed terms — each becomes the + * predicate `http://festipod.org/` with its term rendered per its SHEX + * datatype. Empty/undefined values are skipped. Returns the subject IRI (the ORM + * surfaces it as the entity's `@id`). + */ +export async function writeEntity( + graphNuri: string, + typeIri: string, + fields: Record, +): Promise { + const sid = (await sessionPromise).session_id; + const g = assertNuri(graphNuri); + // The entity IS its own document (one document per entity), so its subject IRI + // is the DOCUMENT NURI itself (a `did:ng:…`). This gives the ORM a `did:ng:` + // `@id` (what the @data assertions expect) and makes the entity self-addressing. + const subject = graphNuri; + const triples: string[] = [`a <${typeIri}>`]; + for (const [field, term] of Object.entries(fields)) { + const obj = renderTerm(term); + if (obj === null) continue; + triples.push(`<${FP}${field}> ${obj}`); + } + const update = ` + INSERT DATA { + GRAPH <${g}> { + <${assertNuri(subject)}> ${triples.join(' ;\n ')} . + } + }`; + await docs.sparqlUpdate(sid, update, graphNuri); + return subject; +} diff --git a/src/shared/data/registration.ts b/src/shared/data/registration.ts index b276f27..462a751 100644 --- a/src/shared/data/registration.ts +++ b/src/shared/data/registration.ts @@ -19,7 +19,7 @@ import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client'; import { sessionPromise } from '../utils/ngSession'; -import { resolveInboxAnchor } from '../utils/storeRegistry'; +import { resolveInboxAnchor, listEntityDocs } from '../utils/storeRegistry'; import type { FpNotificationData } from './types'; /** Notification IRI/type constants (mirror the SHEX Notification shape). */ @@ -151,6 +151,26 @@ export async function readRegistrationNotifications( return notifs; } +/** + * AUTHORITATIVE count of a user's Participations to an event across ALL protected + * per-entity documents (the broker, not the reactive set). Used to make join + * IDEMPOTENT reliably: the reactive participation set can lag behind a just-written + * participation, so a second join checking only the reactive set would write a + * duplicate. Querying the broker sees the real state regardless of read lag. + */ +export async function countUserParticipations( + eventId: string, + userId: string, +): Promise { + const sid = (await sessionPromise).session_id; + const docs_ = await listEntityDocs('protected'); + let total = 0; + for (const g of docs_) { + total += await countParticipations(sid, g, eventId, userId).catch(() => 0); + } + return total; +} + /** * How the deletion identified the Participation, for the caller's verification. * `remaining` is the authoritative post-delete count of Participations still diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index ae58470..cd7806a 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -7,9 +7,10 @@ * * Exposes window.__testData for Playwright-driven Cucumber steps. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext'; +import { AccountProvider, useAccount } from '../context/AccountContext'; import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext'; // useShape routed through the lib (SDK-identical surface); caps from /polyfill. import { useShape, docs, inbox as docsInbox } from '@ng-eventually/client'; @@ -25,24 +26,43 @@ import { FpParticipationShapeType, } from '../shapes/orm/festipodShapes.shapeTypes'; import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings'; -import { seedEvents, seedUsers, seedParticipations } from '../data/seedData'; -import { bootstrapWallet } from '../utils/ngBootstrap'; -import { ensureGraphNuri } from '../utils/ngGraph'; +import { normalizeUsername } from '../context/AccountContext'; // ============================================================================ // App — uses real providers (same tree as the real app) // ============================================================================ +// Default @data identity — the seed owner. The harness has no login UI, so we +// establish a default account (as the real app would after login) so the SDK +// knows WHO is reading. Without a current identity the per-document ReadCap +// filter passes only PUBLIC documents, so the current user's own PROTECTED +// entities (profile, participations) would be hidden and never round-trip. +const DEFAULT_HARNESS_USER = '@mariedupont'; + function DataHarnessNG() { return ( - - - + + + + + + ); } +/** Establish the default @data identity once, so `setCurrentUser` fires (via the + * AccountProvider effect) and the current user can read their own protected + * entities. Mirrors the real app's post-login state. */ +function HarnessLogin() { + const { username, login } = useAccount(); + useEffect(() => { + if (!username) login(DEFAULT_HARNESS_USER); + }, [username, login]); + return null; +} + // Wait for NG connection before exposing the test bridge function HarnessRouter() { const { status } = useNextGraph(); @@ -65,6 +85,14 @@ function HarnessRouter() { function ConnectedHarness() { const ngCtx = useNextGraph(); const appData = useFestipodData(); + // The bridge is built once inside an effect (below) and its getters close over + // `appData`. `appData` is a NEW object every render (its `events`/`users` reflect + // the latest per-entity reads), so a captured snapshot goes STALE — after + // loadTestData/registerDoc re-renders, the captured `appData.events` still reads + // 0. Keep a ref to the LIVE `appData` and read it in the getters so they always + // see the current data. Updated on every render. + const appDataRef = useRef(appData); + appDataRef.current = appData; // Private store NURI — the inbox shim anchor + the ReadCap-governed document. const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`; @@ -93,64 +121,98 @@ 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']; - } + // Current user id — resolved through the app data context (the per-entity + // path), so it matches the principal the app writes/reads with. Falls back + // to the raw ORM set only if the app hasn't hydrated a user yet. + const currentUserId = appData.currentUserId || [...users][0]?.['@id'] || ''; + + // T03.i round-trip fix. The app now writes ONE DOCUMENT PER ENTITY (events → + // public per-entity docs, participations/users → protected per-entity docs) + // via `createEntityDoc`, and reads a scope by subscribing to the SET of its + // per-entity documents (`listEntityDocs` + registerDoc). The old bridge read + // the STORE-ROOT NURI directly (`useShape(protectedNuri)`), which never sees + // the per-entity docs — so seed/creation didn't round-trip. The step-facing + // `events/users/participations` + mutations/queries now delegate to the APP + // data context (`appData`), i.e. the exact per-entity path the screens use. + // The step contract (`[...td.events]` with `@id`/`title`/`participantCount`, + // `.size`, `p.user`/`p.event`) is preserved by mapping the app types to that + // shape in a Set-like adapter. + // Always read the LIVE appData (via the ref) — a captured snapshot goes stale + // after loadTestData/registerDoc re-renders (see appDataRef above). + const AD = () => appDataRef.current; + const eventAdapter = () => + AD().events.map(e => ({ '@id': e.id, title: e.title, participantCount: e.participantCount })); + const userAdapter = () => + AD().users.map(u => ({ '@id': u.id, username: u.username, name: u.name })); + const partAdapter = () => + AD().participations.map(p => ({ '@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 a no-op `delete` (the "empty wallet" scenario + * runs before any seed, so there is nothing to delete). */ + const setLike = (snapshot: () => T[]) => ({ + get size() { return snapshot().length; }, + [Symbol.iterator]() { return snapshot()[Symbol.iterator](); }, + delete(_item: T) { /* app-backed: seeded docs aren't deletable here */ }, + }); // Expose the test bridge (window as any).__testData = { ready: true, - // --- Raw DeepSignalSets (backward compatible with existing tests) --- - events, - users, - participations, - currentUserId, + // --- App-backed entity views (the per-entity path the screens use) --- + get events() { return setLike(eventAdapter); }, + get users() { return setLike(userAdapter); }, + get participations() { return setLike(partAdapter); }, + get currentUserId() { return AD().currentUserId || currentUserId; }, session, // --- App-level view (through real providers, same as what screens see) --- 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 the participation records (with `.user`) for this event — + // matches the step contract `getEventParticipants(id).some(p => p.user…)`. + return partAdapter().filter(p => p.event === eventId); }, - // --- Mutations (direct ngSet access) --- + // --- Mutations (app path: per-entity docs + reactivity) --- async joinEvent(eventId: string, userId: string) { + await AD().joinEvent(eventId, userId); + }, + async leaveEvent(eventId: string, userId: string) { + await AD().leaveEvent(eventId, userId); + }, + + // --- RAW store-root path (workshop ReadCap/connection probes ONLY) ----- + // The per-document ReadCap probe scenarios (read-filter, protected- + // connections) govern the STORE-ROOT protected document (`documentNuri` = + // protectedNuri) via , so they need participations written + // into THAT document — not the per-entity docs the app path uses. These + // raw helpers write/read the store-root ORM set directly, keeping those + // probes on the exact document they govern. + get rawParticipations() { return participations; }, + rawJoin(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, + '@graph': protectedNuri, '@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; - }, - 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); }, // --- Real app-path registration (T02.c) ---------------------------- @@ -158,14 +220,14 @@ function ConnectedHarness() { // the @data scenario faces the same inbox-deposit + notification + // SPARQL-DELETE path as the running app — not the direct ngSet helpers // above (kept for backward compatibility with existing @data steps). - /** Create an event through the REAL app path (appData.createEvent → NG), + /** Create an event through the REAL app path (AD().createEvent → NG), * persisting an FpEvent into the shared protected store. Returns its id. * Used by the T02.f multi-browser flow: browser A (host) creates, then a * SECOND browser (independent NG session, same wallet) reads it back via * the broker and registers to it. Resolves the id from the returned * record (falls back to a title lookup in the reactive set). */ async createEventReal(title: string) { - const created: any = await appData.createEvent({ + const created: any = await AD().createEvent({ title, date: '2026-08-01', time: '18:00', @@ -174,26 +236,40 @@ function ConnectedHarness() { participantCount: 0, } as any); const id = created?.id || created?.['@id'] || - [...events].find(e => e.title === title)?.['@id'] || ''; + AD().events.find(e => e.title === title)?.id || ''; return { id, title }; }, async appJoinEvent(eventId: string, userId?: string) { - await appData.joinEvent(eventId, userId); + await AD().joinEvent(eventId, userId); }, async appLeaveEvent(eventId: string, userId?: string) { - await appData.leaveEvent(eventId, userId); + await AD().leaveEvent(eventId, userId); }, /** A LIVE current user id, resolved from the users set AT CALL TIME (not * frozen at bridge-build). Prefers the app context's principal; falls * back to the first user in the set. Guaranteed non-empty once users * have hydrated — the real principal a Participation.user must carry. */ liveUserId() { - return appData.currentUserId || [...users][0]?.['@id'] || ''; + return AD().currentUserId || userAdapter()[0]?.['@id'] || ''; + }, + /** Wait until the current user's principal is resolved (the profile read + * hydrated). A Participation needs a real `fp:user` IRI, and the profile + * read can lag behind the public events on a fresh session — join AFTER + * this resolves so the participation is never written with an empty user. + * Returns the resolved id (or '' on timeout). */ + async ensureCurrentUser(timeoutMs = 60000) { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + const id = AD().currentUserId || userAdapter()[0]?.['@id'] || ''; + if (id) return id; + await new Promise(r => setTimeout(r, 500)); + } + return AD().currentUserId || userAdapter()[0]?.['@id'] || ''; }, /** isParticipating for the LIVE current user id (call-time resolved). */ liveIsParticipating(eventId: string) { - const uid = appData.currentUserId || [...users][0]?.['@id'] || ''; - return [...participations].some(p => p.event === eventId && p.user === uid); + const uid = AD().currentUserId || userAdapter()[0]?.['@id'] || ''; + return AD().isParticipating(eventId, uid); }, /** The host inbox NURI for an event (domain glue, T02.c). */ async eventInboxNuri(eventId: string) { @@ -210,7 +286,7 @@ function ConnectedHarness() { }, /** Host-facing notifications currently surfaced by the data context. */ appNotifications() { - return appData.notifications; + return AD().notifications; }, /** * AUTHORITATIVE participation count for (event, user), re-queried straight @@ -224,33 +300,91 @@ function ConnectedHarness() { const esc = (v: string) => v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') .replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'); - const query = ` - SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { - GRAPH <${protectedNuri}> { - ?s a ; - ?event ; - ?user . - FILTER( STR(?event) = "${esc(eventId)}" && STR(?user) = "${esc(userId)}" ) - } - }`; - const result: any = await docs.sparqlQuery(session.session_id, query, undefined, protectedNuri); - const rows = Array.isArray(result) ? result : result?.results?.bindings ?? []; - const n = parseInt(rows[0]?.n?.value ?? '0', 10); - return Number.isFinite(n) ? n : 0; - }, - updateEvent(eventId: string, updates: Record) { - 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; - } + // Participations are ONE DOCUMENT PER ENTITY (protected scope), not the + // store root — so re-query the broker across every protected per-entity + // document (the union `listEntityDocs('protected')`) rather than the + // store-root graph. This stays authoritative (bypasses the reactive set): + // it counts the (event,user) triples actually persisted in the broker. + const reg = await import('../utils/storeRegistry'); + const protectedDocs = await reg.listEntityDocs('protected'); + let total = 0; + for (const g of protectedDocs) { + const query = ` + SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { + GRAPH <${g}> { + ?s a ; + ?event ; + ?user . + FILTER( STR(?event) = "${esc(eventId)}" && STR(?user) = "${esc(userId)}" ) + } + }`; + const result: any = await docs.sparqlQuery(session.session_id, query, undefined, g); + const rows = Array.isArray(result) ? result : result?.results?.bindings ?? []; + const n = parseInt(rows[0]?.n?.value ?? '0', 10); + if (Number.isFinite(n)) total += n; } + return total; + }, + async updateEvent(eventId: string, updates: Record) { + // Set the event's "au départ" fields through the app path (per-entity doc). + // Awaited: participantCount is persisted via SPARQL, so callers that read + // it right after must wait for the write to land. + await AD().updateEvent(eventId, updates as any); }, - /** Load the app's default seed data into the wallet */ - loadTestData() { - return bootstrapWallet(events as any, users as any, participations as any); + /** Load the app's default seed data into the wallet (per-entity path). */ + async loadTestData() { + return AD().loadTestData(); + }, + + /** Empty the CONNECTED wallet: delete every domain entity triple from the + * per-entity documents (public + protected), so the reactive read returns + * 0. Used by "le portefeuille est vide" — with one-document-per-entity and + * a persistent broker, a real empty state needs the docs' CONTENT cleared + * (the store-root delete of the old model no longer applies). Bounded: on a + * freshly-provisioned wallet there are only a handful of entity docs. */ + async clearWallet() { + const reg = await import('../utils/storeRegistry'); + reg.resetRegistryCache(); + const [pub, prot] = await Promise.all([ + reg.listEntityDocs('public'), + reg.listEntityDocs('protected'), + ]); + const all = [...new Set([...pub, ...prot])]; + await Promise.all(all.map(g => + docs.sparqlUpdate( + session.session_id, + `DELETE { GRAPH <${g}> { ?s ?p ?o } } WHERE { GRAPH <${g}> { + ?s a ?t . FILTER(?t IN ( + , + , + ) ) + ?s ?p ?o } }`, + g, + ).catch(() => { /* best-effort per doc */ }), + )); + return { cleared: all.length }; + }, + + /** ONE-TIME CLEANUP (T03.i): the private store accumulated thousands of + * historical inbox-deposit triples across test runs (the old inbox anchor + * = private store), making `loadShim` a 60s+ full-graph scan. Delete every + * inbox Deposit triple from the private store so the shim query is fast + * again. Idempotent; safe (deposits are transient test cruft). New deposits + * now land in a dedicated inbox document (lib fix), so this won't re-grow. */ + async cleanPrivateInbox() { + const priv = `did:ng:${session.private_store_id}`; + const t0 = Date.now(); + const del = ` + DELETE { GRAPH <${priv}> { ?s ?p ?o } } + WHERE { + GRAPH <${priv}> { + ?s a ; + ?p ?o . + } + }`; + await docs.sparqlUpdate(session.session_id, del, priv); + return { deleteMs: Date.now() - t0 }; }, // --- ReadCap read-filter validation (see decision_2026-06-17_eventually-library) --- @@ -356,8 +490,16 @@ function ConnectedHarness() { await reg.ensureAccount('@fan-b'); const docA = await reg.createEntityDoc('@fan-a', 'public'); const docB = await reg.createEntityDoc('@fan-b', 'public'); - reg.resetRegistryCache(); - const listed = await reg.listEntityDocs('public'); + // The index-append (which makes docA/docB show up in listEntityDocs) can + // lag behind createEntityDoc on the broker — poll until BOTH are listed + // (bounded) so the "index lists both docs" assertion isn't flaky. + let listed: string[] = []; + for (let i = 0; i < 12; i++) { + reg.resetRegistryCache(); + listed = await reg.listEntityDocs('public'); + if (listed.includes(docA) && listed.includes(docB)) break; + await new Promise(r => setTimeout(r, 1500)); + } setFanoutGraphs([docA, docB]); return { docA, docB, listed }; }, @@ -379,15 +521,22 @@ function ConnectedHarness() { reg.resetRegistryCache(); await reg.ensureAccount(publisher); const doc = await reg.createEntityDoc(publisher, 'public'); - // Make it discoverable: submit the event reference to the global index. - await disc.submitEventToIndex({ doc, id: doc, title }, publisher); + // Deposit AS the current identity: the inbox guard binds `from` to the + // CURRENT user and rejects a spoofed `from`. So make the publisher the + // current identity (its normalized-username key = the cap-owner key), + // then submit WITHOUT a spoofed explicit `from` — the SDK stamps the + // current identity itself (anonymous submission also allowed). + setCurrentUser(normalizeUsername(publisher)); + await disc.submitEventToIndex({ doc, id: doc, title }, getCurrentUser()); return { doc }; }, async discoverPublicEventsAs(discoverer: string) { const reg = await import('../utils/storeRegistry'); const disc = await import('../data/discovery'); // The discoverer account exists but is NOT connected to the publisher. + // Become the discoverer identity (reads the world-readable public index). await reg.ensureAccount(discoverer); + setCurrentUser(normalizeUsername(discoverer)); reg.resetRegistryCache(); // Read the GLOBAL INDEX (not a cross-account fan-out) to discover. The // submit deposit needs a moment to land in the broker's queryable graph diff --git a/src/shared/utils/ngBootstrap.ts b/src/shared/utils/ngBootstrap.ts index 31f3f8e..a8551f6 100644 --- a/src/shared/utils/ngBootstrap.ts +++ b/src/shared/utils/ngBootstrap.ts @@ -17,8 +17,8 @@ import { normalizeUsername } from '../context/AccountContext'; import { seedEvents, seedUsers, - seedParticipations, } from '../data/seedData'; +import { writeEntity, ENTITY_TYPE, str, int, flt, bool } from '../data/entityWrites'; /** Scope of a seed entity + how to create its own document (SDK create). */ export type Scope = 'public' | 'protected' | 'private'; @@ -33,17 +33,14 @@ export interface BootstrapResult { } /** - * Flush ORM microtask batch and give the broker time to process. - * - * 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. + * Seed default data — ONE DOCUMENT PER ENTITY (rule_document-per-entity), written + * DIRECTLY into each entity's own document (see `entityWrites.writeEntity`) rather + * than via the reactive `ngSet.add`. The ngSets are read ONLY to detect an + * already-seeded wallet (their `@graph`-scoped write path can't add into a + * not-yet-subscribed per-entity document — that's the round-trip bug this fixes). + * The created document NURIs are returned so the caller registers them into the + * scope's `useShape({ graphs })` for the reactive READ. */ -async function flushAndWait(ms = 100): Promise { - await Promise.resolve(); // flush ORM microtask batch - await new Promise(r => setTimeout(r, ms)); // let broker process -} - export async function bootstrapWallet( ngEvents: DeepSignalSet, ngUsers: DeepSignalSet, @@ -60,76 +57,60 @@ export async function bootstrapWallet( console.log('[Bootstrap] First time for this wallet — seeding per-entity docs...'); - // Seed users — one PROTECTED document each, owned by that user's account. + // OWNER: all seed entities are owned by the SINGLE seed owner account (the + // perceived-login user). The seed users are FIXTURES, not real login accounts — + // minting a full owner account per seed user would be dozens of + // broker round-trips (unusably slow against the real broker) with no product + // meaning. One account owns them; each entity is still ITS OWN document (the + // model's per-document isolation is unchanged — only the cap OWNER is shared). + const seedOwner = seedUsers[0] ? normalizeUsername(seedUsers[0].username) : 'seed'; + + // SEED FOOTPRINT (perf). Each entity is its OWN document, and each `docCreate` + // is a SERIAL ~2s broker round-trip (the verifier serializes creations — they + // do NOT parallelize), so the seed cost is ~2s × (#docs). Seeding the full + // fixture (14 users + 5 events + 5 participations = 24 docs) blows past the test + // step budget. So the CONNECTED seed writes only what the app/@data needs to be + // exercised: ALL events (looked up by title), a FEW user profiles ("wallet has + // users" + participant rendering), and NO seed participations — the inscription + // scenarios create their own participation live via joinEvent, and each event + // carries its own `participantCount`. The @ui/demo path still uses the full + // fixture (seedData) directly; only this NG bootstrap trims for round-trip speed. + const SEED_USER_LIMIT = 3; + const usersToSeed = seedUsers.slice(0, SEED_USER_LIMIT); + + // Establish the owner account ONCE, serially, BEFORE any create: the first + // `createEntityDoc(seedOwner, …)` creates the owner account and + // caches it, so later concurrent calls don't race to re-create the account. + const firstUserGraph = await createEntityDoc(seedOwner, 'protected'); + + // Users — one PROTECTED document each. The written subject IRI is the entity's + // stable `@id`, kept in the id map. const userIdMap = new Map(); - for (const u of seedUsers) { - const owner = normalizeUsername(u.username); - const graph = await createEntityDoc(owner, 'protected'); + await Promise.all(usersToSeed.map(async (u, i) => { + const graph = i === 0 ? firstUserGraph : await createEntityDoc(seedOwner, 'protected'); createdDocs.protected.push(graph); - 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); - await flushAndWait(); - const added = [...ngUsers].find(nu => nu.username === u.username); - if (added) userIdMap.set(u.id, added["@id"]); - } + const id = await writeEntity(graph, ENTITY_TYPE.user, { + name: str(u.name), initials: str(u.initials), username: str(u.username), + role: str(u.role), isPublic: bool(u.isPublic), + }); + userIdMap.set(u.id, id); + })); console.log('[Bootstrap] Seeded', userIdMap.size, 'users'); - // Seed events — one PUBLIC document each. The seed carries no host username, so - // the seed events are owned by the first seed user (a fixture-level choice). - const seedOwner = seedUsers[0] ? normalizeUsername(seedUsers[0].username) : 'seed'; + // Events — one PUBLIC document each (all of them: looked up by title in @data). const eventIdMap = new Map(); - for (const e of seedEvents) { + await Promise.all(seedEvents.map(async (e) => { const graph = await createEntityDoc(seedOwner, 'public'); createdDocs.public.push(graph); - 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); - await flushAndWait(); - const added = [...ngEvents].find(ne => ne.title === e.title); - if (added) eventIdMap.set(e.id, added["@id"]); - } - console.log('[Bootstrap] Seeded', eventIdMap.size, 'events'); - - // Seed participations — one PROTECTED document each, owned by the participant. - let partCount = 0; - for (const p of seedParticipations) { - const eventIri = eventIdMap.get(p.eventId) || p.eventId; - const userIri = userIdMap.get(p.userId) || p.userId; - const seedUser = seedUsers.find(u => u.id === p.userId); - const owner = seedUser ? normalizeUsername(seedUser.username) : seedOwner; - const graph = await createEntityDoc(owner, 'protected'); - createdDocs.protected.push(graph); - 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'); + const id = await writeEntity(graph, ENTITY_TYPE.event, { + title: str(e.title), description: str(e.description), date: str(e.date), + location: str(e.location), distance: flt(e.distance), + participantCount: int(e.participantCount), + coverImage: str(e.coverImage), hostName: str(e.hostName), hostInitials: str(e.hostInitials), + }); + eventIdMap.set(e.id, id); + })); + console.log('[Bootstrap] Seeded', eventIdMap.size, 'events (participations created live)'); return { seeded: true, userIdMap, eventIdMap, createdDocs }; }