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
+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