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
@@ -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;
}
/**