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