Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
4 changed files with 273 additions and 45 deletions
Showing only changes of commit cd2a45c254 - Show all commits
@@ -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)
@@ -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é
+132 -40
View File
@@ -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<Map<string, string>>(new Map());
const prevOwnerRef = useRef<string | null | undefined>(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<string[]>([]);
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
const [friendships, setFriendships] = useState<FpFriendshipData[]>([]);
@@ -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<Map<string, number>>(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<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
+128 -2
View File
@@ -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<ActiveRegistration[]> {
const deposits = await inbox.read(targetInbox);
// 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.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