feat(data): reactive cross-session reads (doc_subscribe), + real 2-browser e2e

Wire the app read path to the lib's per-doc reactive subscription so a change made
in ANOTHER session propagates without a reload or local action:
- useNgData subscribes the by-need set via subscribeDocs(allReadDocs, bumpRead) —
  one doc_subscribe per NURI, per-doc error isolation (never the ORM fan-out). Any
  patch (own write or broker-synced from a remote peer) re-runs readUnion.
- Reactive discovery: watchDiscoveredEvents(relist) subscribes the global index →
  a new public event from another session enters the read set (and gets its own sub).
- Loop-safe: the sub effect is keyed on a stable sorted-NURI key (readDocKey); a
  fire→bumpRead→read never changes the doc set, so no re-subscribe loop. Identity
  switch empties the set → clean unsubscribe → rebuild → re-subscribe (no leak).
- readUnion stays the one-shot tolerant reader; subscriptions only trigger re-reads.

Real 2-browser e2e (e2e-multibrowser.feature): B registers → A's EventDetailScreen
shows participantCount 1→2 and an 'unknown' participant WITHOUT A reloading, via A's
doc_subscribe on the public event doc (event-driven). Isolated run 12/12 green.

Count mechanism unchanged (P4/Option-B is next); the joiner still writes the public
event doc's participantCount — which is exactly what the observer sees change live.
Gates: @data auth 4/4, @data isolation 4/4, build + tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 23:40:43 +02:00
parent 84bc87d13c
commit 4e96659bd7
6 changed files with 216 additions and 2 deletions
@@ -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é
@@ -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) {
+55 -1
View File
@@ -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<FpMeetingPointData[]>([]);
const [friendships, setFriendships] = useState<FpFriendshipData[]>([]);
+19
View File
@@ -70,3 +70,22 @@ export async function readDiscoveredEvents(): Promise<EventIndexRef[]> {
}
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());
}
+29
View File
@@ -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<string, any>) {
// 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