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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user