Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
6 changed files with 216 additions and 2 deletions
Showing only changes of commit 4e96659bd7 - Show all commits
@@ -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 P1P3 avant P4P6.
- **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 P1P3 avant P4P6.
- **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.
@@ -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