feat(data): participantCount via Option B (deposit + owner materialization)

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-07 09:24:55 +02:00
parent e62a17e5a2
commit cd2a45c254
4 changed files with 273 additions and 45 deletions
@@ -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)