Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
Showing only changes of commit 04a2de0b17 - Show all commits
+113 -2
View File
@@ -244,12 +244,86 @@ function useNgData(): FestipodDataContextValue {
const eventQuery = useShapeQuery(FpEventShapeType, 'public');
const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected');
const partQuery = useShapeQuery(FpParticipationShapeType, 'protected');
const events = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]);
const users = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
const participations = React.useMemo(
// The RAW reactive sets straight from `watchShape` (before the optimistic
// overlay). The exposed `events`/`participations` merge these with the pending
// overlay below — see the "OPTIMISTIC OVERLAY" block.
const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]);
const reactiveParticipations = React.useMemo(
() => adaptParticipations(partQuery.data),
[partQuery.data],
);
// --- OPTIMISTIC OVERLAY (standard useQuery-mutation pattern; NO polling) ------
// `watchShape` only surfaces a doc AFTER the broker push (a real latency), so a
// mutation's effect is invisible in the reactive set for a beat. To keep the
// screens' local-first feel, each mutation reflects its effect IMMEDIATELY in a
// pure app-side React overlay laid OVER the SDK surface (never touching the SDK
// internals — see rule_app-uses-sdk-surface-only), then RECONCILED by the push:
// • `pendingAddEvents` / `pendingAddParticipations` — entities created
// optimistically, keyed by their `@id` (= the doc NURI `writeEntity` returns,
// the SAME id `watchShape` surfaces as `s.subject` → they match exactly).
// • `pendingRemoveIds` — participation ids removed optimistically (leave).
// The exposed set = merge(reactive, pendingAdds) minus pendingRemoves, DEDUPED by
// id. Reconciliation (effects below) auto-cleans the overlay the moment the
// reactive read catches up: an add whose id now appears in the reactive set is
// dropped from pendingAdds; a remove whose id is no longer in the reactive set is
// dropped from pendingRemoves. No re-query, no interval — the overlay only reacts
// to `watchShape`'s own pushes.
const [pendingAddEvents, setPendingAddEvents] = useState<FpEventData[]>([]);
const [pendingAddParticipations, setPendingAddParticipations] = useState<FpParticipationData[]>([]);
const [pendingRemoveIds, setPendingRemoveIds] = useState<Set<string>>(() => new Set());
const events = React.useMemo(() => {
if (pendingAddEvents.length === 0) return reactiveEvents;
const seen = new Set(reactiveEvents.map(e => e.id));
const extra = pendingAddEvents.filter(e => !seen.has(e.id));
return extra.length ? [...reactiveEvents, ...extra] : reactiveEvents;
}, [reactiveEvents, pendingAddEvents]);
const participations = React.useMemo(() => {
let base = reactiveParticipations;
if (pendingAddParticipations.length) {
const seen = new Set(base.map(p => p.id));
const extra = pendingAddParticipations.filter(p => !seen.has(p.id));
if (extra.length) base = [...base, ...extra];
}
if (pendingRemoveIds.size) base = base.filter(p => !pendingRemoveIds.has(p.id));
return base;
}, [reactiveParticipations, pendingAddParticipations, pendingRemoveIds]);
// RECONCILIATION — drop each optimistic add once the reactive read carries its id
// (the push arrived), and each optimistic remove once the reactive read no longer
// carries its id (the SPARQL delete propagated). Guarded to no-op when there is
// nothing to reconcile, so a stable reactive set does not churn state.
useEffect(() => {
if (pendingAddEvents.length === 0) return;
const live = new Set(reactiveEvents.map(e => e.id));
setPendingAddEvents(prev => {
const next = prev.filter(e => !live.has(e.id));
return next.length === prev.length ? prev : next;
});
}, [reactiveEvents, pendingAddEvents]);
useEffect(() => {
if (pendingAddParticipations.length === 0) return;
const live = new Set(reactiveParticipations.map(p => p.id));
setPendingAddParticipations(prev => {
const next = prev.filter(p => !live.has(p.id));
return next.length === prev.length ? prev : next;
});
}, [reactiveParticipations, pendingAddParticipations]);
useEffect(() => {
if (pendingRemoveIds.size === 0) return;
const live = new Set(reactiveParticipations.map(p => p.id));
setPendingRemoveIds(prev => {
let changed = false;
const next = new Set(prev);
for (const id of prev) if (!live.has(id)) { next.delete(id); changed = true; }
return changed ? next : prev;
});
}, [reactiveParticipations, pendingRemoveIds]);
// The read is "settled" once every scope has reached its sync barrier
// (`isSuccess`). A synced-but-empty scope reads `isSuccess` with `data: []` — the
// distinction the auto-seed relies on to tell "still syncing" from "truly empty".
@@ -281,6 +355,11 @@ function useNgData(): FestipodDataContextValue {
// its own (scope re-resolution keyed on `getCurrentUser()`).
setOwnedEventIds([]);
joinUidsRef.current.clear();
// Drop the optimistic overlay too: it belongs to the OLD identity's session
// and must not bleed into the new identity's reads (isolation).
setPendingAddEvents([]);
setPendingAddParticipations([]);
setPendingRemoveIds(new Set());
resetCaps();
resetRegistryCache();
}, [username]);
@@ -557,6 +636,13 @@ function useNgData(): FestipodDataContextValue {
// 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]));
// OPTIMISTIC OVERLAY: surface the created event IMMEDIATELY (its id = eventId =
// the doc NURI, the SAME id `watchShape('public')` will surface as `s.subject`,
// so the reconciliation effect drops this add once the push arrives). Without
// it, `getEvent(eventId)` right after create returns undefined until the push.
// participantCount starts at 0 (creator does not auto-participate).
const optimisticEvent: FpEventData = { ...event, id: eventId, participantCount: event.participantCount || 0 };
setPendingAddEvents(prev => (prev.some(e => e.id === eventId) ? prev : [...prev, optimisticEvent]));
// The creator does NOT auto-participate (no host notion — settled product
// decision): NO participation is written on create. The creator sees "J'y
// serai" and may join/leave their own event like anyone else.
@@ -635,6 +721,14 @@ function useNgData(): FestipodDataContextValue {
await writeEntity(partGraph, ENTITY_TYPE.participation, {
event: iri(eventId), user: iri(uid), isConfirmed: bool(true),
});
// OPTIMISTIC OVERLAY: surface the participation IMMEDIATELY so the joiner shows
// up in the list and `isParticipating` is true before the broker push. Its id =
// partGraph = the doc NURI, the SAME id `watchShape('protected')` will surface
// as `s.subject` (adaptParticipations sets `id: s.subject`) → the reconciliation
// effect drops this add once the push arrives. If a stale pendingRemove targeted
// this exact id (re-join same doc — never happens, ids are fresh), clear it too.
const optimisticPart: FpParticipationData = { id: partGraph, eventId, userId: uid, isConfirmed: true };
setPendingAddParticipations(prev => (prev.some(p => p.id === partGraph) ? prev : [...prev, optimisticPart]));
// 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
@@ -706,6 +800,23 @@ function useNgData(): FestipodDataContextValue {
console.error(msg);
throw new Error(msg);
}
// OPTIMISTIC OVERLAY: the delete is broker-confirmed (remaining === 0) but the
// reactive `watchShape('protected')` set only drops the participation on the
// NEXT push (a beat later). Mark its id removed NOW so the leaver disappears
// from the list immediately; the reconciliation effect clears this pendingRemove
// once the reactive set no longer carries the id. Also drop it from
// pendingAddParticipations in case it was still only-optimistic (join+leave in
// the same session before the join's push landed).
setPendingRemoveIds(prev => {
if (prev.has(part.id)) return prev;
const next = new Set(prev);
next.add(part.id);
return next;
});
setPendingAddParticipations(prev => {
const next = prev.filter(p => p.id !== part.id);
return next.length === prev.length ? prev : next;
});
// 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