feat(data): discover public events via the global index (not fan-out)

On creating a public event, Festipod submits it to the discovery index (an SDK
call); the discovery screen reads the index instead of enumerating accounts. The
app knows nothing of the index's owner, inbox, or materialization — it treats the
lib as a finished SDK whose discovery is a global index. No store ids.

Unit-validated in the lib (79 tests). @data broker validation deferred: the
NextGraph broker (nextgraph.net/eu) was unreachable at run time — to be re-run
in T03.d once the broker recovers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-04 09:33:42 +02:00
parent 337a1e000d
commit a436c3bd79
7 changed files with 150 additions and 42 deletions
@@ -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
@@ -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);
});
+28 -16
View File
@@ -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<string[]>([]);
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]);
+70
View File
@@ -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<EventIndexRef, 'kind'>,
submitter: string | null = null,
): Promise<void> {
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<EventIndexRef[]> {
const entries = await discovery.readIndex();
const refs: EventIndexRef[] = [];
for (const e of entries) {
const p = e.ref as Partial<EventIndexRef> | null;
if (!p || p.kind !== 'event' || !p.doc) continue;
refs.push({ kind: 'event', doc: p.doc, id: p.id ?? '', title: p.title ?? '' });
}
return refs;
}
+23 -8
View File
@@ -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.
// <FanoutProbe> 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 };
},