Ng eventually #1
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user