fix(data): participantCount fiable à la connexion du propriétaire + source unique

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-13 16:40:37 +02:00
parent 0958d70132
commit 82004a30b0
8 changed files with 187 additions and 67 deletions
@@ -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 P1P3 avant P4P6.
- **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.
@@ -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`).
@@ -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é
@@ -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',
@@ -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 = [
@@ -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) => {
+53 -12
View File
@@ -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) {
+27 -2
View File
@@ -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<ActiveRegistration[]> {
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:<overlay>` 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<string, ActiveRegistration>();
const cancelledUids = new Set<string>();
@@ -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;
}
/**