diff --git a/.project/concepts/bdd-testing/_debt.md b/.project/concepts/bdd-testing/_debt.md index 2852ef0..26f9ac6 100644 --- a/.project/concepts/bdd-testing/_debt.md +++ b/.project/concepts/bdd-testing/_debt.md @@ -7,3 +7,8 @@ - **why**: Nouveau scénario @data (workshop) prouvant l'isolation ACTIVE via le SDK contre le vrai broker : un compte non connecté ne lit pas l'entité PROTÉGÉE d'un autre, la lit après `declareConnections`, lit la PUBLIQUE toujours. Nouveaux hooks harness (`governProtected`, `connect`, `canReadPublicProbe`) réutilisant `` sur le vrai set ORM. Suivent le contrat @data (mutation/persistance broker) — pas de nouvelle couche, pas de vestige source-grep. - **files**: src/shared/test-harness/harness-ng.tsx, src/modules/workshop/features/protected-connections.feature, src/modules/workshop/steps/data/protected-connections.steps.ts - **verify (leaves à relire)**: aucune — rule_test-layer-contracts.md / knowledge_data-layer-broker.md inchangés (scénario conforme au contrat @data). Bloc à supprimer après relecture confirmatoire. + +## Block: T03.c — scénario @data « découverte publique via l'index global » +- **why**: Le scénario @data existant `decouverte-publique.feature` bascule du fan-out cross-comptes vers l'INDEX GLOBAL : un compte publie (submit → dépôt dans l'index) et un compte NON connecté découvre en LISANT l'index (materialize → read) puis s'abonne au doc référencé via un vrai `useShape({graphs})`. Aucune nouvelle couche ; contrat @data respecté (mutation/persistance broker réel). Hooks harness `publishPublicEventAs`/`discoverPublicEventsAs` réécrits pour passer par `submitEventToIndex`/`readDiscoveredEvents`. +- **files**: src/shared/test-harness/harness-ng.tsx, src/modules/event/features/decouverte-publique.feature, src/modules/event/steps/data/decouverte.steps.ts +- **verify (leaves à relire)**: aucune — rule_test-layer-contracts.md / knowledge_data-layer-broker.md inchangés (scénario conforme au contrat @data). Bloc à supprimer après relecture confirmatoire. diff --git a/.project/concepts/functional-domain/_debt.md b/.project/concepts/functional-domain/_debt.md index dcb9797..96103a4 100644 --- a/.project/concepts/functional-domain/_debt.md +++ b/.project/concepts/functional-domain/_debt.md @@ -7,3 +7,8 @@ - **why**: Le modèle produit public/protected/private (knowledge_data-scopes-and-discovery) devient effectivement appliqué : protected = propriétaire + connexions, public = tous, private = propriétaire. Le fait DOMAINE (les connexions) est déclaré au SDK par l'app ; le contenu doctrinal du périmètre est inchangé (le code converge vers la doctrine, ne l'invalide pas). Le fichier .feature ne fait que valider ce modèle. - **files**: src/modules/workshop/features/protected-connections.feature - **verify (leaves à relire)**: knowledge_data-scopes-and-discovery.md — confirmer que le triptyque public/protected/private + "connexions bilatérales" reste exact (aucun changement attendu). Bloc à supprimer après relecture confirmatoire. + +## Block: T03.c — découverte via index global (fan-out cross-comptes résorbé) +- **why**: L'app passe du **fan-out cross-comptes** (lecture directe des docs publics de tous les comptes) à la lecture d'un **index global** possédé par le SDK : publier un événement public = soumettre sa référence à l'index ; découvrir = lire l'index. Le fait DOMAINE (intention « la découverte lit un index global d'événements ») est INCHANGÉ — le code converge vers la doctrine existante, ne l'invalide pas. Compte spécial / inbox / curateur portant l'index = simulation du SDK, invisibles à Festipod (frontière SDK) ; aucun store-id ni mécanique d'index dans le plan de données de l'app. +- **files**: src/shared/data/discovery.ts (nouveau), src/shared/context/FestipodDataContext.tsx, src/shared/test-harness/harness-ng.tsx, src/modules/event/features/decouverte-publique.feature, src/modules/event/steps/data/decouverte.steps.ts +- **verify (leaves à relire)**: knowledge_data-scopes-and-discovery.md — la section « Découverte des événements » dit déjà « un index global … le SDK lit cet index » : confirmer qu'aucun mot ne décrit encore un fan-out (aucun attendu). Bloc à supprimer après relecture confirmatoire. diff --git a/src/modules/event/features/decouverte-publique.feature b/src/modules/event/features/decouverte-publique.feature index 3fd9b26..19ac864 100644 --- a/src/modules/event/features/decouverte-publique.feature +++ b/src/modules/event/features/decouverte-publique.feature @@ -1,16 +1,16 @@ # language: fr @EVENT @priority-1 -Fonctionnalité: Découverte publique cross-comptes +Fonctionnalité: Découverte publique via l'index global En tant qu'utilisateur Je veux découvrir les événements publics des autres comptes sans être connecté à eux, afin de trouver des points de rencontre à rejoindre au-delà de mon propre réseau. - # Modèle simple (wallet partagé) : on agrège les documents de périmètre PUBLIC - # de TOUS les comptes (allAccounts → chaque docPublic → listEntityDocs('public')) - # puis on lit ces documents via un abonnement multi-graphes. Les documents - # publics sont makePublic (T02.d) → lisibles sans capability, donc le fan-out - # n'est jamais bloqué par le filtre ReadCap. + # Découverte = lire l'INDEX GLOBAL. Publier un événement public, c'est soumettre + # sa référence à l'index (submitEventToIndex) ; découvrir, c'est lire l'index + # (readDiscoveredEvents) puis s'abonner aux documents référencés. L'app ne fait + # AUCUN fan-out cross-comptes et ne connaît ni l'hôte de l'index ni sa + # mécanique — le SDK possède l'index de bout en bout. @data Scénario: Un compte découvre l'événement public d'un autre compte non connecté @@ -18,4 +18,4 @@ Fonctionnalité: Découverte publique cross-comptes Et le compte "@alice-public" n'est pas connecté à "@bob-public" Quand "@alice-public" découvre les événements publics Alors "@alice-public" voit l'événement public "Concert au parc" - Et l'index public cross-comptes liste le document de l'événement + Et l'index global liste le document de l'événement diff --git a/src/modules/event/steps/data/decouverte.steps.ts b/src/modules/event/steps/data/decouverte.steps.ts index 2812e6c..e3ab679 100644 --- a/src/modules/event/steps/data/decouverte.steps.ts +++ b/src/modules/event/steps/data/decouverte.steps.ts @@ -2,13 +2,14 @@ import { Given, When, Then } from '@cucumber/cucumber'; import { expect } from 'chai'; import type { FestipodWorld } from '../../../../shared/support/world'; -// Data-layer proof of cross-account PUBLIC discovery against the REAL broker. -// A publisher account creates its own public event document; a separate, -// NON-connected discoverer account materializes the cross-account public source -// (allAccounts → listEntityDocs('public')) and reads the event via a real -// useShape({graphs}) — with no friendship/connection ever declared between them. -// Public docs are makePublic (T02.d), so the ReadCap filter never blocks this. -// See brief_2026-06-15_shared-wallet-shim + decision_2026-06-16_discovery-model. +// Data-layer proof of PUBLIC discovery via the GLOBAL INDEX against the REAL +// broker. A publisher account creates its own public event document AND submits +// its reference to the SDK discovery index (submitEventToIndex → deposit). A +// separate, NON-connected discoverer account READS THE INDEX +// (readDiscoveredEvents → materialize) and subscribes to the referenced document +// via a real useShape({graphs}) — with no friendship/connection ever declared +// between them, and NO cross-account fan-out. Discovery goes through the index +// alone. See decision_2026-06-16_discovery-model (special-account index owner). Given('le compte {string} publie un événement public {string}', async function (this: FestipodWorld, publisher: string, title: string) { const res = await this.appFrame!.evaluate( @@ -27,8 +28,8 @@ Given('le compte {string} n\'est pas connecté à {string}', function (this: Fes When('{string} découvre les événements publics', async function (this: FestipodWorld, discoverer: string) { const { doc, title } = (this as any).discovery; - // Discoverer materializes the cross-account public index (fans out over ALL - // accounts) and mounts a multi-graph subscription over the listed docs. + // Discoverer READS THE GLOBAL INDEX (materialize) and mounts a multi-graph + // subscription over the referenced docs — no cross-account fan-out. const res = await this.appFrame!.evaluate( async (d) => await (window as any).__testData.discoverPublicEventsAs(d), discoverer, @@ -57,7 +58,7 @@ Then('{string} voit l\'événement public {string}', async function (this: Festi expect(titles, `discoverer should see the publisher's public event "${title}"`).to.include(title); }); -Then('l\'index public cross-comptes liste le document de l\'événement', function (this: FestipodWorld) { +Then('l\'index global liste le document de l\'événement', function (this: FestipodWorld) { const { doc, listed } = (this as any).discovery; - expect(listed, 'cross-account public index should list the publisher event doc').to.include(doc); + expect(listed, 'the global index should list the publisher event doc').to.include(doc); }); diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 1ee0305..ffadaea 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -28,7 +28,8 @@ import { useAccount, normalizeUsername } from './AccountContext'; import { applyIsolation } from '../utils/isolation'; import { isolation } from '@ng-eventually/client'; import { declareConnections } from '@ng-eventually/client/polyfill'; -import { resolveScopeGraph, listEntityDocs } from '../utils/storeRegistry'; +import { resolveScopeGraph } from '../utils/storeRegistry'; +import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery'; import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults'; import { FpEventShapeType, @@ -277,30 +278,31 @@ function useNgData(): FestipodDataContextValue { return () => { cancelled = true; }; }, [ready]); - // --- Public discovery (T02.e): cross-account fan-out, ALWAYS on ------------ - // Materialize the cross-account source of PUBLIC entities so a user discovers - // other accounts' public events *without a connection* (Alice sees Bob's - // public event even if they're not friends). This is the "simple" model: the - // shared wallet makes every account's public index physically listable, so we - // aggregate `allAccounts → each docPublic → listEntityDocs('public')` and read - // the resulting per-entity documents via useShape({graphs}). Public docs are - // `makePublic` (T02.d), so the ReadCap filter never blocks this fan-out. + // --- Public discovery (T03.c): read the GLOBAL INDEX ---------------------- + // Discovery is "read the global index" (the SDK read). The app asks the SDK + // for the discovered public event references and subscribes to the documents + // they point at — a user sees other accounts' public events *without a + // connection* (Alice sees Bob's public event even if they're not friends). + // The SDK owns the index entirely (how it's stored, who hosts it, how a + // submission is materialized); the app holds NO index document NURI / store id + // and never fans out over accounts. Making an event discoverable is the + // symmetric SDK act on createEvent (`submitEventToIndex`). // // Additive & non-regressive: runs in BOTH modes but only contributes when the - // shim has registered public entity docs. In the default mono-store path the - // shim is empty (no account ever registered → fan-out is []), so the discovery - // shape stays empty and the mono-store `events` read is untouched. When the - // shim IS populated (multi-account staging), discovery unions those events in. + // index has entries. In the default path the index is empty (nothing was ever + // submitted → []), so the discovery shape stays empty and the base `events` + // read is untouched. When events HAVE been submitted, discovery unions them in. const [discoveryGraphs, setDiscoveryGraphs] = useState([]); useEffect(() => { if (!ready) return; let cancelled = false; (async () => { try { - const pub = await listEntityDocs('public'); // fans out over ALL accounts - if (!cancelled) setDiscoveryGraphs(pub); + const refs = await readDiscoveredEvents(); // reads the SDK global index + const docs = [...new Set(refs.map(r => r.doc).filter(Boolean))]; + if (!cancelled) setDiscoveryGraphs(docs); } catch (err) { - console.error('[FestipodData] public discovery fan-out failed:', err); + console.error('[FestipodData] index-based discovery failed:', err); } })(); return () => { cancelled = true; }; @@ -484,6 +486,16 @@ function useNgData(): FestipodDataContextValue { } as FpParticipation); setSelectedEventId(addedEvent["@id"]); } + // Make the PUBLIC event discoverable: submit its reference to the SDK global + // discovery index (an SDK act — the app holds no index/store id). `submitter` + // = the declaring user when known, anonymous otherwise. Best-effort: a failed + // submission must not roll back a successful event creation. + if (addedEvent) { + submitEventToIndex( + { doc: eventGraph, id: addedEvent["@id"], title: event.title }, + currentUserId || null, + ).catch(err => console.error('[FestipodData] submit event to index failed:', err)); + } return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` }; }, [protectedGraph, publicGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, username]); diff --git a/src/shared/data/discovery.ts b/src/shared/data/discovery.ts new file mode 100644 index 0000000..ed4e58d --- /dev/null +++ b/src/shared/data/discovery.ts @@ -0,0 +1,70 @@ +/** + * Discovery domain glue — the FESTIPOD interpretation layered on top of the + * GENERIC `@ng-eventually/client` `discovery` surface (the SDK's global + * discovery index). + * + * The SDK owns the discovery MECHANISM entirely: how the global index is stored, + * who hosts it, how a submission is materialized. The app treats the SDK as a + * finished NextGraph SDK — discovery is simply "read the global index"; making a + * public event discoverable is "submit its reference to the index". The app + * holds NO document NURI of the index, no store id, and knows nothing of how the + * index is owned or curated. + * + * THIS module supplies only the Festipod domain: + * - the shape of the reference deposited into the index (`EventIndexRef`), + * - `submitEventToIndex` — make a public event discoverable (an SDK act), + * - `readDiscoveredEvents` — the discovered event references (an SDK read). + * + * Importable by `shared/` and by domain modules — it never imports a module, + * only the lib. See knowledge_data-scopes-and-discovery (product intent). + */ + +import { discovery } from '@ng-eventually/client'; + +/** + * The reference Festipod deposits into the global discovery index for a public + * event. The SDK treats this as an opaque payload; only this domain module reads + * its fields. `doc` is the event's own document NURI (where it physically lives, + * so a discoverer can subscribe to it); `id`/`title` are discovery metadata so + * the list can render before the document syncs. + */ +export interface EventIndexRef { + kind: 'event'; + /** The event's document NURI (the discoverer subscribes to this to read it). */ + doc: string; + /** The event's domain id (stable across the sync). */ + id: string; + /** The event title — discovery-list metadata (renders before full sync). */ + title: string; +} + +/** + * Make a PUBLIC event discoverable: submit its reference to the global index + * (the SDK act). `submitter` = the declaring user id when connected, or `null` + * for an anonymous submission (mirrors the domain "identified if known, anonymous + * otherwise"). Best-effort at the call site — a failed submission must not roll + * back a successful event creation. + */ +export async function submitEventToIndex( + ref: Omit, + submitter: string | null = null, +): Promise { + const payload: EventIndexRef = { kind: 'event', ...ref }; + await discovery.submitToIndex(payload, { from: submitter }); +} + +/** + * Read the discovered public events from the global index (the SDK read). Maps + * each event reference to its `{ doc, id, title }`; ignores non-event entries. + * The caller subscribes to the returned `doc` NURIs to read the full events. + */ +export async function readDiscoveredEvents(): Promise { + const entries = await discovery.readIndex(); + const refs: EventIndexRef[] = []; + for (const e of entries) { + const p = e.ref as Partial | null; + if (!p || p.kind !== 'event' || !p.doc) continue; + refs.push({ kind: 'event', doc: p.doc, id: p.id ?? '', title: p.title ?? '' }); + } + return refs; +} diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index 41d9b2e..e6caa80 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -361,29 +361,44 @@ function ConnectedHarness() { return { docA, docB, listed }; }, - // --- Public discovery cross-accounts (T02.e) ----------------------- + // --- Public discovery via the GLOBAL INDEX (T03.c) ----------------- // Product-level scenario: a PUBLISHER account creates its own PUBLIC - // event document (createEntityDoc → makePublic via caps.open); a - // separate, NON-connected DISCOVERER account then materializes the - // cross-account public source (allAccounts → listEntityDocs('public')) - // and reads the event via a real useShape({graphs}). No friendship/ - // connection is ever declared between them — discovery is by the public - // fan-out alone. Returns the publisher's doc + the discovered index. + // event document and SUBMITS its reference to the SDK global discovery + // index (submitEventToIndex). A separate, NON-connected DISCOVERER + // account then READS THE INDEX (readDiscoveredEvents) — deposit → + // materialize → read — and subscribes to the referenced document via a + // real useShape({graphs}). No friendship/connection is ever declared + // between them, and NO cross-account fan-out is used — discovery goes + // through the index alone. Returns the publisher's doc + the index refs. // is reused to mount the multi-graph subscription; the // event is written into the publisher doc before the reader lists it. async publishPublicEventAs(publisher: string, title: string) { const reg = await import('../utils/storeRegistry'); + const disc = await import('../data/discovery'); reg.resetRegistryCache(); await reg.ensureAccount(publisher); const doc = await reg.createEntityDoc(publisher, 'public'); + // Make it discoverable: submit the event reference to the global index. + await disc.submitEventToIndex({ doc, id: doc, title }, publisher); return { doc }; }, async discoverPublicEventsAs(discoverer: string) { const reg = await import('../utils/storeRegistry'); + const disc = await import('../data/discovery'); // The discoverer account exists but is NOT connected to the publisher. await reg.ensureAccount(discoverer); reg.resetRegistryCache(); - const listed = await reg.listEntityDocs('public'); // cross-account + // Read the GLOBAL INDEX (not a cross-account fan-out) to discover. The + // submit deposit needs a moment to land in the broker's queryable graph + // (same lag as any inbox deposit), so poll the index (bounded) until an + // entry appears before mounting the multi-graph subscription. + let listed: string[] = []; + for (let i = 0; i < 20 && listed.length === 0; i++) { + const refs = await disc.readDiscoveredEvents(); + listed = [...new Set(refs.map(r => r.doc).filter(Boolean))]; + if (listed.length) break; + await new Promise(r => setTimeout(r, 250)); + } setFanoutGraphs(listed); return { listed }; },