diff --git a/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md b/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md index 4b5bb6e..dd9a989 100644 --- a/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md +++ b/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md @@ -178,6 +178,6 @@ But : B s'inscrit → l'`EventDetailScreen` de A montre `participantCount` incr > **Hooks réactifs du SDK** (précision) : l'adaptateur React de NextGraph expose `useShape` (shapes RDF réactives) et `useDiscrete` (docs CRDT discrets) — pas de `useQuery`. La lib ré-expose `useShape`. Pour la lecture UNION de N docs (le cas de Festipod), `useShape`/l'ORM en fan-out *hangue* ; le chemin réactif app passe donc par `subscribeDocs` (par-doc) + re-`readUnion`, éventuellement enveloppé en un hook de lecture réactive côté lib (à décider en P3). Autres points à trancher : -- **Ordre de phasage :** ~~(P1) lib : `subscribeDoc` + variante multi-doc + tests D.1~~ **FAIT (`c0498a6`)** ; ~~(P2) lib : remplacer `inbox.watch`/`discovery.watchIndex` par `doc_subscribe`~~ **FAIT (`c0498a6`)** ; **(P3) app : brancher la souscription par-doc dans `useNgData` (bumpRead poussé) + découverte réactive — PROCHAIN** ; (P4) app : Option B join (retirer le write compteur de l'inscrit, matérialisation propriétaire) ; (P5) app : Option B leave symétrique ; (P6) e2e D.2. P1→P3 livrent la réactivité ; P4→P6 le compteur correct. On peut livrer P1–P3 avant P4–P6. +- **Ordre de phasage :** ~~(P1) lib : `subscribeDoc` + variante multi-doc + tests D.1~~ **FAIT (`c0498a6`)** ; ~~(P2) lib : remplacer `inbox.watch`/`discovery.watchIndex` par `doc_subscribe`~~ **FAIT (`c0498a6`)** ; ~~(P3) app : brancher la souscription par-doc dans `useNgData` (bumpRead poussé) + découverte réactive~~ **FAIT (branche `ng-eventually`, non commité)** — `useNgData` monte un effet `subscribeDocs(allReadDocs, …)` clé sur un join trié des NURIs (`readDocKey`, anti-boucle : un patch → `bumpRead` → re-`readUnion` ne change pas le set → pas de re-souscription ; le reset d'identité `prevOwnerRef` vide le set → `readDocKey=''` → cleanup unsubscribe, puis re-listing → re-souscription sur le set reconstruit) + un effet de découverte réactive `watchDiscoveredEvents()` (wrapper app sur `discovery.watchIndex`, déjà `doc_subscribe`) → `relist()`. `readUnion` reste le lecteur one-shot tolérant. **Prouvé par l'e2e D.2** (`e2e-multibrowser.feature`, scénario « Un participant apparaît réactivement… », @multibrowser @shared-wallet, 12 steps verts en isolation) : B s'inscrit → A voit `participantCount === 2` + un participant « inconnu » **sans reload ni action**, via `doc_subscribe` sur le doc public de l'événement (le join en P3 écrit encore ce compteur, cf. §B.5 — c'est ce qui valide P3 avant P4). ; (P4) app : Option B join (retirer le write compteur de l'inscrit, matérialisation propriétaire) ; (P5) app : Option B leave symétrique ; ~~(P6) e2e D.2~~ **FAIT avec P3** (le scénario réactif ci-dessus ; la symétrie désinscription réactive reste à ajouter avec P5). P1→P3 livrent la réactivité ; P4→P6 le compteur correct. On peut livrer P1–P3 avant P4–P6. - **Idempotence de la matérialisation** : le `uid` par-dépôt (`RegistrationPayload.uid`, `registration.ts:56`) est le pivot ; la (référence) enregistrée par le propriétaire doit être consultée avant tout incrément/décrément pour ne jamais double-compter (rejeu de sync) ni « ressusciter » un compte. - **Migration inbox natif** : aujourd'hui l'inbox est émulée sur le wallet partagé (`inbox.ts` post/read RDF). À la migration vers l'inbox broker natif (`inbox_post`/`inbox_pop_for_user`, scellé), le flux Option B **reste valide** (dépôt non-membre autorisé, lecture réservée aux *readers* = propriétaire), mais le wrapper `subscribeDoc` sur l'inbox devra viser le mécanisme natif de notification de dépôt. À vérifier au moment de la migration. diff --git a/src/modules/event/features/e2e-multibrowser.feature b/src/modules/event/features/e2e-multibrowser.feature index 458fdbe..4410f90 100644 --- a/src/modules/event/features/e2e-multibrowser.feature +++ b/src/modules/event/features/e2e-multibrowser.feature @@ -51,6 +51,25 @@ Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f) # Bob (navigateur B) publie un événement PUBLIC ; Alice (navigateur A) le # découvre SANS être connectée/amie avec Bob, via le fan-out public. + # --- Lecture réactive cross-session (P3, brief §D.2) --- + # A crée l'événement et en ouvre le détail (compteur = 1). B s'inscrit. SANS que + # A recharge ni n'agisse, l'état réactif de A (poussé par doc_subscribe sur le doc + # public de l'événement) montre participantCount === 2 et un participant "inconnu". + + Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload + Étant donné un navigateur "A" avec le wallet partagé + Et un navigateur "B" avec le wallet partagé + Et le navigateur "A" charge l'application via le broker + Et le navigateur "B" charge l'application via le broker + Et le navigateur "A" est connecté à NextGraph + Et le navigateur "B" est connecté à NextGraph + Et le navigateur "A" crée l'événement "Apéro réactif" + Et le navigateur "A" ouvre le détail de l'événement "Apéro réactif" + Et le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 1 + Quand le navigateur "B" s'inscrit à l'événement "Apéro réactif" + Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 2 + Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif" + Scénario: Un navigateur découvre l'événement public publié dans l'autre Étant donné un navigateur "A" avec le wallet partagé Et un navigateur "B" avec le wallet partagé diff --git a/src/modules/event/steps/e2e/multibrowser-features.steps.ts b/src/modules/event/steps/e2e/multibrowser-features.steps.ts index f6dcc63..fc5e668 100644 --- a/src/modules/event/steps/e2e/multibrowser-features.steps.ts +++ b/src/modules/event/steps/e2e/multibrowser-features.steps.ts @@ -141,6 +141,99 @@ Then('l\'inscription de l\'événement {string} ne ressuscite pas dans le naviga expect(count, `participation to "${title}" must NOT resurrect after re-sync`).to.equal(0); }); +// --- Lecture réactive cross-session (P3, brief §D.2) --- +// A crée l'événement, en ouvre le détail (le contexte sélectionne l'event), et +// observe son état RÉACTIF passer de 1 à 2 quand B s'inscrit — SANS recharger. +// L'assertion attend un ÉVÉNEMENT (waitForFunction sur l'état réactif poussé par +// doc_subscribe), pas un timeout fixe : le timeout de garde ne fait que borner +// l'attente, il n'est pas la SOURCE de la mise à jour. + +When('le navigateur {string} ouvre le détail de l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) { + const frame = this.browser(name).appFrame!; + // Wait for the event to be in this session's reactive set, then select it (what + // EventDetailScreen navigation does). Reads stay reactive via the subscription. + await frame.waitForFunction( + (t) => [...(window as any).__testData.events].some((e: any) => e.title === t), + title, + { timeout: 30000 }, + ); + await frame.evaluate( + (t) => { + const td = (window as any).__testData; + const ev = [...td.events].find((e: any) => e.title === t); + if (ev) td.appData.setSelectedEventId(ev['@id']); + }, + title, + ); +}); + +Then('le compteur de participants réactif dans {string} pour {string} vaut {int}', async function (this: FestipodWorld, name: string, title: string, expected: number) { + const frame = this.browser(name).appFrame!; + // Wait (event-driven) for the reactive participantCount to reach `expected` — the + // guard timeout only bounds the wait; the value arrives via the union re-read the + // subscription triggers, never via the timeout itself. + await frame.waitForFunction( + ([t, n]: [string, number]) => { + const td = (window as any).__testData; + const ev = [...td.events].find((e: any) => e.title === t); + if (!ev) return false; + const st = td.reactiveEventState(ev['@id']); + return st.found && st.participantCount === n; + }, + [title, expected] as [string, number], + { timeout: 20000 }, + ); + const count = await frame.evaluate( + (t) => { + const td = (window as any).__testData; + const ev = [...td.events].find((e: any) => e.title === t); + return ev ? td.reactiveEventState(ev['@id']).participantCount : -1; + }, + title, + ); + expect(count, `reactive participantCount for "${title}" in browser ${name}`).to.equal(expected); +}); + +Then('sans recharger, le compteur de participants réactif dans {string} pour {string} passe à {int}', async function (this: FestipodWorld, name: string, title: string, expected: number) { + const frame = this.browser(name).appFrame!; + // NO reload / no local action on A between B's join and this assertion — the + // update MUST arrive through A's `doc_subscribe` on the (public) event doc that B + // wrote. Event-driven wait: waitForFunction polls A's already-live reactive state + // (no loadAppInBrowser here), succeeding only once the subscription push re-read. + const reached = await frame.waitForFunction( + ([t, n]: [string, number]) => { + const td = (window as any).__testData; + const ev = [...td.events].find((e: any) => e.title === t); + if (!ev) return false; + const st = td.reactiveEventState(ev['@id']); + return st.found && st.participantCount === n; + }, + [title, expected] as [string, number], + { timeout: 30000 }, + ).then(() => true).catch(() => false); + expect(reached, `reactive participantCount for "${title}" in browser ${name} must reach ${expected} WITHOUT reload (via doc_subscribe)`).to.be.true; +}); + +Then('le navigateur {string} affiche un participant {string} pour {string}', async function (this: FestipodWorld, name: string, _kind: string, title: string) { + const frame = this.browser(name).appFrame!; + // B is NOT a connection of A, so its participation doc is unreadable to A → it + // never appears as a NAMED participant; it falls into the "unknown" placeholder + // count (participantCount − knownCount ≥ 1), exactly EventDetailScreen's + // "Voir tous les participants" path. Assert reactively (event-driven). + const unknown = await frame.waitForFunction( + (t) => { + const td = (window as any).__testData; + const ev = [...td.events].find((e: any) => e.title === t); + if (!ev) return false; + const st = td.reactiveEventState(ev['@id']); + return st.found && st.unknownCount >= 1 ? st.unknownCount : false; + }, + title, + { timeout: 20000 }, + ).then(h => h.jsonValue()).catch(() => 0); + expect(Number(unknown), `browser ${name} must show ≥1 "unknown" participant for "${title}"`).to.be.at.least(1); +}); + // --- Découverte publique cross-comptes (T02.e) --- When('le compte {string} publie un événement public {string} dans le navigateur {string}', async function (this: FestipodWorld, publisher: string, title: string, name: string) { diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index c25ad10..b79ca3c 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -31,7 +31,8 @@ import { useAccount, normalizeUsername } from './AccountContext'; import { declareConnections } from '../utils/connections'; import { listMyEntityDocs, createEntityDoc, resetRegistryCache } from '../utils/storeRegistry'; import { resetCaps } from '@ng-eventually/client/polyfill'; -import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery'; +import { submitEventToIndex, readDiscoveredEvents, watchDiscoveredEvents } from '../data/discovery'; +import { subscribeDocs } from '@ng-eventually/client'; import { readEntities } from '../data/readEntities'; import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites'; import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap'; @@ -363,6 +364,59 @@ function useNgData(): FestipodDataContextValue { return () => { cancelled = true; }; }, [ready, allReadDocs, readTick]); + // --- REACTIVE READS: subscribe the by-need doc set, re-read on any change --- + // P3 (reactive-reads brief §A): the one-shot `readUnion` above stays the reader, + // but it must re-run when a doc changes in ANOTHER session, not only after a local + // mutation. So mount a PER-DOCUMENT subscription (`subscribeDocs`, one `doc_subscribe` + // per NURI, per-doc error isolation — NOT the ORM fan-out that hangs) over the exact + // set the union read reads (`allReadDocs`). On ANY change callback (initial state push + // OR a later broker-synced patch — this session's write or a remote peer's) → `bumpRead()`, + // which re-runs `readEntities(allReadDocs)` so the screens re-render with the new value. + // + // LIFECYCLE / LOOP-AVOIDANCE (brief §A.3): + // • Keyed on a STABLE join of the SORTED NURIs (`readDocKey`), NOT on `allReadDocs`'s + // identity: the effect re-subscribes ONLY when the doc SET genuinely changes. A + // subscription firing → `bumpRead` → `readUnion` → `setEvents/...` does NOT change + // `publicDocs`/`protectedDocs`, so `allReadDocs`'s content (and thus `readDocKey`) + // is unchanged → NO re-subscribe. That breaks the subscribe→read→subscribe loop. + // • `allReadDocs` is derived via `useMemo` (stable content); we further guard the + // effect on the join so an equal set (new array identity, same NURIs) is a no-op. + // • On identity switch, the `prevOwnerRef` reset effect empties `publicDocs`/ + // `protectedDocs` → `readDocKey` becomes '' → this effect's cleanup unsubscribes + // the OLD identity's docs; the listing effect then rebuilds the set for the NEW + // identity → `readDocKey` changes → subscriptions are re-established on the rebuilt + // set. So the reset drives a clean unsubscribe/re-subscribe, no leak across identities. + const readDocKey = React.useMemo( + () => [...allReadDocs].sort().join('|'), + [allReadDocs], + ); + useEffect(() => { + if (!ready) return; + const nuris = readDocKey ? readDocKey.split('|') : []; + if (nuris.length === 0) return; + // One `doc_subscribe` per NURI; any change (local or remote) re-runs the union + // read via bumpRead. The set is fixed for this effect run (keyed on readDocKey), + // so a change never mutates the set → no re-subscribe loop. + const unsubscribe = subscribeDocs(nuris, () => bumpRead()); + return () => unsubscribe(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready, readDocKey]); + + // --- REACTIVE DISCOVERY: a NEW public event created elsewhere appears w/o reload - + // P3 (brief §A.3): subscribe the global discovery INDEX document (a single doc, so + // immune to the fan-out hang). When a remote session submits a new public event, the + // index doc gets a patch → `relist()` re-runs the listing effect (`listMyEntityDocs` + // + `readDiscoveredEvents`), which folds the new event doc into `publicDocs` → it + // enters `allReadDocs` → `readDocKey` changes → the per-doc subscription effect above + // re-mounts and subscribes the new doc individually (per-doc, no fan-out). The lib's + // `watchIndex` is already `doc_subscribe`-based (no polling). Re-subscribes on identity + // switch via `username` (the index is global, but a fresh identity re-establishes it). + useEffect(() => { + if (!ready) return; + const unsubscribe = watchDiscoveredEvents(() => relist()); + return () => unsubscribe(); + }, [ready, username, relist]); + // Not in SHEX shapes yet const [meetingPoints, setMeetingPoints] = useState([]); const [friendships, setFriendships] = useState([]); diff --git a/src/shared/data/discovery.ts b/src/shared/data/discovery.ts index 3eae4d2..817ca86 100644 --- a/src/shared/data/discovery.ts +++ b/src/shared/data/discovery.ts @@ -70,3 +70,22 @@ export async function readDiscoveredEvents(): Promise { } return refs; } + +/** + * Watch the global discovery index REACTIVELY (event-driven, no polling): the SDK + * subscribes to the index document via `doc_subscribe`, so `onChange` fires on the + * initial state push AND on every subsequent change to the index — a submission + * made in ANOTHER session propagates here without a reload. The callback is a mere + * change SIGNAL: the caller re-runs `readDiscoveredEvents()` on it (the read-model + * pattern — subscribe as signal, re-query for the value). Returns an unsubscribe. + * + * A NEW public event created by a remote session appears reactively: its reference + * lands in the index → the index doc gets a patch → `onChange` fires → the caller + * relists → the new event doc enters the by-need read set and is itself subscribed. + */ +export function watchDiscoveredEvents(onChange: () => void): () => void { + // `watchIndex` is already `doc_subscribe`-based in the lib (no setInterval); it + // fires onEntries on the initial push and each later change to the index doc. We + // ignore the entries payload and use it purely as a re-list SIGNAL. + return discovery.watchIndex(() => onChange()); +} diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index e5f52f9..75ace22 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -330,6 +330,35 @@ function ConnectedHarness() { } return total; }, + /** + * REACTIVE app state for an event (P3 — reactive cross-session reads). + * Reads the LIVE app data context (via AD()) at CALL TIME, so it reflects + * whatever the reactive `readUnion` re-read produced after a `doc_subscribe` + * push — WITHOUT any reload or local action. Returns: + * - `participantCount`: the event's reactive count (mirrors what + * EventDetailScreen renders as "Participants (N)"). + * - `knownCount`: participants this viewer can name (its connections), + * mirroring EventDetailScreen's `knownParticipants` (excludes self). + * - `unknownCount`: `participantCount - knownCount` — the "unknown" + * placeholders EventDetailScreen shows ("Voir tous les participants"). + * `found` is false when the event isn't in this session's reactive set yet. + * The multi-browser test polls this via `frame.waitForFunction` (event-driven: + * it waits for the subscription push to land, not a fixed timeout). + */ + reactiveEventState(eventId: string) { + const ad = AD(); + const ev = ad.events.find(e => e.id === eventId); + if (!ev) return { found: false, participantCount: 0, knownCount: 0, unknownCount: 0 }; + const selfId = ad.currentUserId; + const known = ad.getEventParticipants(eventId).filter(u => u.id !== selfId); + const participantCount = ev.participantCount ?? 0; + return { + found: true, + participantCount, + knownCount: known.length, + unknownCount: Math.max(0, participantCount - known.length), + }; + }, async updateEvent(eventId: string, updates: Record) { // Set the event's "au départ" fields through the app path (per-entity doc). // Awaited: participantCount is persisted via SPARQL, so callers that read