diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index c282d7c..df5c634 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -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): Promise => { - 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) => { - 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) => { - 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) => { - console.log('[FestipodData] updateProfile (local, no-op)'); - }, []); + console.log(`${logPrefix} updateProfile (local, no-op)`); + }, [logPrefix]); const loadTestData = useCallback(async (): Promise => { - 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): Promise => { - 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) => { - 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) => { - 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 => { - 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 {children}; diff --git a/src/shared/data/useShapeQuery.ts b/src/shared/data/useShapeQuery.ts index ec8b28f..3952a6f 100644 --- a/src/shared/data/useShapeQuery.ts +++ b/src/shared/data/useShapeQuery.ts @@ -111,9 +111,11 @@ export function useShapeQuery( // "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;