Ng eventually #1
@@ -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:<repo>[:v:<overlay>]`). 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:<overlay>` 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:<overlay>` (`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`).
|
||||
|
||||
@@ -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:<overlay>`) à 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"
|
||||
|
||||
@@ -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:<overlay>`) 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) {
|
||||
|
||||
@@ -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:<overlay>` 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<string, string>();
|
||||
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<Map<string, number>>(new Map());
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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:<repo>[:v:<overlay>]`. On the
|
||||
* current tree the SAME event carries the IDENTICAL NURI (incl. any `:v:<overlay>`)
|
||||
* 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:<overlay>` 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:<repo>:v:<overlay> → did:ng:o:<repo>. The overlay segment is the
|
||||
// LAST `:v:`-introduced part; a base id (`did:ng:o:<repo>`) 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<ActiveRegistration[]> {
|
||||
const deposits = await inbox.read(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);
|
||||
// First pass: collect distinct joins by uid; collect leave cancellations.
|
||||
const joins = new Map<string, ActiveRegistration>();
|
||||
const cancelledUids = new Set<string>();
|
||||
const leaveUserIds: Array<string | null> = [];
|
||||
for (const d of deposits) {
|
||||
const p = d.payload as Partial<RegistrationPayload> | 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<FpNotificationData[]> {
|
||||
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<RegistrationPayload> | 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user