From 82004a30b0b71b31a0307b8feab959f7d2d41133 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 13 Jul 2026 16:40:37 +0200 Subject: [PATCH] =?UTF-8?q?fix(data):=20participantCount=20fiable=20=C3=A0?= =?UTF-8?q?=20la=20connexion=20du=20propri=C3=A9taire=20+=20source=20uniqu?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: user2 crée un événement, user1 s'inscrit et voit "1", mais user2 (créateur) reste à 0. Recadrage (spec existante): l'exigence est "le propriétaire traite son inbox à sa PROCHAINE CONNEXION", pas une notif live temps-réel. Cause: le owner-materializer lisait l'inbox AVANT sa synchronisation → active=0 → écrit 0 → mémoïse 0 → ne retraite plus. Fix: - Lecture inbox gated sur barrière: inbox.readSynced (ensureRepoOpen attend le 1er State, puis read — comme discovery.readIndex) au lieu de inbox.read. Un dépôt déjà synchronisé EST vu à la connexion. Pas de polling. - Materializer déclenché directement à la connexion ([ready, ownedKey]). - materializedCountRef ne verrouille plus un 0 prématuré (rôle = anti-boucle seul). - Source UNIQUE du nombre = event.participantCount: le littéral participantCount:1 de CreateEventScreen retiré (démarre à 0), l'affichage ne calcule plus de nombre local (ParticipantsListScreen). Le statut "Je participe" optimiste est intact. - Logs [Attendance] sur tout le chemin dépôt→matérialisation→écriture. Test: e2e-multibrowser "converge à la prochaine connexion" reframé + dé-@wip, ROUGE avant / VERT après sur profil frais. Non-régression @multibrowser 4/4, @data 7/7. Doctrine: knowledge_context-internals (caveat BUG ACTIF → CORRIGÉ), brief_2026-07-06 (cadrage "sans reload" = sur-cadrage; exigence = fiable à la connexion). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-07-06_reactive-reads-and-attendance.md | 2 +- .../data-layer/knowledge_context-internals.md | 4 +- .../event/features/e2e-multibrowser.feature | 77 ++++++++----------- .../event/screens/CreateEventScreen.tsx | 7 +- .../event/screens/ParticipantsListScreen.tsx | 7 +- .../steps/e2e/multibrowser-features.steps.ts | 63 ++++++++++++++- src/shared/context/FestipodDataContext.tsx | 65 +++++++++++++--- src/shared/data/registration.ts | 29 ++++++- 8 files changed, 187 insertions(+), 67 deletions(-) diff --git a/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md b/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md index 9df38f8..1f1e4ff 100644 --- a/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md +++ b/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md @@ -179,7 +179,7 @@ But : B s'inscrit → l'`EventDetailScreen` de A montre `participantCount` incr Autres points à trancher : -> ⚠️ **RÉFUTÉ (2026-07-13)** — l'affirmation ci-dessous « **Prouvé par l'e2e D.2 … 12 steps verts … A voit `participantCount === 2` sans reload** » est **FAUSSE**. Sur profil FRAIS, ce scénario réactif est `@wip` et ROUGE : le propriétaire (A) reste à `participantCount = 0` quand un inscrit (B) d'un autre verifier dépose. Le « vert » observé était un **artefact de wallet bloaté** (le backfill `listMyEntityDocs` faisait varier `ownedKey` et re-déclenchait le materializer par accident). Cause racine vérifiée : `inbox.watch`/`doc_subscribe` ne délivre **aucun Patch cross-session**, donc le materializer du propriétaire n'est jamais re-déclenché par un dépôt distant (il l'est seulement par `ownedKey`). Voir [[knowledge_context-internals]] §participantCount (caveat en tête). La convergence réactive de l'attendance reste **NON prouvée** ; ce brief ne doit PAS graduer tant que le push cross-session (côté lib) n'est pas corrigé + le scénario vert sur profil frais. +> ⚠️ **RECADRÉ + CORRIGÉ (2026-07-13).** L'affirmation ci-dessous « Prouvé par l'e2e D.2 … sans reload » était **FAUSSE** (le « vert » venait d'un wallet bloaté). Mais surtout le **cadrage « réactif / sans reload / push cross-session » était un SUR-CADRAGE** : la spec réelle est **« le propriétaire traite son inbox de façon fiable à sa PROCHAINE CONNEXION »** (pas de notification live temps-réel entre deux utilisateurs connectés). Bug corrigé sous ce cadrage : le materializer lisait l'inbox **avant sa sync** (→ 0 mémoïsé). Fix = lecture inbox **gated sur barrière** (`inbox.readSynced` = `ensureRepoOpen` + `read`) + déclenchement à la connexion + source unique `event.participantCount`. Scénario `event/e2e-multibrowser.feature` **reframé « à la prochaine connexion » et dé-`@wip`, VERT sur profil frais** (une reconnexion/re-matérialisation de A est le mécanisme accepté). Détail : [[knowledge_context-internals]] §participantCount. Le plan de phasage ci-dessous doit être relu à cette lumière (le « sans reload » n'est plus l'exigence). - **Ordre de phasage :** ~~(P1) lib : `subscribeDoc` + variante multi-doc + tests D.1~~ **FAIT (`c0498a6`)** ; ~~(P2) lib : remplacer `inbox.watch`/`discovery.watchIndex` par `doc_subscribe`~~ **FAIT (`c0498a6`)** ; ~~(P3) app : brancher la souscription par-doc dans `useNgData` (bumpRead poussé) + découverte réactive~~ **FAIT (branche `ng-eventually`, non commité)** — `useNgData` monte un effet `subscribeDocs(allReadDocs, …)` clé sur un join trié des NURIs (`readDocKey`, anti-boucle : un patch → `bumpRead` → re-`readUnion` ne change pas le set → pas de re-souscription ; le reset d'identité `prevOwnerRef` vide le set → `readDocKey=''` → cleanup unsubscribe, puis re-listing → re-souscription sur le set reconstruit) + un effet de découverte réactive `watchDiscoveredEvents()` (wrapper app sur `discovery.watchIndex`, déjà `doc_subscribe`) → `relist()`. `readUnion` reste le lecteur one-shot tolérant. **Prouvé par l'e2e D.2** (`e2e-multibrowser.feature`, scénario « Un participant apparaît réactivement… », @multibrowser @shared-wallet, 12 steps verts en isolation) : B s'inscrit → A voit `participantCount === 2` + un participant « inconnu » **sans reload ni action**, via `doc_subscribe` sur le doc public de l'événement (le join en P3 écrit encore ce compteur, cf. §B.5 — c'est ce qui valide P3 avant P4). ; (P4) app : Option B join (retirer le write compteur de l'inscrit, matérialisation propriétaire) ; (P5) app : Option B leave symétrique ; ~~(P6) e2e D.2~~ **FAIT avec P3** (le scénario réactif ci-dessus ; la symétrie désinscription réactive reste à ajouter avec P5). P1→P3 livrent la réactivité ; P4→P6 le compteur correct. On peut livrer P1–P3 avant P4–P6. - **Idempotence de la matérialisation** : le `uid` par-dépôt (`RegistrationPayload.uid`, `registration.ts:56`) est le pivot ; la (référence) enregistrée par le propriétaire doit être consultée avant tout incrément/décrément pour ne jamais double-compter (rejeu de sync) ni « ressusciter » un compte. diff --git a/.project/concepts/data-layer/knowledge_context-internals.md b/.project/concepts/data-layer/knowledge_context-internals.md index fb2cd2a..243d53f 100644 --- a/.project/concepts/data-layer/knowledge_context-internals.md +++ b/.project/concepts/data-layer/knowledge_context-internals.md @@ -1,6 +1,6 @@ --- type: knowledge -summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, auto-seed OPT-IN (FESTIPOD_AUTO_SEED, OFF par défaut), participantCount dérivé Option-B avec BUG ACTIF de convergence cross-session (propriétaire reste à 0), instrumentation useShapeQuery (spinner+timing), mutations no-op en mode local malgré le toast +summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, auto-seed OPT-IN (FESTIPOD_AUTO_SEED, OFF par défaut), participantCount dérivé Option-B (fiable à la connexion du propriétaire via lecture inbox gated sur barrière ; source unique = event.participantCount), instrumentation useShapeQuery (spinner+timing), mutations no-op en mode local malgré le toast last_checked: 2026-07-07 --- @@ -47,7 +47,7 @@ wallet vide → re-seed à chaque reconnexion (bug corrigé). Pièges restants : ## `participantCount` — dérivé et possédé par le propriétaire (Option B) -> ⚠️ **BUG ACTIF (vérifié 2026-07-13) — la convergence réactive décrite ci-dessous NE MARCHE PAS cross-session.** Symptôme : user2 crée un événement, user1 s'inscrit et voit le participant, mais user2 (le propriétaire) reste à `participantCount = 0`. **Cause racine** : le owner-materializer n'est re-déclenché que quand sa dépendance React `ownedKey` change (backfill `listMyEntityDocs`) — **JAMAIS par un push d'inbox**. Le dépôt de l'inscrit ARRIVE bien dans le verifier du propriétaire (une lecture directe le PULL), mais `inbox.watch`/`doc_subscribe` ne délivre **aucun Patch pour un dépôt cross-session** (autre verifier). Donc sur wallet propre (un seul event possédé, `ownedKey` stable), le propriétaire matérialise UNE fois avant le dépôt → lit `active=0` → écrit 0 → **mémoïse 0** (`materializedCountRef`) → ne recalcule plus. Le **bloat de wallet MASQUAIT** le bug : le backfill faisait varier `ownedKey` en boucle → re-déclenchait par accident (« faux-vert »). Régression documentée ROUGE par le scénario réactif `@wip` de `event/e2e-multibrowser.feature`. Fix côté lib (push d'inbox cross-session fiable sans re-poll broker) + lever la mémoïsation prématurée du `0` — non appliqué (design-sensible). Le reste de cette section décrit le design VISÉ, correct pour le cas same-session. +> ✅ **CORRIGÉ (2026-07-13).** L'exigence est **« fiable à la PROCHAINE CONNEXION du propriétaire »** (le créateur traite son inbox à sa connexion), PAS une notification live cross-utilisateur temps-réel. Le bug était : le owner-materializer matérialisait **trop tôt** (avant que le dépôt de l'inscrit soit synchronisé) → lisait `active=0` → écrivait 0 → **mémoïsait ce 0** → ne retraitait plus. Fix : (1) **lecture inbox gated sur barrière** — `inbox.readSynced` (= `ensureRepoOpen(doc)` attend le premier `State`, PUIS `read`, comme `discovery.readIndex`) au lieu de `inbox.read`, donc un dépôt déjà synchronisé EST vu à la connexion ; (2) le materializer se déclenche **directement à la connexion** (`[ready, ownedKey]`), plus seulement sur un push ; (3) `materializedCountRef` ne verrouille plus un 0 prématuré (son seul rôle = anti-boucle : n'écrire que si la valeur dérivée change) ; (4) **source unique du NOMBRE = `event.participantCount`** (le littéral `participantCount: 1` de `CreateEventScreen` est retiré → démarre à 0 ; l'affichage ne calcule plus de nombre local). Gardé VERT (profil frais) par `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (dé-`@wip`). Pas de polling ([[rule_no-broker-polling]]). **Depuis Option B (2026-07-07)** : `participantCount` n'est plus muté en place par l'inscrit. Le flux est dépôt-inbox → matérialisation-propriétaire : - `joinEvent`/`leaveEvent` n'écrivent **plus** `participantCount` sur le doc de l'événement (ce serait une violation d'isolation — l'inscrit écrirait le doc d'un autre ; le write NextGraph est membership-bound, pas d'append). L'inscrit écrit seulement son **propre** doc de participation (protected) puis **dépose** un marqueur dans l'inbox de l'événement (`depositRegistration` sur join, `depositLeave` sur leave, `src/shared/data/registration.ts`). diff --git a/src/modules/event/features/e2e-multibrowser.feature b/src/modules/event/features/e2e-multibrowser.feature index e8717d0..d722451 100644 --- a/src/modules/event/features/e2e-multibrowser.feature +++ b/src/modules/event/features/e2e-multibrowser.feature @@ -51,44 +51,22 @@ Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f) # Bob (navigateur B) publie un événement PUBLIC ; Alice (navigateur A) le # découvre SANS être connectée/amie avec Bob, via le fan-out public. - # --- Lecture réactive cross-session (P3, brief §D.2) --- - # A crée l'événement et en ouvre le détail (compteur = 0, le créateur ne - # participe pas). B s'inscrit. SANS que A recharge ni n'agisse, l'état réactif de - # A (poussé par doc_subscribe sur le doc public de l'événement) montre - # participantCount === 1 et un participant "inconnu". - - # @wip — BUG PRODUIT RÉEL : le compteur ne converge pas chez le PROPRIÉTAIRE - # (asymétrie hôte/inscrit). Reproduit sur profil FRAIS + événement neuf, 1er run - # (bloat écarté). VÉRIFIÉ par sonde instrumentée (log des firings du - # owner-materializer + inbox.read/materializeAttendance des deux côtés). + # --- Compteur fiable à la connexion du propriétaire (Option B, brief §D.2) --- + # Cadrage (recadré 2026-07-13) : ce n'est PAS une exigence de push temps-réel + # cross-session. Le compteur est STOCKÉ dans l'événement, SEUL le propriétaire + # l'écrit ; l'inscrit DÉPOSE dans l'inbox de l'événement ; le propriétaire traite + # ces dépôts à sa PROCHAINE CONNEXION (matérialisation fiable, barrière de sync de + # l'inbox). Une reconnexion / re-matérialisation de A est donc ACCEPTÉE — on ne + # vise pas « sans reload ». # - # CAUSE RACINE (vérifiée) : le owner-materializer de A - # (FestipodDataContext, effet `[ready, ownedKey]`) n'est RE-DÉCLENCHÉ que quand - # `ownedKey` change (backfill `listMyEntityDocs`) — JAMAIS par un push d'inbox. - # `inbox.watch` (→ `subscribeDoc`/`doc_subscribe` sur le doc-inbox partagé, lib - # `@ng-eventually/client`) délivre bien son `State` initial à A, mais AUCUN Patch - # quand B dépose depuis un AUTRE verifier : le dépôt cross-session ne remonte pas - # en PUSH. Conséquence : sur un wallet propre (un seul event possédé, `ownedKey` - # stable), le materializer de A tourne UNE fois — AVANT que le dépôt de B soit - # synchronisé dans le verifier de A — lit `active=0`, écrit `participantCount=0`, - # mémoïse 0, et ne recalcule plus jamais → A (hôte) reste à 0. B (inscrit) voit sa - # participation via son propre état, d'où l'asymétrie. (Le dépôt ARRIVE bien dans - # le verifier de A : un `inbox.read` direct — cold anchored read qui PULL — le - # voit ; seul le PUSH d'abonnement manque.) - # NB : sur un wallet BLOATÉ, le backfill `listMyEntityDocs` fait varier `ownedKey` - # en boucle et re-déclenche le materializer par accident → le compteur converge - # "par chance". C'est ce qui masquait le bug lors des re-runs. - # RÉFUTÉ : l'ancienne hypothèse "B écrit le doc de A → StorageError". Mesuré : le - # write `participantCount` réussit (`writeResult: OK`) quand on l'appelle ; le - # défaut est le NON-DÉCLENCHEMENT du recalcul chez A, pas un échec d'écriture. - # - # FIX RECOMMANDÉ (design-sensible, non appliqué) : rendre le push d'inbox - # cross-session fiable dans la lib (`inbox.watch` doit re-lire au vrai push d'un - # dépôt distant sans re-poll broker — cf. rule_no-broker-polling), OU un signal - # réactif équivalent qui re-déclenche le materializer du propriétaire à l'arrivée - # d'un dépôt distant. Reste @wip tant que non corrigé. - @wip - Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload + # FIX (appliqué) : la matérialisation du propriétaire lit l'inbox APRÈS sa barrière + # de sync (`inbox.readSynced` → `ensureRepoOpen` + `read`, lib + # `@ng-eventually/client`), de sorte qu'un dépôt de l'inscrit déjà synchronisé au + # broker EST vu (plus de « 0 prématuré » mémoïsé) ; le materializer est lancé + # directement à la connexion ([ready, ownedKey]), pas seulement sur un push + # d'inbox. Source unique du NOMBRE : `event.participantCount` (le littéral local + # `participantCount: 1` du CreateEventScreen est retiré → départ à 0). + Scénario: Le compteur converge chez le propriétaire à sa prochaine connexion Étant donné un navigateur "A" avec le wallet partagé Et un navigateur "B" avec le wallet partagé Et le navigateur "A" charge l'application via le broker @@ -101,18 +79,23 @@ Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f) # création le compteur démarre à 0 (|inscriptions actives| = 0). Et le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 0 Quand le navigateur "B" s'inscrit à l'événement "Apéro réactif" - Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 1 + # À sa PROCHAINE CONNEXION, A traite l'inbox (barrière de sync) et matérialise le + # dépôt de B sur son propre doc → participantCount passe à 1. Symétrie avec B : + # après reconnexion, B lit le doc public mis à jour par A et voit 1 aussi. + Et le navigateur "A" se reconnecte et matérialise l'inbox + Alors le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 1 Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif" - # Convergence symétrique côté inscrit : B voit aussi le compteur à 1 réactivement - # (le doc public de l'événement mis à jour par A se propage via le broker vers B). - Et sans recharger, le compteur de participants réactif dans "B" pour "Apéro réactif" passe à 1 - # Option B symétrique : B se désinscrit → A (propriétaire) matérialise le - # marqueur "leave" depuis l'inbox et RECALCULE participantCount sur SON PROPRE - # doc → le compteur repasse à 0 côté A, SANS reload ni action de A. + Et le navigateur "B" se reconnecte et matérialise l'inbox + Et le compteur de participants réactif dans "B" pour "Apéro réactif" vaut 1 + # Option B symétrique : B se désinscrit → à la prochaine connexion de A, A + # matérialise le marqueur "leave" depuis l'inbox et RECALCULE participantCount + # sur son propre doc → le compteur repasse à 0. Quand le navigateur "B" se désinscrit de l'événement "Apéro réactif" - Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 0 - # Convergence symétrique côté inscrit après désinscription : B voit aussi 0. - Et sans recharger, le compteur de participants réactif dans "B" pour "Apéro réactif" passe à 0 + Et le navigateur "A" se reconnecte et matérialise l'inbox + Alors le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 0 + # Symétrie côté inscrit après désinscription : B voit aussi 0 après reconnexion. + Et le navigateur "B" se reconnecte et matérialise l'inbox + Et le compteur de participants réactif dans "B" pour "Apéro réactif" vaut 0 Scénario: Un navigateur découvre l'événement public publié dans l'autre Étant donné un navigateur "A" avec le wallet partagé diff --git a/src/modules/event/screens/CreateEventScreen.tsx b/src/modules/event/screens/CreateEventScreen.tsx index a921496..25211af 100644 --- a/src/modules/event/screens/CreateEventScreen.tsx +++ b/src/modules/event/screens/CreateEventScreen.tsx @@ -88,7 +88,12 @@ export function CreateEventScreen() { endTime, location: location || 'Lieu à définir', description, - participantCount: 1, + // Option B: the creator does NOT auto-participate (no host notion). The count + // starts at 0 and is DERIVED by the owner-materializer from real inbox + // deposits (|active registrations|) — never a local literal. Passing 1 here was + // the "local number" bug: the creator's own view showed 1 while the derived + // truth (and every other viewer) was 0. + participantCount: 0, themes: ['Social'], hostName: 'Moi', hostInitials: 'MD', diff --git a/src/modules/event/screens/ParticipantsListScreen.tsx b/src/modules/event/screens/ParticipantsListScreen.tsx index 3babb2a..818d06e 100644 --- a/src/modules/event/screens/ParticipantsListScreen.tsx +++ b/src/modules/event/screens/ParticipantsListScreen.tsx @@ -12,7 +12,12 @@ export function ParticipantsListScreen() { const event = eventId ? getEvent(eventId) : undefined; const participants = eventId ? getEventParticipants(eventId) : []; - const totalCount = event?.participantCount ?? participants.length; + // The displayed NUMBER of participants comes ONLY from `event.participantCount` + // (the owner-derived, single source of truth — Option B), never from a local + // `participants.length` count. `participants` (named, cap-readable) only ever + // REDUCES the "unknown" placeholder count; it never sets the total. When there is + // no event at all, there is nothing to count → 0. + const totalCount = event ? event.participantCount : 0; const unknownCount = Math.max(0, totalCount - participants.length); const rows = [ diff --git a/src/modules/event/steps/e2e/multibrowser-features.steps.ts b/src/modules/event/steps/e2e/multibrowser-features.steps.ts index fc5e668..b8ea5b0 100644 --- a/src/modules/event/steps/e2e/multibrowser-features.steps.ts +++ b/src/modules/event/steps/e2e/multibrowser-features.steps.ts @@ -96,6 +96,21 @@ Then('l\'inbox de l\'événement {string} reçoit un dépôt {string}', async fu When('le navigateur {string} se désinscrit de l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) { const frame = this.browser(name).appFrame!; const uid = secondParticipant(this); + // `leaveEvent` finds the participation in the reactive set before it deposits the + // "leave" marker (an absent participation → early return → no deposit → the owner + // never sees the leave). In the same-session leave (désinscription-persistante) + // the optimistic overlay makes it present instantly; in the reconnect flow (owner- + // convergence) B reloaded, so wait for its participation to sync back into the set + // first. Event-driven wait on the reactive state — not a broker re-query loop. + await frame.waitForFunction( + ([t, u]: [string, string]) => { + const td = (window as any).__testData; + const ev = [...td.events].find((e: any) => e.title === t); + return !!ev && td.isParticipating(ev['@id'], u); + }, + [title, uid] as [string, string], + { timeout: 30000 }, + ).catch(() => { /* proceed; leaveEvent will no-op if truly absent */ }); await frame.evaluate( async ([t, u]: [string, string]) => { const td = (window as any).__testData; @@ -167,6 +182,48 @@ When('le navigateur {string} ouvre le détail de l\'événement {string}', async ); }); +// --- Compteur fiable à la connexion (Option B, recadré 2026-07-13) --- +// The requirement is NOT a live cross-session push: the count is stored in the +// event, only the owner writes it, the registrant DEPOSITS into the event inbox, +// and the owner processes those deposits at its NEXT CONNECTION. So this step +// models exactly that: reload the browser's harness (a fresh verifier that PULLS +// the broker state — including the registrant's synced deposit), wait until it is +// ready and has re-materialized (the owner-materializer runs at connection with a +// BARRIER-GATED inbox read — `inbox.readSynced` — so the synced deposit is seen), +// then re-open the event detail. NO broker-polling loop (rule_no-broker-polling): +// the reconnection is the trigger; the subsequent assertion waits on the reactive +// state settling. +When('le navigateur {string} se reconnecte et matérialise l\'inbox', async function (this: FestipodWorld, name: string) { + const events = (this as any).mbEvents || {}; + // Reload the harness → fresh verifier re-loads the wallet from the broker, + // pulling the other browser's synced writes (the deposit, or the owner's count). + await this.loadAppInBrowser(name, 'harness'); + const frame = this.browser(name).appFrame!; + await frame.waitForFunction( + () => (window as any).__testData?.ready === true, + null, + { timeout: 30000 }, + ); + // Re-select every known event so the owner-materializer (keyed on ownedKey, + // resolved from listMyEntityDocs on connect) has run its barrier-gated pass and + // the detail view is back on the event. Best-effort per title. + for (const title of Object.keys(events)) { + await frame.waitForFunction( + (t) => [...(window as any).__testData.events].some((e: any) => e.title === t), + title, + { timeout: 30000 }, + ).catch(() => { /* the event may not be in this browser's set yet */ }); + await frame.evaluate( + (t) => { + const td = (window as any).__testData; + const ev = [...td.events].find((e: any) => e.title === t); + if (ev) td.appData.setSelectedEventId(ev['@id']); + }, + title, + ); + } +}); + Then('le compteur de participants réactif dans {string} pour {string} vaut {int}', async function (this: FestipodWorld, name: string, title: string, expected: number) { const frame = this.browser(name).appFrame!; // Wait (event-driven) for the reactive participantCount to reach `expected` — the @@ -181,7 +238,11 @@ Then('le compteur de participants réactif dans {string} pour {string} vaut {int return st.found && st.participantCount === n; }, [title, expected] as [string, number], - { timeout: 20000 }, + // The owner materializes ALL its owned events at connection (each with a + // barrier-gated inbox read) before its own write pushes back into the reactive + // set — so allow a generous window. This is an event-driven wait on the reactive + // state settling (NOT a broker re-query loop, rule_no-broker-polling). + { timeout: 45000 }, ); const count = await frame.evaluate( (t) => { diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index c7f9aef..c282d7c 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -511,29 +511,52 @@ function useNgData(): FestipodDataContextValue { if (owned.length === 0) return; let cancelled = false; - const materialize = async () => { + const materialize = async (trigger: string) => { if (cancelled) return; try { // Resolve the (shared) inbox anchor once; each owned event filters its own // deposits inside `materializeAttendance` / `readRegistrationNotifications`. const targetInbox = await hostInboxNuri(''); + console.log( + `[Attendance] owner materialize START (trigger=${trigger}) — ${owned.length} owned ` + + `event(s), inbox=${targetInbox}`, + ); const notifs: FpNotificationData[] = []; for (const evId of owned) { // (1) COUNT — derive the distinct active-registration set for this event - // and write it on MY OWN event doc (only when it changed). + // and write it on MY OWN event doc (only when it changed). The read inside + // `materializeAttendance` is BARRIER-GATED (`inbox.readSynced`): at the + // owner's connection it waits for the inbox sync barrier before reading, so + // a registrant's already-synced deposit IS seen (no premature 0). const active = await materializeAttendance(targetInbox, evId); const nextCount = active.length; // no host baseline (creator not auto-in) - if (materializedCountRef.current.get(evId) !== nextCount) { + const prevCount = materializedCountRef.current.get(evId); + // Write ONLY when the derived value actually changes (anti-loop). This + // memo does NOT lock in a premature 0: the barrier-gated read above makes + // the first post-connection materialize see the real deposits, so once the + // set becomes non-empty `nextCount !== prevCount` and the correct count is + // written. A transient write failure reverts the memo so the next trigger + // retries. The guard's sole job is to avoid re-writing an UNCHANGED value. + if (prevCount !== nextCount) { materializedCountRef.current.set(evId, nextCount); + console.log( + `[Attendance] owner materialize — event=${canonicalEventId(evId)}: ` + + `participantCount ${prevCount ?? '(none)'} → ${nextCount} (writing own doc)`, + ); // The write lands on the owned event doc, which `watchShape('public')` // already subscribes → the reactive read re-renders the new count on // the broker push (no manual re-query). await updateEntityField(evId, evId, 'participantCount', int(nextCount)) .catch(err => { - // Revert the memo so a transient write failure retries next push. + // Revert the memo so a transient write failure retries next trigger. materializedCountRef.current.delete(evId); - console.error('[FestipodData] owner materialize count failed:', err); + console.error('[Attendance] owner materialize count WRITE FAILED:', err); }); + } else { + console.log( + `[Attendance] owner materialize — event=${canonicalEventId(evId)}: ` + + `participantCount unchanged (${nextCount}) — no write`, + ); } // (2) NOTIFICATIONS — surface "new participant" deposits (unchanged T02.c). const evNotifs = await readRegistrationNotifications(targetInbox, evId); @@ -548,20 +571,29 @@ function useNgData(): FestipodDataContextValue { }); } } catch (err) { - console.error('[FestipodData] owner materialization failed:', err); + console.error('[Attendance] owner materialization failed:', err); } }; - // Event-driven: `inbox.watch` fires on the initial state push and on every - // later deposit (local or broker-synced) — no polling. Re-materialize on each. - // The inbox anchor is resolved async, so wire the watch inside an IIFE and - // stash the unsubscribe for cleanup (guarded by `cancelled` if the effect tore - // down before the anchor resolved). + // (A) RELIABLE-AT-CONNECTION: run one barrier-gated materialization directly on + // this trigger ([ready, ownedKey]). This is the spec's core — the owner, at its + // NEXT CONNECTION, deterministically processes its owned events' inbox (the read + // waits for the inbox sync barrier, so a registrant's synced deposit is seen). + // It does NOT depend on a cross-session inbox push arriving. + void materialize('connection'); + + // (B) SAME-SESSION LIVE: `inbox.watch` fires on the initial state push and on + // every later deposit visible to THIS verifier (a local deposit, or a remote one + // that pushes) — no polling. Re-materialize on each so the owner's own session + // stays live when a deposit does push. Cross-session convergence does NOT rely on + // this (it relies on (A) at the owner's next connection); this only sharpens the + // same-session/live case. The inbox anchor is resolved async, so wire the watch + // inside an IIFE and stash the unsubscribe for cleanup. let unsubscribe: (() => void) | null = null; (async () => { const targetInbox = await hostInboxNuri(''); if (cancelled) return; - unsubscribe = inbox.watch(targetInbox, () => void materialize()); + unsubscribe = inbox.watch(targetInbox, () => void materialize('inbox-push')); })(); return () => { cancelled = true; if (unsubscribe) unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -754,6 +786,11 @@ function useNgData(): FestipodDataContextValue { const targetInbox = await hostInboxNuri(eventId); // Carry the joiner's participation-doc NURI so the owner (if a connection) // could read it in clear; the count itself does not depend on reading it. + console.log( + `[Attendance] joinEvent — depositing registration into event inbox: ` + + `event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` + + `materializing this deposit on its own doc, at its next connection)`, + ); const { ts, uid: depositUid } = await depositRegistration(targetInbox, eventId, registrantId, partGraph); // Remember the join uid so a same-session leave can cancel it precisely. joinUidsRef.current.set(`${eventId}|${uid}`, depositUid); @@ -833,6 +870,10 @@ function useNgData(): FestipodDataContextValue { // Carry the join uid when this session minted it (precise cancellation); // otherwise the owner falls back to (eventId, userId) matching. const regUid = joinUidsRef.current.get(`${eventId}|${uid}`); + console.log( + `[Attendance] leaveEvent — depositing leave marker into event inbox: ` + + `event=${canonicalEventId(eventId)} user=${uid} regUid=${regUid ?? '(none)'}`, + ); await depositLeave(targetInbox, eventId, registrantId, regUid); joinUidsRef.current.delete(`${eventId}|${uid}`); } catch (err) { diff --git a/src/shared/data/registration.ts b/src/shared/data/registration.ts index e0cd81f..7191412 100644 --- a/src/shared/data/registration.ts +++ b/src/shared/data/registration.ts @@ -174,6 +174,10 @@ export async function depositRegistration( participationDoc, }; await inbox.post(targetInbox, { from: null, payload, ts }); + console.log( + `[Attendance] deposit new-participant → inbox for event=${canonicalEventId(eventId)} ` + + `user=${registrantId ?? '(anon)'} uid=${uid}`, + ); return { ts, uid }; } @@ -201,6 +205,10 @@ export async function depositLeave( regUid, }; await inbox.post(targetInbox, { from: null, payload, ts }); + console.log( + `[Attendance] deposit leave-participant → inbox for event=${canonicalEventId(eventId)} ` + + `user=${registrantId ?? '(anon)'} uid=${uid} regUid=${regUid ?? '(none)'}`, + ); return { ts, uid }; } @@ -242,12 +250,23 @@ export async function materializeAttendance( targetInbox: string, eventId: string, ): Promise { - const deposits = await inbox.read(targetInbox); + // BARRIER-GATED read (`inbox.readSynced`, not `inbox.read`): the owner + // materializes at its NEXT CONNECTION, and on a fresh session the event inbox + // repo is not yet open in the verifier — a plain anchored read would silently + // return 0 deposits even for a registrant's deposit already synced to the broker + // (the premature-0 that made the owner stick at count 0). `readSynced` awaits the + // inbox's first `State` (deterministic sync barrier) before reading, so the + // synced deposits ARE visible. No polling (rule_no-broker-polling): one barrier + // wait, then one read. + const deposits = await inbox.readSynced(targetInbox); // Match deposits to this event on the CANONICAL id-form (base repo id, stripping // any `:v:` suffix). On the current tree the forms already agree, but // matching on the canonical base id GUARDS against a future overlay-form drift // between `payload.eventId` and this owned `eventId` (see `canonicalEventId`). const canonId = canonicalEventId(eventId); + console.log( + `[Attendance] materializeAttendance — inbox synced+read for event=${canonId}: ${deposits.length} deposit(s) total in inbox`, + ); // First pass: collect distinct joins by uid; collect leave cancellations. const joins = new Map(); const cancelledUids = new Set(); @@ -279,7 +298,13 @@ export async function materializeAttendance( .sort((a, b) => a.ts - b.ts)[0]; if (victim) joins.delete(victim.uid); } - return [...joins.values()].sort((a, b) => a.ts - b.ts); + const active = [...joins.values()].sort((a, b) => a.ts - b.ts); + console.log( + `[Attendance] materializeAttendance — event=${canonId}: ${active.length} active ` + + `registration(s) [leaves: byUid=${cancelledUids.size}, byUser=${leaveUserIds.length}] → ` + + `uids=[${active.map(r => r.uid).join(', ')}]`, + ); + return active; } /**