Derive the participant count instead of incrementing it

Every Participation is readable by everyone — only the link to a person is
protected — so an event's count is obtained by counting its participations. No
privileged view, nothing to compensate for.

The stored counter is gone from the write path: joining writes a participation
and nothing else, leaving writes nothing else. Writing the participation IS
moving the count, so there is no second write to keep in step and no counter to
race. That race was measured, not supposed: the count sat on whatever the seed
had written, with no error anywhere, because each increment derived from a read
of the previous one.

Derived in BOTH providers, so demo and connected modes cannot disagree about
what a count means. fp:participantCount stays in the shape and the fixtures,
kept in step, but nothing reads it.

The fixtures become the source of truth and had to say something true: they
advertised 24, 12, 45, 16, 30 against five participation rows. Seventeen rows
now, counts aligned to them. Marie's three are untouched on purpose — the home
screen lists "my events" from exactly those, and the @ui tests render against
it. Small on purpose too: a plausible demo, not a hundred rows reproducing
figures nobody reads.

us-7-inscription-evenement.feature still sets a starting count and expects it
±1. Those steps encode the superseded counter and will need rewriting around
creating participations; they are @data, which is parked while no backend is
registered.
This commit is contained in:
Sylvain Duchesne
2026-10-01 18:35:24 +02:00
parent b25bafbb90
commit 0c4f6b6650
2 changed files with 74 additions and 22 deletions
+39 -14
View File
@@ -131,6 +131,35 @@ const mapParticipation = (p: FpParticipation): FpParticipationData => ({
// Shared queries builder — same logic for both local and NG modes
// ============================================================================
/**
* The participant count, DERIVED from the participations rather than read off the
* event.
*
* Every Participation is readable by everyone — only the link to a person is
* protected, so counting them needs no privileged view and no compensation
* (concept `data-layer`, brief_2026-07-20_attendance-set-model).
*
* WHY NOT THE STORED FIELD. It used to be incremented by whoever joined: read the
* counter out of the list, add one, write it back. Every write then derived from a
* read of the previous one, which races itself the moment the read is not
* instantaneous — measured here as a count stuck on whatever the seed had written,
* with no error anywhere. The project's own brief declares that mutated counter
* superseded. Derived, it cannot drift: it is a function of the rows.
*
* `fp:participantCount` stays in the shape and in the fixtures, kept in step, but
* nothing reads it any more.
*/
function withDerivedCounts(
events: FpEventData[],
participations: FpParticipationData[],
): FpEventData[] {
const counts = new Map<string, number>();
for (const p of participations) {
counts.set(p.eventId, (counts.get(p.eventId) ?? 0) + 1);
}
return events.map(e => ({ ...e, participantCount: counts.get(e.id) ?? 0 }));
}
function buildQueries(
events: FpEventData[],
users: FpUserData[],
@@ -181,9 +210,11 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
const [selectedEventId, setSelectedEventId] = useState<string>(empty ? '' : 'event-1');
const [selectedUserId, setSelectedUserId] = useState<string>('');
const events = empty ? [] : seedEvents;
const users = empty ? [] : seedUsers;
const participations = empty ? [] : seedParticipations;
// Derived here too, so demo mode and connected mode cannot disagree about what
// a count means.
const events = empty ? [] : withDerivedCounts(seedEvents, participations);
const meetingPoints = empty ? [] : seedMeetingPoints;
const friendships = empty ? [] : seedFriendships;
@@ -331,9 +362,9 @@ function useNgData(): FestipodDataContextValue {
const profileRows = profilesResult?.data ?? [];
const participationRows = participationsResult?.data ?? [];
const events = eventRows.map(eventBinding.toDomain);
const users = profileRows.map(profileBinding.toDomain);
const participations = participationRows.map(participationBinding.toDomain);
const events = withDerivedCounts(eventRows.map(eventBinding.toDomain), participations);
// --- Writes: every entity through REFINE ----------------------------------
const { mutateAsync: createEventRow } = useCreate<FpEvent>();
@@ -528,14 +559,11 @@ function useNgData(): FestipodDataContextValue {
console.log('[FestipodData] Already participating, skipping');
return;
}
// NOTHING ELSE. The count is derived from the participations, so writing the
// participation IS moving the count — there is no second write to keep in
// step, and no counter to race.
await createParticipation(eventId, uid);
// The count lives on the event, which is now a Refine record: bumping it is a
// full record write, not the in-place ORM mutation it used to be.
const current = eventRows.find(r => eventBinding.domainId(r) === eventId);
if (current) {
await writeEvent(eventId, { participantCount: current.participantCount + 1 });
}
}, [participations, createParticipation, eventRows, eventBinding, writeEvent, currentUserId]);
}, [participations, createParticipation, currentUserId]);
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
@@ -547,16 +575,13 @@ function useNgData(): FestipodDataContextValue {
return p.eventId === eventId && p.userId === uid;
});
if (row) {
// Removing the participation IS moving the count — see joinEvent.
await deleteParticipationRow({
resource: participationBinding.resource,
id: participationBinding.recordKey(row),
});
const current = eventRows.find(r => eventBinding.domainId(r) === eventId);
if (current) {
await writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) });
}
}
}, [participationRows, participationBinding, deleteParticipationRow, eventRows, eventBinding, writeEvent, currentUserId]);
}, [participationRows, participationBinding, deleteParticipationRow, currentUserId]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
+35 -8
View File
@@ -128,7 +128,7 @@ export const seedEvents: FpEventData[] = [
endTime: '18:00',
location: 'Le Revel, Rogues (30)',
distance: 142,
participantCount: 24,
participantCount: 5,
description: 'Une semaine collaborative pour se rencontrer, co-créer et faire avancer le projet de Réseau Social Universel. Au programme : sessions plénières en intelligence collective, ateliers en forum ouvert, et randonnée au Cirque de Navacelles. Hébergement sur place au Revel, écolieu à Rogues dans le Gard.',
hostName: 'Reconnexion',
hostInitials: 'RC',
@@ -143,7 +143,7 @@ export const seedEvents: FpEventData[] = [
endTime: '17:00',
location: 'La Maison du Vélo, Lyon',
distance: 3,
participantCount: 12,
participantCount: 3,
description: 'Un atelier pratique pour découvrir les low-tech et apprendre à fabriquer des objets du quotidien.',
hostName: 'La Maison du Vélo',
hostInitials: 'MV',
@@ -158,7 +158,7 @@ export const seedEvents: FpEventData[] = [
endTime: '18:00',
location: "Tiers-lieu L'Hermitage",
distance: 89,
participantCount: 45,
participantCount: 4,
description: "Un forum ouvert sur la transition écologique et sociale, dans le tiers-lieu L'Hermitage.",
hostName: "L'Hermitage",
hostInitials: 'LH',
@@ -173,7 +173,7 @@ export const seedEvents: FpEventData[] = [
endTime: '17:00',
location: 'MJC Montplaisir, Lyon',
distance: 5,
participantCount: 16,
participantCount: 3,
description: 'Initiation à la Communication Non Violente. Venez découvrir les bases de la CNV pour améliorer vos relations.',
hostName: 'MJC Montplaisir',
hostInitials: 'MJ',
@@ -188,7 +188,7 @@ export const seedEvents: FpEventData[] = [
endTime: '21:00',
location: "La Maison de l'Environnement",
distance: 7,
participantCount: 30,
participantCount: 2,
description: "Rencontre mensuelle du groupe local des Colibris pour échanger sur les projets en cours.",
hostName: 'Les Colibris',
hostInitials: 'LC',
@@ -198,14 +198,41 @@ export const seedEvents: FpEventData[] = [
// Participations: which users are in which events
export const seedParticipations: FpParticipationData[] = [
// Marie (current user) participates in events 1, 2, 3
// THESE ARE THE SOURCE OF TRUTH for a participant count. The count is DERIVED
// by counting an event's participations, not stored on the event: every
// Participation is readable by everyone, only the link to a person is
// protected, so there is nothing to compensate for. The `participantCount` on
// `seedEvents` is kept in step with these rows and is otherwise ignored on read.
//
// Marie (the current user) participates in events 1, 2 and 3 — UNCHANGED, and
// deliberately: the home screen lists "my events" from exactly these, and the
// @ui tests render against it.
{ id: 'part-1', eventId: 'event-1', userId: 'user-1', isConfirmed: true },
{ id: 'part-2', eventId: 'event-2', userId: 'user-1', isConfirmed: true },
{ id: 'part-3', eventId: 'event-3', userId: 'user-1', isConfirmed: true },
// Jean participates in events 1
// Everything below exists so a derived count shows a plausible number instead
// of the near-zero the three rows above would give. Small on purpose: the point
// is a demo that looks real, not a reproduction of the figures the fixtures
// used to advertise (24, 12, 45…), which would take a hundred rows nobody reads.
// event-1 — 5 participants
{ id: 'part-4', eventId: 'event-1', userId: 'user-2', isConfirmed: true },
// Thomas participates in event 1
{ id: 'part-5', eventId: 'event-1', userId: 'user-7', isConfirmed: true },
{ id: 'part-6', eventId: 'event-1', userId: 'user-3', isConfirmed: true },
{ id: 'part-7', eventId: 'event-1', userId: 'user-9', isConfirmed: true },
// event-2 — 3 participants
{ id: 'part-8', eventId: 'event-2', userId: 'user-4', isConfirmed: true },
{ id: 'part-9', eventId: 'event-2', userId: 'user-8', isConfirmed: true },
// event-3 — 4 participants
{ id: 'part-10', eventId: 'event-3', userId: 'user-5', isConfirmed: true },
{ id: 'part-11', eventId: 'event-3', userId: 'user-6', isConfirmed: true },
{ id: 'part-12', eventId: 'event-3', userId: 'user-10', isConfirmed: true },
// event-4 — 3 participants, none of them the current user
{ id: 'part-13', eventId: 'event-4', userId: 'user-2', isConfirmed: true },
{ id: 'part-14', eventId: 'event-4', userId: 'user-5', isConfirmed: true },
{ id: 'part-15', eventId: 'event-4', userId: 'user-9', isConfirmed: true },
// event-5 — 2 participants
{ id: 'part-16', eventId: 'event-5', userId: 'user-3', isConfirmed: true },
{ id: 'part-17', eventId: 'event-5', userId: 'user-8', isConfirmed: true },
];
export const seedMeetingPoints: FpMeetingPointData[] = [