Ng eventually #1
@@ -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é
|
||||
|
||||
@@ -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()}` }]);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user