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