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
+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