refactor(data): canonical event-id matching for the owner-materializer (defensive)
Guard the Option-B owner-materializer against overlay-form drift: match inbox deposits to owned events on the CANONICAL base repo id (canonicalEventId strips any ✌️<overlay> suffix), applied at the matching boundary in materializeAttendance / readRegistrationNotifications and to dedup ownedEventIds (ownedKey). The count is still WRITTEN on the real owned NURI — a stripped id is never a write/anchor target. Honest framing: this is DEFENSIVE, not a fix for an active bug. On the current tree create-time, listMyEntityDocs and the read @id already carry the identical NURI (readUnion pins the subject to the input NURI, 63ecfee) — verified: the count converges for an event owned via listMyEntityDocs. A prior investigation's 'never matches' reading was the seeded-but-not-owned artifact (a prior-run identity owned the seed → reached via discovery, not ownedEventIds — correct behavior). Un-@wip the @data convergence scenario (asserts the just-joined uid enters the owner-derived active set — deterministic despite shared-inbox accumulation); it now passes. Fix authParticipationCount already landed separately. Doctrine: knowledge_context-internals (canonical id-form invariant). Build + tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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