feat(data): logs [app][data] identité-first + participantCount avant→après

Préfixe identité-first ; label participation ; valeur compteur avant/après écriture owner + lecture affichage. Additif.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
This commit is contained in:
Sylvain Duchesne
2026-07-20 13:16:04 +02:00
parent a21d9b0735
commit 46ed894621
2 changed files with 92 additions and 46 deletions
+88 -44
View File
@@ -168,41 +168,44 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
? users.find(u => normalizeIdentifier(u.username) === normalizeIdentifier(identifier))
: undefined;
const currentUserId = empty ? '' : (accountUser?.id ?? CURRENT_USER_ID);
// Identity-first log prefix: current user id when resolved, else the bare
// `[app][data]` form (e.g. the transient `empty` connecting state).
const logPrefix = currentUserId ? `[${currentUserId}][app][data]` : '[app][data]';
const currentUser = users.find(u => u.id === currentUserId);
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
const queries = buildQueries(events, users, participations, meetingPoints, friendships, currentUserId);
console.log('[FestipodData] Render —', empty ? 'connecting (empty)' : 'local',
console.log(`${logPrefix} Render —`, empty ? 'connecting (empty)' : 'local',
'| events:', events.length,
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// Local mode: mutations are no-ops (static defaults)
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (local, no-op):', event.title);
console.log(`${logPrefix} createEvent (local, no-op):`, event.title);
return { ...event, id: nextId('event') };
}, []);
}, [logPrefix]);
const updateEvent = useCallback((_id: string, _updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (local, no-op)');
}, []);
console.log(`${logPrefix} updateEvent (local, no-op)`);
}, [logPrefix]);
const joinEvent = useCallback((_eventId: string) => {
console.log('[FestipodData] joinEvent (local, no-op)');
}, []);
console.log(`${logPrefix} joinEvent (local, no-op)`);
}, [logPrefix]);
const leaveEvent = useCallback((_eventId: string) => {
console.log('[FestipodData] leaveEvent (local, no-op)');
}, []);
console.log(`${logPrefix} leaveEvent (local, no-op)`);
}, [logPrefix]);
const addMeetingPoint = useCallback((_mp: Omit<FpMeetingPointData, 'id'>) => {
console.log('[FestipodData] addMeetingPoint (local, no-op)');
}, []);
console.log(`${logPrefix} addMeetingPoint (local, no-op)`);
}, [logPrefix]);
const addFriend = useCallback((_friendId: string) => {
console.log('[FestipodData] addFriend (local, no-op)');
}, []);
console.log(`${logPrefix} addFriend (local, no-op)`);
}, [logPrefix]);
const updateProfile = useCallback((_updates: Partial<FpUserData>) => {
console.log('[FestipodData] updateProfile (local, no-op)');
}, []);
console.log(`${logPrefix} updateProfile (local, no-op)`);
}, [logPrefix]);
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (local, no-op)');
console.log(`${logPrefix} loadTestData (local, no-op)`);
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } };
}, []);
@@ -386,7 +389,7 @@ function useNgData(): FestipodDataContextValue {
if (cancelled) return;
setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]);
} catch (err) {
console.error('[FestipodData] owned-events resolution failed:', err);
console.error(`${logPrefix} owned-events resolution failed:`, err);
}
})();
return () => { cancelled = true; };
@@ -429,14 +432,14 @@ function useNgData(): FestipodDataContextValue {
if (!readReady) return; // still syncing — do NOT mistake pending for empty
const walletHasData = events.length > 0 || users.length > 0;
if (!shouldAutoSeed(walletHasData)) {
console.log('[FestipodData] Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip');
console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`);
return;
}
// Enabled AND synced-empty → a real empty wallet. Seed once.
hasTriedAutoSeed.current = true;
console.log('[FestipodData] Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…');
console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…`);
bootstrapWallet(false, createEntityDoc, identifier || undefined)
.catch(err => console.error('[FestipodData] Auto-seed failed:', err));
.catch(err => console.error(`${logPrefix} Auto-seed failed:`, err));
// The reactive `watchShape` reads pick the seeded per-entity docs up on their
// own (each createEntityDoc appends to the scope index → the container-index
// subscription re-resolves → the new docs enter the read). No registerDoc/relist.
@@ -460,7 +463,21 @@ function useNgData(): FestipodDataContextValue {
// back to the read profile's IRI only when there is no login (dev/demo).
const currentUserId =
(identifier ? `urn:festipod:user:${normalizeIdentifier(identifier)}` : (currentUser?.id || ''));
// Identity-first log prefix, reused by every DATA log below (including the
// closures defined earlier in this function body — they only execute after
// this render has finished, by which point `logPrefix` is initialized).
const logPrefix = currentUserId ? `[${currentUserId}][app][data]` : '[app][data]';
const selectedEvent = events.find(e => e.id === selectedEventId);
// DISPLAY READ — log participantCount exactly as currently exposed for
// rendering. Compared against the owner-materializer's WRITE logs below, this
// pinpoints whether a stuck counter is a DATA problem (never incremented) or a
// DISPLAY/read problem (incremented but not re-read until the next session).
if (selectedEvent) {
console.log(
`${logPrefix} participation read for display — event=${canonicalEventId(selectedEvent.id)} ` +
`"${selectedEvent.title}": participantCount lu = ${selectedEvent.participantCount}`,
);
}
const selectedUser = users.find(u => u.id === selectedUserId);
// --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) -------
@@ -518,11 +535,23 @@ function useNgData(): FestipodDataContextValue {
// deposits inside `materializeAttendance` / `readRegistrationNotifications`.
const targetInbox = await hostInboxNuri('');
console.log(
`[Attendance] owner materialize START (trigger=${trigger}) — ${owned.length} owned ` +
`${logPrefix} owner participation materialize START (trigger=${trigger}) — ${owned.length} owned ` +
`event(s), inbox=${targetInbox}`,
);
const notifs: FpNotificationData[] = [];
for (const evId of owned) {
// BEFORE — the event's readable detail (short id + title) and the
// participantCount value as currently READ/exposed (the app-side `events`
// state), captured before this cycle's derive+write. Comparing this to the
// AFTER log below tells whether the counter is a DATA problem (never
// incremented) or a DISPLAY/read problem (incremented but not re-read).
const knownEvent = events.find(e => e.id === evId);
const knownCount = knownEvent?.participantCount;
console.log(
`${logPrefix} participation materialize — event=${canonicalEventId(evId)}` +
(knownEvent?.title ? ` "${knownEvent.title}"` : '') +
` — participantCount before write (as currently read) = ${knownCount ?? '(unknown)'}`,
);
// (1) COUNT — derive the distinct active-registration set for this event
// and write it on MY OWN event doc (only when it changed). The read inside
// `materializeAttendance` is BARRIER-GATED (`inbox.readSynced`): at the
@@ -540,21 +569,34 @@ function useNgData(): FestipodDataContextValue {
if (prevCount !== nextCount) {
materializedCountRef.current.set(evId, nextCount);
console.log(
`[Attendance] owner materialize — event=${canonicalEventId(evId)}: ` +
`${logPrefix} owner participation materialize — event=${canonicalEventId(evId)}: ` +
`participantCount ${prevCount ?? '(none)'}${nextCount} (writing own doc)`,
);
// The write lands on the owned event doc, which `watchShape('public')`
// already subscribes → the reactive read re-renders the new count on
// the broker push (no manual re-query).
let writeOk = true;
await updateEntityField(evId, evId, 'participantCount', int(nextCount))
.catch(err => {
// Revert the memo so a transient write failure retries next trigger.
writeOk = false;
materializedCountRef.current.delete(evId);
console.error('[Attendance] owner materialize count WRITE FAILED:', err);
console.error(`${logPrefix} owner participation materialize count WRITE FAILED:`, err);
});
if (writeOk) {
// AFTER — the write has landed on the owner's own doc. N → M reuses the
// SAME "before" reference point logged above, so it is directly
// comparable: a DISPLAY-read log (see `selectedEvent`, above in this
// file) still showing the old N after this fires means the counter data
// is fine and it is the read side that lags.
console.log(
`${logPrefix} participation materialize — event=${canonicalEventId(evId)}: ` +
`participantCount AFTER write = ${knownCount ?? '(unknown)'}${nextCount}`,
);
}
} else {
console.log(
`[Attendance] owner materialize — event=${canonicalEventId(evId)}: ` +
`${logPrefix} owner participation materialize — event=${canonicalEventId(evId)}: ` +
`participantCount unchanged (${nextCount}) — no write`,
);
}
@@ -571,7 +613,7 @@ function useNgData(): FestipodDataContextValue {
});
}
} catch (err) {
console.error('[Attendance] owner materialization failed:', err);
console.error(`${logPrefix} owner participation materialization failed:`, err);
}
};
@@ -634,7 +676,7 @@ function useNgData(): FestipodDataContextValue {
events, users, participations, meetingPoints, friendships, currentUserId,
);
console.log('[FestipodData] Render — NG | events:', events.length,
console.log(`${logPrefix} Render — NG | events:`, events.length,
'| users:', users.length, '| participations:', participations.length,
'| selectedEvent:', selectedEvent?.title ?? '(none)');
@@ -647,7 +689,7 @@ function useNgData(): FestipodDataContextValue {
// private) — the app carries no access logic.
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (NG):', event.title);
console.log(`${logPrefix} createEvent (NG):`, event.title);
// Owner principal = the account identifier (what setCurrentUser declares). The
// SDK create returns THIS entity's OWN public document and declares its
// ReadCap policy (public → world-readable). Fall back to a generic account
@@ -697,13 +739,13 @@ function useNgData(): FestipodDataContextValue {
submitEventToIndex(
{ doc: eventGraph, id: addedEvent["@id"], title: event.title },
null,
).catch(err => console.error('[FestipodData] submit event to index failed:', err));
).catch(err => console.error(`${logPrefix} submit event to index failed:`, err));
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [currentUserId, identifier]);
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
console.log(`${logPrefix} updateEvent (NG):`, id, updates);
// The event's `@id` IS its own document NURI (one entity = one document), so
// it is both the write graph and the subject. Persist each provided mutable
// field DIRECTLY via SPARQL (the durable write); `watchShape` re-reads on the
@@ -718,19 +760,19 @@ function useNgData(): FestipodDataContextValue {
if (updates.date !== undefined) persists.push(updateEntityField(graph, id, 'date', str(updates.date)));
if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location)));
if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance)));
await Promise.all(persists).catch(err => console.error('[FestipodData] persist event update failed:', err));
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err));
}, []);
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
console.log(`${logPrefix} joinEvent (NG):`, eventId, 'user:', uid);
// A Participation MUST carry a user principal (SHEX `fp:user` is mandatory) —
// writing one without it produces an entity the ORM drops on read (the
// participation silently never round-trips). Refuse an empty principal rather
// than persist a broken participation. The caller resolves a real user id (the
// current user's IRI) before joining.
if (!uid) {
console.error('[FestipodData] joinEvent: empty user principal — refusing to write a participation with no fp:user.');
console.error(`${logPrefix} joinEvent: empty user principal — refusing to write a participation with no fp:user.`);
return;
}
// IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set.
@@ -739,7 +781,7 @@ function useNgData(): FestipodDataContextValue {
// one participation"). The broker query sees the real state regardless of lag.
const already = await countUserParticipations(identifier || uid || 'anon', eventId, uid).catch(() => 0);
if (already > 0) {
console.log('[FestipodData] Already participating (broker-confirmed), skipping');
console.log(`${logPrefix} Already participating (broker-confirmed), skipping`);
return;
}
// 1) Persist the Participation as its OWN document in the PROTECTED scope
@@ -787,7 +829,7 @@ function useNgData(): FestipodDataContextValue {
// 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.
console.log(
`[Attendance] joinEvent — depositing registration into event inbox: ` +
`${logPrefix} joinEvent — depositing participation registration into event inbox: ` +
`event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` +
`materializing this deposit on its own doc, at its next connection)`,
);
@@ -805,13 +847,13 @@ function useNgData(): FestipodDataContextValue {
// notification id from the inbox and same-ms/anon deposits never collide.
setNotifications(prev => [...prev, { ...notif, id: `notif-${depositUid}` }]);
} catch (err) {
console.error('[FestipodData] joinEvent inbox/notify failed:', err);
console.error(`${logPrefix} joinEvent inbox/notify failed:`, err);
}
}, [events, currentUserId, identifier]);
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid);
console.log(`${logPrefix} leaveEvent (NG):`, eventId, 'user:', uid);
// Find the participation in the union-read set. Each participation is its OWN
// document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH
// the subject IRI AND the graph NURI it lives in.
@@ -828,14 +870,14 @@ function useNgData(): FestipodDataContextValue {
try {
result = await deleteParticipation(graphNuri, eventId, uid, subjectIri);
} catch (err) {
console.error('[FestipodData] SPARQL DELETE participation failed:', err);
console.error(`${logPrefix} SPARQL DELETE participation failed:`, err);
throw err instanceof Error ? err : new Error(String(err));
}
// AUTHORITATIVE verification: only proceed once the broker RE-QUERY confirms
// the participation is gone (remaining === 0). If the delete matched nothing,
// surface it rather than falsely flip the UI (it would resurrect on re-sync).
if (result.remaining > 0) {
const msg = `[FestipodData] leaveEvent: SPARQL delete removed nothing ` +
const msg = `${logPrefix} leaveEvent: SPARQL delete removed nothing ` +
`(before=${result.before}, remaining=${result.remaining}, bySubject=${result.bySubject}) ` +
`for event=${eventId} user=${uid} — NOT flipping UI (would resurrect).`;
console.error(msg);
@@ -871,13 +913,13 @@ function useNgData(): FestipodDataContextValue {
// otherwise the owner falls back to (eventId, userId) matching.
const regUid = joinUidsRef.current.get(`${eventId}|${uid}`);
console.log(
`[Attendance] leaveEvent — depositing leave marker into event inbox: ` +
`${logPrefix} leaveEvent — depositing participation leave marker into event inbox: ` +
`event=${canonicalEventId(eventId)} user=${uid} regUid=${regUid ?? '(none)'}`,
);
await depositLeave(targetInbox, eventId, registrantId, regUid);
joinUidsRef.current.delete(`${eventId}|${uid}`);
} catch (err) {
console.error('[FestipodData] leaveEvent inbox deposit failed:', err);
console.error(`${logPrefix} leaveEvent inbox deposit failed:`, err);
}
// The participation doc is subscribed by `watchShape('protected')`; the SPARQL
// DELETE pushes → the reactive read drops it (`isParticipating` reflects it).
@@ -900,7 +942,7 @@ function useNgData(): FestipodDataContextValue {
}, [currentUserId]);
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
console.log('[FestipodData] updateProfile (NG):', updates);
console.log(`${logPrefix} updateProfile (NG):`, updates);
// The current user's profile is its own document (subject IRI = doc NURI).
const target = currentUser ?? users[0];
if (!target) return;
@@ -911,11 +953,11 @@ function useNgData(): FestipodDataContextValue {
if (updates.username !== undefined) persists.push(updateEntityField(graph, graph, 'username', str(updates.username)));
if (updates.role !== undefined) persists.push(updateEntityField(graph, graph, 'role', str(updates.role)));
if (updates.isPublic !== undefined) persists.push(updateEntityField(graph, graph, 'isPublic', bool(updates.isPublic)));
await Promise.all(persists).catch(err => console.error('[FestipodData] persist profile update failed:', err));
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist profile update failed:`, err));
}, [currentUser, users]);
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (NG)');
console.log(`${logPrefix} loadTestData (NG)`);
// An EXPLICIT load is authoritative — SUPPRESS the dev auto-seed so only ONE
// seed runs (marking the guard at the START, before the awaited seed, closes
// the window where the auto-seed effect could also fire on a still-empty read).
@@ -959,7 +1001,9 @@ function NgDataProvider({ children }: { children: ReactNode }) {
export function FestipodDataProvider({ children }: { children: ReactNode }) {
const { status } = useNextGraph();
console.log('[FestipodData] Provider — NG status:', status);
// No identity resolved at this level (only NG connection status is known here) —
// identity-first prefix falls back to the bare `[app][data]` form.
console.log('[app][data] Provider — NG status:', status);
if (status === 'connected') {
return <NgDataProvider>{children}</NgDataProvider>;
+4 -2
View File
@@ -111,9 +111,11 @@ export function useShapeQuery<T = UnionSubject>(
// "readDoc → N rows" logs gain an app-level running total.
recordSet(label, n);
// eslint-disable-next-line no-console
console.log(`[FestipodData] set reçu: ${n} objets ${label} (${scope}) en ${elapsed}ms`);
// No identity in scope at this call site (this hook receives no currentUserId) —
// identity-first prefix falls back to the bare `[app][data]` form.
console.log(`[app][data] set reçu: ${n} objets ${label} (${scope}) en ${elapsed}ms`);
// eslint-disable-next-line no-console
console.log(`[FestipodData] totaux — ${totalsSummary()} (${totalSets()} sets reçus)`);
console.log(`[app][data] totaux — ${totalsSummary()} (${totalSets()} sets reçus)`);
}, [query, cycleId, shapeKey, scope]);
return query;