From cd2a45c254697734ce90473e30a44926365208fa Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 7 Jul 2026 09:24:55 +0200 Subject: [PATCH] feat(data): participantCount via Option B (deposit + owner materialization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the write-isolation violation: joinEvent/leaveEvent no longer write participantCount on the event doc (a non-owner writing the owner's public doc — illegitimate in NextGraph). The joiner/leaver only write their own protected participation doc and DEPOSIT a marker into the event inbox (depositRegistration / depositLeave). The event OWNER's session materializes: it subscribes (inbox.watch, doc_subscribe — no polling) to the inboxes of its OWNED events (ownedEventIds), and on each deposit recomputes participantCount on its OWN event doc. The count is DERIVED, not incremented: materializeAttendance derives the SET of distinct active registrations (new-participant deduped by uid, MINUS leave-participant by regUid/fallback eventId+userId), count = 1 (host self) + |active set|. A pure function of the inbox → broker re-syncs converge, never double-count nor resurrect (idempotent); the write is guarded (only on change → no loop). Authoritative deleteParticipation preserved (caveat_participation-deletion). Because the owner writes its own PUBLIC event doc and every session subscribes to it (P3), the count round-trips reactively to all — no reload. Owner-offline = eventual (V1; a future @ng-eventually/service materializes on the owner's behalf). Real 2-browser e2e (e2e-multibrowser.feature): B registers → A materializes → count 1→2 reactively (no reload) + unknown participant; B leaves → count →1. 14/14 green. Gates: @data auth 4/4, @data isolation 4/4, build + tsc clean. Doctrine: knowledge_context-internals (Option B section). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data-layer/knowledge_context-internals.md | 11 +- .../event/features/e2e-multibrowser.feature | 5 + src/shared/context/FestipodDataContext.tsx | 172 ++++++++++++++---- src/shared/data/registration.ts | 130 ++++++++++++- 4 files changed, 273 insertions(+), 45 deletions(-) diff --git a/.project/concepts/data-layer/knowledge_context-internals.md b/.project/concepts/data-layer/knowledge_context-internals.md index a7c01ed..b15e48c 100644 --- a/.project/concepts/data-layer/knowledge_context-internals.md +++ b/.project/concepts/data-layer/knowledge_context-internals.md @@ -1,7 +1,7 @@ --- type: knowledge summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, auto-seed dev-only supprimé par loadTestData (seed possédé par l'identité courante), participantCount muté en place (cache), mutations no-op en mode local malgré le toast -last_checked: 2026-07-06 +last_checked: 2026-07-07 --- # Internals & pièges de `FestipodDataContext` @@ -21,9 +21,14 @@ Un auto-seed se déclenche **uniquement hors production** (`process.env.NODE_ENV - Le seed est **possédé par l'identité courante** (`bootstrapWallet(…, owner)`), pas par un propriétaire fixe : les entités protégées seedées (profils) passent ainsi le cap de lecture par-document du propriétaire (sinon elles seraient masquées et jamais relues). - **Pas de retry** au-delà : si le seed échoue, écran vide + `console.error`. Le délai de 3s reste heuristique. -## `participantCount` muté en place +## `participantCount` — dérivé et possédé par le propriétaire (Option B) -`joinEvent`/`leaveEvent`/`updateEvent` **mutent directement** `ngEvent.participantCount` (`+1`/`-1`) — c'est un **cache** du nombre de `Participation`, pas une valeur recalculée. Il peut **désynchroniser** des objets `Participation` réels (ex. après un crash, un rejeu, ou la suppression partielle décrite dans [[caveat_participation-deletion]]). Ne pas s'y fier comme source de vérité du nombre de participants. +**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`). +- La session du **propriétaire** de l'événement matérialise : elle est abonnée (`inbox.watch`, `doc_subscribe`, sans polling) à l'inbox de ses events possédés (`ownedEventIds` = `listMyEntityDocs(owner,'public')` + les events fraîchement créés), et sur chaque dépôt **recalcule** `participantCount` sur **son propre** doc d'événement (`updateEntityField` sur son doc). C'est le seul écrivain du compteur. +- **Le compteur est DÉRIVÉ, pas incrémenté** : `materializeAttendance` (registration.ts) lit l'inbox et calcule l'**ensemble** des inscriptions actives distinctes (dépôts `new-participant` dédupés par `uid`, MOINS ceux annulés par un `leave-participant` — par `regUid` exact ou fallback `(eventId, userId)`). `participantCount = 1 (hôte lui-même, base de création) + |ensemble actif|`. Comme c'est une **fonction pure de l'inbox**, un rejeu de sync broker converge — jamais de double-comptage ni de décrément fantôme (idempotence). L'écriture est gardée (n'écrit que si la valeur change), anti-boucle. +- **Propriétaire hors-ligne = éventuel** : seule la session du propriétaire matérialise ; déconnecté, le compteur n'avance pas pour les autres (les participations/dépôts restent persistés — rien n'est perdu ; un futur service matérialisera à sa place). +- Le compteur reste néanmoins un **agrégat**, pas la liste des participants nommés : `getEventParticipants` (identité nommée) reste gouverné par le cap de lecture protected ([[caveat_participation-deletion]] pour la suppression autoritative, inchangée). Cf. le brief `brief_2026-07-06_reactive-reads-and-attendance` §B. ## Changement d'identité = session fraîche (isolation) diff --git a/src/modules/event/features/e2e-multibrowser.feature b/src/modules/event/features/e2e-multibrowser.feature index 4410f90..306a5e5 100644 --- a/src/modules/event/features/e2e-multibrowser.feature +++ b/src/modules/event/features/e2e-multibrowser.feature @@ -69,6 +69,11 @@ Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f) 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 à 2 Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif" + # 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 à 1 côté A, SANS reload ni action de A. + 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 à 1 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/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index b79ca3c..b51986a 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -10,12 +10,15 @@ import type { import { hostInboxNuri, depositRegistration, + depositLeave, buildNotification, insertNotification, readRegistrationNotifications, + materializeAttendance, deleteParticipation, countUserParticipations, } from '../data/registration'; +import { inbox } from '@ng-eventually/client'; import { CURRENT_USER_ID, seedEvents, @@ -269,6 +272,11 @@ function useNgData(): FestipodDataContextValue { // to the NEW identity), and reset the emulated caps + registry cache so nothing // from the old identity lingers. Ref-guarded so it fires only on a real change, // not on the first mount (empty sets already). + // Session-local map `${eventId}|${userId}` → the join deposit's uid, so a leave + // in the SAME session can carry `regUid` for a precise cancellation. Absent it + // (cross-session leave), the owner's materializer falls back to (event, user) + // matching — so this is an optimization, not a correctness dependency. + const joinUidsRef = useRef>(new Map()); const prevOwnerRef = useRef(undefined); useEffect(() => { if (prevOwnerRef.current === undefined) { @@ -281,6 +289,8 @@ function useNgData(): FestipodDataContextValue { // and the emulated isolation state, then let the listing effect rebuild. setPublicDocs([]); setProtectedDocs([]); + setOwnedEventIds([]); + joinUidsRef.current.clear(); resetCaps(); resetRegistryCache(); setReadTick(t => t + 1); @@ -321,6 +331,10 @@ function useNgData(): FestipodDataContextValue { if (cancelled) return; setPublicDocs(prev => [...new Set([...prev, ...myPublic, ...discDocs])]); setProtectedDocs(prev => [...new Set([...prev, ...myProtected])]); + // OPTION B: my OWN public event docs are the events I OWN — the ONLY docs + // whose `participantCount` I may write. Track them so the owner-materializer + // subscribes to their inboxes and materializes deposits onto my own doc. + setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]); setReadTick(t => t + 1); } catch (err) { console.error('[FestipodData] entity-doc listing failed:', err); @@ -417,6 +431,12 @@ function useNgData(): FestipodDataContextValue { return () => unsubscribe(); }, [ready, username, relist]); + // OPTION B — the set of event docs the CURRENT identity OWNS (its own public + // event docs). Each such NURI IS the event `@id` (writeEntity uses the doc NURI + // as the subject). The owner-materializer subscribes to each owned event's inbox + // and writes `participantCount` on THAT (owned) doc — never on someone else's. + const [ownedEventIds, setOwnedEventIds] = useState([]); + // Not in SHEX shapes yet const [meetingPoints, setMeetingPoints] = useState([]); const [friendships, setFriendships] = useState([]); @@ -494,43 +514,98 @@ function useNgData(): FestipodDataContextValue { const selectedEvent = events.find(e => e.id === selectedEventId); const selectedUser = users.find(u => u.id === selectedUserId); - // --- Notification materialization (T02.c) --------------------------------- - // Run the inbox read over the current user's hosted events and - // surface "new participant" deposits as host-facing FpNotifications. Keyed on - // the events the user hosts/selects; polls once per (events, selectedEvent). - // Data-level surfacing — the notification module reads `notifications`. - const hostedEventIds = React.useMemo( - () => events.filter(e => currentUserId && e.id).map(e => e.id), - [events, currentUserId], + // --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) ------- + // The event OWNER's session materializes its OWN events' inbox deposits into + // (1) the correct `participantCount` on its OWN event doc, and + // (2) host-facing "new participant" FpNotifications. + // This is what makes the count CORRECT and reactive WITHOUT any non-owner ever + // writing the event doc: the joiner only deposits; the owner counts. + // + // Reactive, no polling: subscribe the inbox document via `inbox.watch` (now a + // `doc_subscribe` push in the lib — brief §A.4, single doc so immune to the ORM + // fan-out hang). Today all events share ONE inbox anchor (`hostInboxNuri` + // ignores the eventId → `resolveInboxAnchor()`), so ONE subscription serves all + // my owned events; each push re-materializes every owned event from the full + // deposit list. At per-event-inbox migration this fans to one watch per owned + // event (still one doc each — no fan-out). + // + // IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct + // active registrations (`materializeAttendance`: distinct join uids MINUS + // cancelled ones), never an unbounded ±1. A broker re-sync replays the same + // deposits → same set → same count. `participantCount = 1 (host self, the + // create-time baseline) + activeRegistrations.size`. The write is GUARDED + // (write only when the value actually changes) so re-materializing an unchanged + // inbox does not thrash the doc / loop the reactive read. + // + // OWNER-OFFLINE = EVENTUAL (brief §E.2): only the owner's session runs this, so + // while the owner is disconnected the count doesn't advance for others (the + // deposits persist in the inbox — nothing is lost; a future service will + // materialize on the owner's behalf). + const ownedKey = React.useMemo( + () => [...new Set(ownedEventIds)].sort().join('|'), + [ownedEventIds], ); + // Last count written per owned event, so we only persist a genuine change. + const materializedCountRef = useRef>(new Map()); useEffect(() => { - if (!ready || hostedEventIds.length === 0) return; + if (!ready) return; + const owned = ownedKey ? ownedKey.split('|') : []; + if (owned.length === 0) return; let cancelled = false; - (async () => { + + const materialize = async () => { + if (cancelled) return; try { - // The SDK resolves the inbox anchor for the current session; read it ONCE - // and let the inbox filter deposits per hosted event. + // Resolve the (shared) inbox anchor once; each owned event filters its own + // deposits inside `materializeAttendance` / `readRegistrationNotifications`. const targetInbox = await hostInboxNuri(''); - const all: FpNotificationData[] = []; - for (const evId of hostedEventIds) { - const notifs = await readRegistrationNotifications(targetInbox, evId); - all.push(...notifs); + 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). + const active = await materializeAttendance(targetInbox, evId); + const nextCount = 1 + active.length; // 1 = host self (create baseline) + if (materializedCountRef.current.get(evId) !== nextCount) { + materializedCountRef.current.set(evId, nextCount); + await updateEntityField(evId, evId, 'participantCount', int(nextCount)) + .then(() => { if (!cancelled) bumpRead(); }) + .catch(err => { + // Revert the memo so a transient write failure retries next push. + materializedCountRef.current.delete(evId); + console.error('[FestipodData] owner materialize count failed:', err); + }); + } + // (2) NOTIFICATIONS — surface "new participant" deposits (unchanged T02.c). + const evNotifs = await readRegistrationNotifications(targetInbox, evId); + notifs.push(...evNotifs); } - if (!cancelled && all.length) { + if (!cancelled && notifs.length) { setNotifications(prev => { const seen = new Set(prev.map(n => n.id)); const merged = [...prev]; - for (const n of all) if (!seen.has(n.id)) { seen.add(n.id); merged.push(n); } + for (const n of notifs) if (!seen.has(n.id)) { seen.add(n.id); merged.push(n); } return merged; }); } } catch (err) { - console.error('[FestipodData] notification materialization failed:', err); + console.error('[FestipodData] 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). + let unsubscribe: (() => void) | null = null; + (async () => { + const targetInbox = await hostInboxNuri(''); + if (cancelled) return; + unsubscribe = inbox.watch(targetInbox, () => void materialize()); })(); - return () => { cancelled = true; }; + return () => { cancelled = true; if (unsubscribe) unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ready, hostedEventIds.join('|')]); + }, [ready, ownedKey]); // Protected-sharing act: the app owns the relationship concept — it declares the // current identity's own connections (a Festipod domain fact) and, for each @@ -600,6 +675,9 @@ function useNgData(): FestipodDataContextValue { coverImage: str(event.coverImage), hostName: str(event.hostName), hostInitials: str(event.hostInitials), }); registerDoc('public', eventGraph); + // OPTION B: this event's doc is MINE (I just created it), so track it as owned + // → the owner-materializer subscribes to its inbox and maintains its count. + setOwnedEventIds(prev => (prev.includes(eventGraph) ? prev : [...prev, eventGraph])); if (currentUserId) { // The host's participation is its OWN document in the PROTECTED scope. const partGraph = await createEntityDoc(owner, 'protected'); @@ -683,15 +761,14 @@ function useNgData(): FestipodDataContextValue { event: iri(eventId), user: iri(uid), isConfirmed: bool(true), }); registerDoc('protected', partGraph); - // Bump the event's participantCount durably. The event `@id` is its own doc - // NURI (graph = subject). Read the current count from the union-read `events`; - // persist +1 via SPARQL so the re-query reflects it. Fire-and-forget. - const curEvent = events.find(e => e.id === eventId); - if (curEvent) { - const next = curEvent.participantCount + 1; - updateEntityField(eventId, eventId, 'participantCount', int(next)) - .catch(err => console.error('[FestipodData] persist participantCount (join) failed:', err)); - } + // OPTION B (brief §B): the joiner does NOT write `participantCount` on the + // EVENT doc — that doc belongs to the OWNER, and a non-owner write there is an + // isolation violation (NextGraph write is membership-bound; there is no append + // — see brief §1). The count now moves only via the OWNER materializing this + // deposit onto its OWN event doc (the `hostedEventIds` materializer below). + // The joiner's sole writes are: their OWN participation doc (above) + the + // inbox DEPOSIT (below). While the owner is offline the count doesn't advance + // for others — accepted eventual behaviour (brief §E.2); nothing is lost. // 2) Notify the host: deposit into the event/host inbox via the GENERIC lib // inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant // when connected, anonymous (null) otherwise. Best-effort: a failed deposit @@ -703,7 +780,11 @@ function useNgData(): FestipodDataContextValue { // event). This is the domain injection the generic lib deliberately omits. const recipientId = eventId; const targetInbox = await hostInboxNuri(eventId); - const { ts, uid: depositUid } = await depositRegistration(targetInbox, eventId, registrantId); + // 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. + 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); const notif = buildNotification(recipientId, eventId, registrantId, ts); // The host FpNotification is its OWN document in the PROTECTED scope (one // doc per entity). Best-effort — the inbox materialization is the source of @@ -753,17 +834,28 @@ function useNgData(): FestipodDataContextValue { console.error(msg); throw new Error(msg); } - // Confirmed gone server-side → persist the event's participantCount decrement - // durably, then re-query the union read (the participation leaves the set on - // re-read; `isParticipating` reflects it). - const curEvent = events.find(e => e.id === eventId); - if (curEvent) { - const next = Math.max(0, curEvent.participantCount - 1); - updateEntityField(eventId, eventId, 'participantCount', int(next)) - .catch(err => console.error('[FestipodData] persist participantCount (leave) failed:', err)); + // OPTION B, symmetric (brief §B "Désinscription"): the leaver does NOT write + // `participantCount` on the EVENT doc (owner-owned — same isolation violation + // as the join). Instead it DEPOSITS a `leave-participant` marker into the + // event inbox; the OWNER materializes it and recomputes the count on its OWN + // doc (idempotent — a re-synced leave never double-decrements, since the count + // is derived from the SET of distinct active registrations, not from −1). + try { + const registrantId = uid || null; + const targetInbox = await hostInboxNuri(eventId); + // 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}`); + await depositLeave(targetInbox, eventId, registrantId, regUid); + joinUidsRef.current.delete(`${eventId}|${uid}`); + } catch (err) { + console.error('[FestipodData] leaveEvent inbox deposit failed:', err); } + // Re-query the union read (the participation leaves the set on re-read; + // `isParticipating` reflects it). The count itself follows the owner's + // materialization of the leave marker (reactive, cross-session). bumpRead(); - }, [participations, events, currentUserId, bumpRead]); + }, [participations, events, currentUserId, username, bumpRead]); const addMeetingPoint = useCallback((mp: Omit) => { setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); diff --git a/src/shared/data/registration.ts b/src/shared/data/registration.ts index 84b8692..c4d95c8 100644 --- a/src/shared/data/registration.ts +++ b/src/shared/data/registration.ts @@ -24,6 +24,9 @@ import type { FpNotificationData } from './types'; /** Notification IRI/type constants (mirror the SHEX Notification shape). */ export const NOTIF_TYPE_NEW_PARTICIPANT = 'new-participant'; +/** Leave marker deposited by a registrant on `leaveEvent` (Option B, symmetric). + * The owner materializes it to remove the registration from the active set. */ +export const NOTIF_TYPE_LEAVE_PARTICIPANT = 'leave-participant'; const NOTIF_TYPE_IRI = 'http://festipod.org/Notification'; const P = { recipient: 'http://festipod.org/recipient', @@ -42,7 +45,9 @@ const P = { * lib treats this as `unknown`; only this domain module reads its fields. */ export interface RegistrationPayload { - kind: typeof NOTIF_TYPE_NEW_PARTICIPANT; + /** `new-participant` on join, `leave-participant` on leave (Option B: the owner + * materializes both markers to derive the active-registration SET). */ + kind: typeof NOTIF_TYPE_NEW_PARTICIPANT | typeof NOTIF_TYPE_LEAVE_PARTICIPANT; eventId: string; /** The registrant's user id, or null when the deposit was anonymous. */ userId: string | null; @@ -52,8 +57,27 @@ export interface RegistrationPayload { * deposits in the same ms by the same anon principal would otherwise both mint * `notif-${ts}-anon` and collide (a re-join would silently duplicate OR be * dropped by the seen-set). Carrying our own `uid` in the payload makes the - * derived notification id collision-free without changing the lib. */ + * derived notification id collision-free without changing the lib. + * + * OPTION B — this uid is ALSO the pivot of the owner's idempotent + * materialization: a `new-participant` deposit's uid keys the registration in + * the active SET, and a matching `leave-participant` carries the SAME `regUid` + * (see below) to remove it. Deriving the count from the SET makes a broker + * re-sync (which replays the same deposits) converge — never double-count. */ uid: string; + /** + * The registrant's OWN participation-document NURI (protected, owned by the + * registrant). Carried on `new-participant` so the owner — if a connection of + * the registrant — could read the participation in clear. The count itself does + * NOT depend on reading it (the owner only counts distinct active registrations + * from the inbox markers), so a non-connection owner still counts correctly. */ + participationDoc?: string; + /** + * On a `leave-participant` deposit: the `uid` of the `new-participant` deposit + * this leave cancels, so the owner removes exactly that registration from the + * active set. When the join uid is not known (e.g. a leave with no prior join + * in this session), the owner falls back to cancelling by (eventId, userId). */ + regUid?: string; } /** Mint a stable, collision-resistant per-deposit uid (time + randomness). */ @@ -111,6 +135,7 @@ export async function depositRegistration( targetInbox: string, eventId: string, registrantId: string | null, + participationDoc?: string, ): Promise<{ ts: number; uid: string }> { const ts = Date.now(); const uid = mintDepositUid(); @@ -119,11 +144,112 @@ export async function depositRegistration( eventId, userId: registrantId, uid, + participationDoc, }; await inbox.post(targetInbox, { from: null, payload, ts }); return { ts, uid }; } +/** + * Deposit a LEAVE marker into the event's inbox (Option B, symmetric to + * `depositRegistration`). The owner materializes it to remove the matching + * registration from the active set. `regUid` (the join deposit's uid) lets the + * owner cancel exactly that registration; when unknown, the owner falls back to + * (eventId, userId). Idempotent by the leave's own `uid` and by `regUid`: a + * re-synced leave removes an already-removed registration → no double-decrement. + */ +export async function depositLeave( + targetInbox: string, + eventId: string, + registrantId: string | null, + regUid?: string, +): Promise<{ ts: number; uid: string }> { + const ts = Date.now(); + const uid = mintDepositUid(); + const payload: RegistrationPayload = { + kind: NOTIF_TYPE_LEAVE_PARTICIPANT, + eventId, + userId: registrantId, + uid, + regUid, + }; + await inbox.post(targetInbox, { from: null, payload, ts }); + return { ts, uid }; +} + +/** One active registration the owner has materialized from the inbox. */ +export interface ActiveRegistration { + /** The join deposit's stable uid — the identity of this registration. */ + uid: string; + /** The registrant's user id (or null when the deposit was anonymous). */ + userId: string | null; + /** The registrant's participation-doc NURI when carried on the join. */ + participationDoc?: string; + /** The join deposit timestamp. */ + ts: number; +} + +/** + * OPTION B — OWNER MATERIALIZATION (pure, idempotent, replay-safe). + * + * Reads the event's inbox and derives the SET of DISTINCT ACTIVE registrations + * for `eventId`: every `new-participant` deposit, keyed by its stable `uid`, + * MINUS every registration a later `leave-participant` cancels. A leave cancels + * by `regUid` (the exact join uid) when carried, else by matching `userId` + * (best-effort for a leave whose join uid this session never saw). + * + * The count is a PURE FUNCTION of the current inbox contents, so it CONVERGES: + * - re-reading the same inbox (a broker re-sync replays the same deposits) + * yields the SAME set → never double-counts a join nor double-decrements a + * leave (the crux of idempotence); + * - dedup is by the join `uid`, so a duplicated deposit collapses to one entry; + * - a leave for an unknown/already-removed registration is simply a no-op on the + * set — it can never resurrect a phantom count. + * + * The owner then writes `participantCount` on its OWN event doc as + * `1 (host self, from create) + activeRegistrations.size`. The host's own + * participation is the create-time baseline (never deposited into the inbox), so + * it is added here rather than derived from a deposit. + */ +export async function materializeAttendance( + targetInbox: string, + eventId: string, +): Promise { + const deposits = await inbox.read(targetInbox); + // First pass: collect distinct joins by uid; collect leave cancellations. + const joins = new Map(); + const cancelledUids = new Set(); + const leaveUserIds: Array = []; + for (const d of deposits) { + const p = d.payload as Partial | null; + if (!p || !p.eventId || p.eventId !== eventId || !p.uid) continue; + if (p.kind === NOTIF_TYPE_NEW_PARTICIPANT) { + if (!joins.has(p.uid)) { + joins.set(p.uid, { + uid: p.uid, + userId: p.userId ?? null, + participationDoc: p.participationDoc, + ts: d.ts, + }); + } + } else if (p.kind === NOTIF_TYPE_LEAVE_PARTICIPANT) { + if (p.regUid) cancelledUids.add(p.regUid); + else leaveUserIds.push(p.userId ?? null); + } + } + // Apply cancellations: by exact join uid first, then by userId fallback (cancel + // the earliest still-active join for that user, so N leaves cancel N joins). + for (const uid of cancelledUids) joins.delete(uid); + for (const leaverId of leaveUserIds) { + if (leaverId == null) continue; // anonymous leave can't be matched by user + const victim = [...joins.values()] + .filter(r => r.userId === leaverId) + .sort((a, b) => a.ts - b.ts)[0]; + if (victim) joins.delete(victim.uid); + } + return [...joins.values()].sort((a, b) => a.ts - b.ts); +} + /** * Materialize a host inbox's deposits into host-facing notifications (data-level * surfacing). The inbox read (`inbox.read`) returns the raw deposits; we