diff --git a/.project/concepts/data-layer/knowledge_context-internals.md b/.project/concepts/data-layer/knowledge_context-internals.md index b15e48c..c2e9ed7 100644 --- a/.project/concepts/data-layer/knowledge_context-internals.md +++ b/.project/concepts/data-layer/knowledge_context-internals.md @@ -30,6 +30,14 @@ Un auto-seed se déclenche **uniquement hors production** (`process.env.NODE_ENV - **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. +### Invariant id-form : apparier sur la forme CANONIQUE de l'event-id + +Le `@id` d'un événement **est** son NURI de document (`did:ng:o:[:v:]`). Le matérialiseur du propriétaire apparie les **dépôts** de l'inbox aux événements possédés **par l'event-id** : `ownedEventIds` (ce que le matérialiseur itère), la **clé de dépôt** (`payload.eventId`, ce sous quoi l'inscrit dépose) et la **cible d'écriture** du compteur doivent désigner le même événement. + +**Constat mesuré (2026-07-07)** : sur l'arbre courant ces trois voies portent le **même** NURI (suffixe `:v:` inclus) — create-time, `listMyEntityDocs` et le `@id` relu coïncident, parce que `readUnion` **épingle le subject au NURI d'entrée** (lib `read-model.ts`, `63ecfee`). L'appariement marche donc déjà, **y compris** pour un événement possédé atteint via `listMyEntityDocs` (validé par le scénario @data « …fait converger le compteur dérivé »). La canonicalisation ci-dessous est **défensive**, pas la correction d'un bug actif. (Le non-match qu'une investigation avait cru voir était l'artefact **seedé-mais-pas-possédé** : sur un wallet persistant, le seed appartenait à une identité `test-*` d'un run antérieur → la session courante l'atteint par découverte, pas par `ownedEventIds` — comportement correct.) + +**Règle** : apparier l'event-id sur sa **forme canonique** — l'id de repo de base, en retirant tout suffixe `:v:` (`canonicalEventId`, `src/shared/data/registration.ts`). Cette forme canonique est utilisée pour l'**appariement** dans `materializeAttendance` / `readRegistrationNotifications`, et pour **dédupliquer** `ownedEventIds` (`ownedKey`, FestipodDataContext) afin qu'un même événement atteint par deux voies ne soit pas matérialisé deux fois. **Attention** : seul l'**appariement** utilise la forme stripée ; le compteur est toujours **écrit** sur le vrai NURI possédé (un doc vivant, ouvrable) — un id stripé ne doit jamais servir de cible d'écriture / d'ancre. C'est un invariant **côté app** (pas un détail NextGraph) : quelle que soit la façon dont la lib fait varier l'overlay, l'app apparie sur la base commune. + ## Changement d'identité = session fraîche (isolation) Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumule** les docs de scope de l'identité courante (pour ne pas perdre un doc juste créé avant la re-liste). Or le stopgap wallet-partagé garde **un seul arbre React** au travers d'un faux-logout + re-login sous un **autre identifiant** (pas de rechargement — `AccountContext.login` ne fait que réécrire l'identifiant en localStorage, `AuthGate` ne remonte rien). Sans réinitialisation, **les docs PROTECTED de l'identité précédente (ses participations) survivent dans le jeu de lecture de la nouvelle identité et fuient** via la lecture union : le cap gate ne peut pas les filtrer quand le registre de caps (en mémoire) ne gouverne pas ce doc *cette* session (doc persisté d'un run antérieur, ou chargement frais où les caps sont vides). Symptôme observé : un utilisateur B voyait la participation de A (et l'événement de A apparaissait sur l'**accueil** de B, car l'accueil = `getUserEvents(currentUserId)`, cf. concept `app-architecture`). diff --git a/src/modules/event/features/us-7-inscription-evenement.feature b/src/modules/event/features/us-7-inscription-evenement.feature index 4956aa1..f6bd14f 100644 --- a/src/modules/event/features/us-7-inscription-evenement.feature +++ b/src/modules/event/features/us-7-inscription-evenement.feature @@ -68,19 +68,18 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement # --- Data : compteur dérivé (Option B) --- # - # @wip : la CONVERGENCE de participantCount vers la valeur dérivée exige la - # matérialisation par la session du PROPRIÉTAIRE de l'événement. En @data - # mono-session, deux obstacles la rendent non-déterministe : (1) l'inbox de - # l'événement est ancrée à un compte-sentinelle partagé (les dépôts s'accumulent - # sur la vie du wallet de test, donc |actif| n'est pas borné au scénario) ; - # (2) l'événement seedé est CRÉÉ sous un NURI (non-versionné, celui de - # ownedEventIds) mais RELU sous un NURI versionné (:v:) — l'inscrit dépose sous - # le NURI relu, alors que le matérialiseur itère les NURI possédés non-versionnés, - # donc le compteur ne bouge jamais pour un événement seedé. La convergence 1→2 - # réactive est prouvée par le scénario @multibrowser réactif (propriétaire A + - # inscrit B, e2e-multibrowser.feature). Voir data-layer/knowledge_context-internals - # § participantCount et le brief brief_2026-07-06_reactive-reads-and-attendance §B. - @data @wip + # La matérialisation par le PROPRIÉTAIRE dérive l'ensemble actif de l'inbox de + # l'événement. L'id d'événement est apparié sur sa forme CANONIQUE (id de repo de + # base, en retirant tout suffixe `:v:`) à travers ownedEventIds / la clé + # de dépôt / le filtre du matérialiseur — donc le propriétaire matérialise les + # dépôts d'un événement qu'il possède quelle que soit la voie d'id (create OU + # listMyEntityDocs OU après reload). Ce scénario assère le DELTA : l'inscrit qui + # vient de rejoindre EST dans l'ensemble actif dérivé du propriétaire (déterministe + # même si l'inbox partagée accumule des dépôts, rendant le compteur ABSOLU + # non-borné au scénario). La convergence 1→2 réactive absolue reste prouvée par le + # scénario @multibrowser réactif (propriétaire A + inscrit B, e2e-multibrowser). + # Voir data-layer/knowledge_context-internals § participantCount + le caveat id-form. + @data Scénario: L'inscription fait converger le compteur dérivé du propriétaire Étant donné un événement "Formation CNV" existe Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV" diff --git a/src/modules/event/steps/data/inscription.steps.ts b/src/modules/event/steps/data/inscription.steps.ts index f129761..04f2f78 100644 --- a/src/modules/event/steps/data/inscription.steps.ts +++ b/src/modules/event/steps/data/inscription.steps.ts @@ -150,27 +150,32 @@ Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async }); Then('le compteur dérivé de l\'événement {string} reflète l\'inscription', async function (this: FestipodWorld, eventTitle: string) { - // OPTION B — the count is DERIVED and OWNER-materialized (see the feature's @wip - // rationale + data-layer/knowledge_context-internals §participantCount). In - // single-session @data it does NOT converge deterministically (shared inbox - // anchor accumulates deposits across the wallet's life; the seeded event is - // CREATED under an unversioned NURI but READ under a versioned one, so the - // joiner's deposit and the owner-materializer's owned-id never match). This step - // encodes the INTENT (the derived count reflects the join reactively) but the - // scenario is @wip — the real validation lives in the @multibrowser reactive - // scenario (owner A + joiner B, e2e-multibrowser.feature). Waits reactively on - // the owner-materialized count moving above the host baseline. - await this.appFrame!.waitForFunction( - (title) => { - const td = (window as any).__testData; - const ev = [...td.events].find((e: any) => e.title === title); - if (!ev) return false; - const rs = td.reactiveEventState(ev['@id']); - return rs.found && rs.participantCount > 1; // host(1) + at least this join - }, - eventTitle, - { timeout: 20000 }, - ); + // OPTION B — the count is DERIVED by the OWNER materializing the event's inbox + // (see data-layer/knowledge_context-internals §participantCount). The event-id is + // matched on its CANONICAL form (base repo id, stripping any `:v:`) across + // ownedEventIds / the deposit key / the materializer filter, so an owner + // materializes deposits for an event it owns regardless of the id-form path (create + // OR listMyEntityDocs OR after reload) — the fix that makes this converge. + // + // ASSERT THE DELTA, not an absolute count: the shared inbox anchor accumulates + // deposits across the wallet's life, so |active| is not bounded to this scenario — + // but "the just-joined user IS in the owner's derived active set" is deterministic. + // (The absolute 1→2 convergence stays proven end-to-end by the @multibrowser + // reactive scenario with a real owner A + joiner B.) Poll (the deposit's index + // append + broker sync lag), bounded. + const inActive = await this.appFrame!.evaluate(async (title) => { + const td = (window as any).__testData; + const ev = [...td.events].find((e: any) => e.title === title); + const uid = await td.ensureCurrentUser(); + if (!ev || !uid) return false; + for (let i = 0; i < 20; i++) { + const users: (string | null)[] = await td.activeRegistrationUsers(ev['@id']); + if (users.includes(uid)) return true; + await new Promise(r => setTimeout(r, 750)); + } + return false; + }, eventTitle); + expect(inActive, `the just-joined user must be in the owner-derived active set for "${eventTitle}"`).to.be.true; }); Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index b51986a..f26b5a2 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -17,6 +17,7 @@ import { materializeAttendance, deleteParticipation, countUserParticipations, + canonicalEventId, } from '../data/registration'; import { inbox } from '@ng-eventually/client'; import { @@ -541,10 +542,20 @@ function useNgData(): FestipodDataContextValue { // 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], - ); + // Dedup owned events by their CANONICAL id-form (base repo id, stripping any + // `:v:` suffix): the SAME event can enter `ownedEventIds` under two + // overlays (create-time vs a later `listMyEntityDocs` backfill), and iterating + // both would materialize + count-write the same event twice. Keep ONE real NURI + // per canonical id as the write/anchor target (the count is written on a live + // doc NURI — never a stripped id). See `canonicalEventId` in registration.ts. + const ownedKey = React.useMemo(() => { + const byCanon = new Map(); + for (const nuri of ownedEventIds) { + const c = canonicalEventId(nuri); + if (!byCanon.has(c)) byCanon.set(c, nuri); + } + return [...byCanon.values()].sort().join('|'); + }, [ownedEventIds]); // Last count written per owned event, so we only persist a genuine change. const materializedCountRef = useRef>(new Map()); useEffect(() => { diff --git a/src/shared/data/registration.ts b/src/shared/data/registration.ts index f8dbfd5..6dbadec 100644 --- a/src/shared/data/registration.ts +++ b/src/shared/data/registration.ts @@ -85,6 +85,33 @@ function mintDepositUid(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } +/** + * CANONICAL event-id — the id-form the owner-materializer matches deposits ON. + * DEFENSIVE invariant, not a fix for an active bug. + * + * An event's `@id` is its document NURI, a `did:ng:o:[:v:]`. On the + * current tree the SAME event carries the IDENTICAL NURI (incl. any `:v:`) + * across every boundary — create-time / `listMyEntityDocs` and the read `@id` all + * agree, because `readUnion` pins the subject to the input doc NURI (lib + * `read-model.ts`). So matching already works. This canonicalization GUARDS that: + * `ownedEventIds` (what the materializer iterates), the joiner's DEPOSIT key + * (`payload.eventId`) and the count-write target are all matched on ONE canonical + * form — the BASE repo id (strip any `:v:` suffix) — so that should an + * overlay-form ever diverge across those paths, the owner-materializer still + * matches instead of silently returning 0 (a no-op count that never converges). + * Only the MATCHING uses the stripped form — the count is still WRITTEN on the real + * (owned) NURI, a live openable doc NURI (a stripped id must never be a write/anchor + * target). + * + * A NURI with no `:v:` overlay (or a non-`did:ng:o:` id) passes through unchanged. + */ +export function canonicalEventId(id: string): string { + // did:ng:o::v: → did:ng:o:. The overlay segment is the + // LAST `:v:`-introduced part; a base id (`did:ng:o:`) has no `:v:`. + const i = id.indexOf(':v:'); + return i === -1 ? id : id.slice(0, i); +} + /** * Resolve the inbox document NURI for a meeting point / host. * @@ -216,13 +243,18 @@ export async function materializeAttendance( eventId: string, ): Promise { const deposits = await inbox.read(targetInbox); + // Match deposits to this event on the CANONICAL id-form (base repo id, stripping + // any `:v:` 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); // 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 || !p.eventId || canonicalEventId(p.eventId) !== canonId || !p.uid) continue; if (p.kind === NOTIF_TYPE_NEW_PARTICIPANT) { if (!joins.has(p.uid)) { joins.set(p.uid, { @@ -260,13 +292,15 @@ export async function readRegistrationNotifications( recipientEventId: string, ): Promise { const deposits = await inbox.read(targetInbox); + const canonRecipient = recipientEventId ? canonicalEventId(recipientEventId) : ''; const notifs: FpNotificationData[] = []; for (const d of deposits) { const p = d.payload as Partial | null; if (!p || p.kind !== NOTIF_TYPE_NEW_PARTICIPANT || !p.eventId) continue; - // Keep only deposits for the event whose host is reading. - // `recipientEventId` doubles as the recipient. - if (recipientEventId && p.eventId !== recipientEventId) continue; + // Keep only deposits for the event whose host is reading, matched on the + // CANONICAL id-form (base repo id) so an overlay difference never drops a + // deposit. `recipientEventId` doubles as the recipient. + if (canonRecipient && canonicalEventId(p.eventId) !== canonRecipient) continue; const built = buildNotification(recipientEventId, p.eventId, d.from ?? null, d.ts); // F5 dedup: prefer the stable per-deposit uid carried in the payload so // same-ms / anonymous deposits never collide. Fall back to the legacy diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index 60864b0..4c58280 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -287,6 +287,18 @@ function ConnectedHarness() { (d: any) => d?.payload?.kind === 'new-participant' && d?.payload?.eventId === eventId, ); }, + /** OPTION B — the owner's DERIVED active-registration set for an event + * (`materializeAttendance`), matched on the CANONICAL event-id form. Used + * by the @data convergence check to assert the DELTA (the just-joined user + * is in the active set) rather than an absolute count — the shared inbox + * anchor accumulates deposits across the wallet's life, so |active| is not + * bounded to one scenario, but "contains this uid" IS deterministic. */ + async activeRegistrationUsers(eventId: string) { + const regmod = await import('../data/registration'); + const target = await regmod.hostInboxNuri(''); + const active = await regmod.materializeAttendance(target, eventId); + return active.map(r => r.userId); + }, /** Host-facing notifications currently surfaced by the data context. */ appNotifications() { return AD().notifications;