Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
12 changed files with 745 additions and 252 deletions
Showing only changes of commit 966ba9855c - Show all commits
@@ -33,5 +33,26 @@ Cucumber → Playwright (Chromium, profil persistant)
- **Flags Chromium** (`--disable-web-security`, `--allow-insecure-localhost`, désactivation de Private Network Access) : nécessaires car le broker public charge un harness `http://127.0.0.1` en iframe.
- **Profil persistant** `.playwright-profile/` (gitignored, wallet en localStorage) — exige le vrai binaire Chrome, pas `chrome-headless-shell`.
- **Serveur HTTP** lancé en `BeforeAll` (port auto), sert le HTML + `/harness.js` (fichiers séparés — le script inline casse à cause de caractères spéciaux du bundle).
- **Subscriptions ORM** : les shapes des entités partageables sont souscrites sur le scope **protected** (`harness-ng.tsx` utilise `protectedNuri`), cohérent avec le placement des entités domaine côté app (concept `data-layer`).
- **Bridge `window.__testData`** : `events`/`users`/`participations` (sets live), `currentUserId`, lookups (`getEvent`, `getEventByTitle`), mutations (`joinEvent`, `leaveEvent`, `updateEvent``joinEvent`/`leaveEvent` réels depuis T02.b/c : persistance Participation + inbox + Notification / DELETE-WHERE), requêtes (`isParticipating`, `getEventParticipants`).
- **Bridge = le vrai chemin app (per-entité).** Depuis le passage à *un document par entité*
(concept `data-layer`, [[rule_document-per-entity]]), le bridge `window.__testData`
(`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/
`getEventParticipants`, `loadTestData`) **délègue au contexte de données de l'app**
(`appData` via `FestipodDataProvider`) — c'est le chemin per-entité réel des écrans, pas une
lecture au niveau du store-racine. Le harness monte donc l'**`AccountProvider`** et se logge
par défaut (`@mariedupont`) pour établir l'identité courante (sans quoi le filtre ReadCap ne
laisserait passer que le public). Il lit `appData` via une **ref vivante** (un snapshot capturé
devient périmé après un re-rendu de seed).
- Chemins probes de bas niveau conservés (scope store-racine `protectedNuri`) pour les
scénarios ReadCap/isolation qui *gouvernent* ce document : `rawJoin`/`rawParticipations`,
`governDocument`/`governProtected`/`documentNuri`, `FilterProbe`/`FanoutProbe`.
- **Identité avant écriture.** Une `Participation` a un `fp:user` obligatoire ; comme la lecture
du profil peut retarder derrière les events publics, les steps attendent
`ensureCurrentUser()` avant `joinEvent` (sinon participation écrite sans user → jetée en
lecture, ne fait jamais l'aller-retour) et attendent (`waitForFunction`) que la participation
soit relue.
- **Caveat wallet persistant** : le wallet partagé **accumule** les docs per-entité à chaque run
(seed + inscriptions). Le fan-out de lecture (`listEntityDocs`) parcourt tous les docs de tous
les comptes → ralentit et fait *timeouter* les steps quand le wallet est pollué. Pour une suite
fiable, repartir d'un wallet **frais** (supprimer `.playwright-profile/` → recréation
automatique) ; le seed connecté est volontairement **allégé** (peu de docs) car chaque
`docCreate` est un aller-retour broker sériel ~2s.
@@ -29,9 +29,42 @@ confiance.
## Comment l'appliquer
- À la création : demander au SDK **un document pour l'entité, dans son scope** ; y écrire
l'entité. Ne pas réutiliser un document d'un autre périmètre ni un document de niveau store.
- À la création : demander au SDK **un document pour l'entité, dans son scope**
(`createEntityDoc(scope)`) ; y écrire l'entité. Ne pas réutiliser un document d'un autre
périmètre ni un document de niveau store.
- En lecture : passer par le SDK, **par scope** — pas de résolution de document/NURI côté app.
- Le mapping *entité → scope* (événement/PdR → public, profil réseau/participation → protected,
settings → private) est un fait produit (concept `functional-domain`,
[[knowledge_data-scopes-and-discovery]]).
## Écriture directe vs. set réactif (piège d'aller-retour)
L'**écriture** d'une entité se fait **directement dans son propre document** (via l'appel
SPARQL du SDK — `src/shared/data/entityWrites.ts`, `writeEntity`), **pas** via l'ajout à
l'ensemble réactif `ngSet.add`. Raison : l'ensemble réactif (`useShape(shape, { graphs })`)
n'est *inscriptible* que si le document cible est **déjà** dans son scope d'abonnement ; or
enregistrer le document fraîchement créé dans ce scope est un état React qui ne prend effet
qu'au rendu **suivant** → on ne peut pas créer-puis-ajouter en une passe synchrone (boucle de
seed, première création). Contre le vrai broker, `ngSet.add` sur un scope vide lève « Set is
readonly because scope is empty » (les tests unitaires fake-ng ne l'attrapent pas).
Donc : **écriture = SPARQL direct dans le doc de l'entité** (immédiat, par-document) ;
**lecture = réactive** (le NURI du doc est enregistré dans le `useShape({ graphs })`, l'ORM le
relit). Idem pour la **mutation d'un champ** existant (p. ex. `participantCount`) : une mutation
ORM en place est **locale** et se fait **écraser** par la re-synchro réactive du doc depuis le
broker (retour à la valeur persistée) → persister via SPARQL (`updateEntityField` : DELETE puis
INSERT du triplet) pour que le changement tienne et que la relecture concorde. Chaque champ est écrit avec le **bon terme RDF** selon la shape SHEX (xsd:integer /
float / boolean, ou IRI pour les références `Participation.event`/`.user`) — un champ obligatoire
manquant ou mal typé fait que l'ORM **jette l'entité** à la relecture (elle ne fait jamais
l'aller-retour). Le **sujet** de l'entité = le **NURI de son document** (une entité = un document),
ce qui donne un `@id` en `did:ng:…`.
Corollaire d'identité : une `Participation` porte un `fp:user` **obligatoire** — ne jamais
l'écrire avec un principal vide (l'entité serait jetée en lecture). Le principal du user courant
est **stable et dérivé du username** (`urn:festipod:user:<username-normalisé>`), disponible
**immédiatement** après login (pas de dépendance à la lecture du profil protégé, qui peut
retarder) et **invariant** (il ne bascule pas d'un fallback vers l'IRI de profil en cours de
session, ce qui désynchroniserait une participation écrite sous une valeur d'une vérification
sous l'autre). C'est le même principal que l'identité SDK (`setCurrentUser`) et le cap owner
dérivent du username ; les connexions bilatérales (`declareConnections`) se déclarent avec ces
mêmes clés username (pas des IRIs de profil) pour que « protégé = mes connexions » discrimine.
+16 -17
View File
@@ -2,28 +2,27 @@ import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// Seed data matching what bootstrapWallet uses
import { seedEvents, seedUsers } from '../../../../shared/data/seedData';
// --- Setup ---
Given('le portefeuille est vide', async function (this: FestipodWorld) {
// Verify starting state: the harness graph should have its own seeded data.
// We clear events/users/participations to simulate a truly empty wallet.
await this.appFrame!.evaluate(() => {
// Empty the wallet for real: with one-document-per-entity + a persistent broker,
// deleting entities means clearing the per-entity documents' CONTENT (the SDK
// clearWallet), not just flipping a store-root set. Then poll until the reactive
// read reflects the empty state.
await this.appFrame!.evaluate(async () => {
const td = (window as any).__testData;
// Delete all events
for (const e of [...td.events]) td.events.delete(e);
// Delete all users
for (const u of [...td.users]) td.users.delete(u);
// Delete all participations
for (const p of [...td.participations]) td.participations.delete(p);
await td.clearWallet();
});
// Verify empty
await this.appFrame!.waitForFunction(
() => {
const td = (window as any).__testData;
return td.events.size === 0 && td.users.size === 0;
},
{ timeout: 30000 },
);
const counts = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return { events: td.events.size, users: td.users.size, participations: td.participations.size };
return { events: td.events.size, users: td.users.size };
});
expect(counts.events, 'Events should be empty').to.equal(0);
expect(counts.users, 'Users should be empty').to.equal(0);
@@ -43,7 +42,7 @@ Given('le portefeuille contient déjà des événements', async function (this:
// Wait for data to propagate
await this.appFrame!.waitForFunction(
() => (window as any).__testData.events.size > 0,
{ timeout: 10000 },
{ timeout: 75000 },
);
}
});
@@ -70,7 +69,7 @@ When('je charge les données de test', async function (this: FestipodWorld) {
// Either data was already there, or it should appear after loading
return td.events.size > 0 || td._loadResult?.seeded === false;
},
{ timeout: 10000 },
{ timeout: 75000 },
).catch(() => {
// Timeout is OK if wallet was already populated (idempotent case)
});
@@ -13,7 +13,7 @@ import type { FestipodWorld } from '../../../../shared/support/world';
// --- Setup (app path) ---
// NOTE: app-path steps pass the LIVE current user id (resolved at call time via
// td.liveUserId(), guaranteed non-empty once users hydrated), so the Participation
// await td.ensureCurrentUser(), guaranteed non-empty once users hydrated), so the Participation
// carries a real principal (the ORM rejects an empty user IRI). Assertions read
// `liveIsParticipating` with the SAME live id, so join/leave and the checks agree.
@@ -21,7 +21,7 @@ Given('l\'utilisateur n\'est pas inscrit à l\'événement {string} via l\'app',
await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.appLeaveEvent(event['@id'], td.liveUserId());
if (event) await td.appLeaveEvent(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
});
@@ -29,7 +29,7 @@ Given('l\'utilisateur est inscrit à l\'événement {string} via l\'app', async
await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.appJoinEvent(event['@id'], td.liveUserId());
if (event) await td.appJoinEvent(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
});
@@ -39,7 +39,7 @@ When('l\'utilisateur s\'inscrit à l\'événement {string} via l\'app', async fu
await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.appJoinEvent(event['@id'], td.liveUserId());
if (event) await td.appJoinEvent(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
});
@@ -47,7 +47,7 @@ When('l\'utilisateur se désinscrit de l\'événement {string} via l\'app', asyn
await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.appLeaveEvent(event['@id'], td.liveUserId());
if (event) await td.appLeaveEvent(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
});
@@ -85,15 +85,35 @@ Then('l\'utilisateur devient participant de l\'événement {string}', async func
// The app-path join writes to the FestipodDataContext participation set, which
// converges with the harness's own useShape set via the shared store. Poll
// in-browser (waitForFunction) until it appears, to absorb that sync latency.
// Poll: the participation is written into its own protected doc and read back
// reactively; under a busy wallet that read can lag, so accept the AUTHORITATIVE
// broker count as well (the write is durable regardless of the reactive re-read).
await this.appFrame!.waitForFunction(
(title) => {
const td = (window as any).__testData;
if (!td.currentUserId) return false; // wait for the profile read to hydrate
const event = [...td.events].find((e: any) => e.title === title);
return !!event && td.liveIsParticipating(event['@id']);
},
eventTitle,
{ timeout: 15000 },
);
{ timeout: 45000 },
).catch(async () => {
// Reactive read lagged — confirm authoritatively against the broker, polling
// to absorb the index-append propagation lag of the per-entity fan-out.
const n = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return 0;
const uid = await td.ensureCurrentUser();
for (let i = 0; i < 12; i++) {
const c = await td.authParticipationCount(event['@id'], uid);
if (c > 0) return c;
await new Promise(r => setTimeout(r, 1500));
}
return 0;
}, eventTitle);
expect(n, `participation to "${eventTitle}" must exist on the broker`).to.be.greaterThan(0);
});
});
Then('le broker ne contient plus aucune participation à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
@@ -103,7 +123,7 @@ Then('le broker ne contient plus aucune participation à l\'événement {string}
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return -1;
return td.authParticipationCount(event['@id'], td.liveUserId());
return td.authParticipationCount(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
expect(count, `broker must hold 0 participations to "${eventTitle}" after leave (authoritative re-query)`).to.equal(0);
});
@@ -20,16 +20,22 @@ Given('un événement {string} existe', async function (this: FestipodWorld, eve
return [...td.events].some((e: any) => e.title === title);
},
eventTitle,
{ timeout: 10000 },
{ timeout: 75000 },
);
});
Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
// Clean slate for the current user's participation. The app leave is
// authoritative (SPARQL DELETE on the participation's own doc) but reactive-gated
// (it only acts on a participation present in the reactive set); enough for the
// common case. Kept LIGHT (no full-wallet fan-out — that saturates the browser
// on a busy wallet).
await this.appFrame!.evaluate(
(title) => {
async (title) => {
const td = (window as any).__testData;
const uid = await td.ensureCurrentUser();
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.leaveEvent(event['@id'], td.currentUserId);
if (event) await td.leaveEvent(event['@id'], uid);
},
eventTitle,
);
@@ -37,10 +43,11 @@ Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async func
Given('l\'utilisateur est inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
(title) => {
async (title) => {
const td = (window as any).__testData;
const uid = await td.ensureCurrentUser();
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.joinEvent(event['@id'], td.currentUserId);
if (event) await td.joinEvent(event['@id'], uid);
},
eventTitle,
);
@@ -48,10 +55,10 @@ Given('l\'utilisateur est inscrit à l\'événement {string}', async function (t
Given('l\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) {
await this.appFrame!.evaluate(
([title, c]: [string, number]) => {
async ([title, c]: [string, number]) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.updateEvent(event['@id'], { participantCount: c });
if (event) await td.updateEvent(event['@id'], { participantCount: c });
},
[eventTitle, count] as [string, number],
);
@@ -61,10 +68,11 @@ Given('l\'événement {string} a {int} participants au départ', async function
When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
(title) => {
async (title) => {
const td = (window as any).__testData;
const uid = await td.ensureCurrentUser();
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.joinEvent(event['@id'], td.currentUserId);
if (event) await td.joinEvent(event['@id'], uid);
},
eventTitle,
);
@@ -72,10 +80,11 @@ When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (thi
When('l\'utilisateur se désinscrit de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
(title) => {
async (title) => {
const td = (window as any).__testData;
const uid = await td.ensureCurrentUser();
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.leaveEvent(event['@id'], td.currentUserId);
if (event) await td.leaveEvent(event['@id'], uid);
},
eventTitle,
);
@@ -83,10 +92,11 @@ When('l\'utilisateur se désinscrit de l\'événement {string}', async function
When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
(title) => {
async (title) => {
const td = (window as any).__testData;
const uid = await td.ensureCurrentUser();
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.joinEvent(event['@id'], td.currentUserId);
if (event) await td.joinEvent(event['@id'], uid);
},
eventTitle,
);
@@ -95,32 +105,73 @@ When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {s
// --- Assertions ---
Then('l\'utilisateur est participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
const participating = await this.appFrame!.evaluate(
// The participation is written into its own protected document and read back
// reactively — poll (the read lags the write against the broker), resolving the
// current user id at call time.
await this.appFrame!.waitForFunction(
(title) => {
const td = (window as any).__testData;
const uid = td.currentUserId;
if (!uid) return false;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return false;
return td.isParticipating(event['@id'], td.currentUserId);
return !!event && td.isParticipating(event['@id'], uid);
},
eventTitle,
);
expect(participating, `User should be participating in "${eventTitle}"`).to.be.true;
{ timeout: 30000 },
).catch(async () => {
// Reactive read lagged — confirm authoritatively against the broker, polling
// to absorb the index-append propagation lag of the per-entity fan-out.
const n = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return 0;
const uid = await td.ensureCurrentUser();
for (let i = 0; i < 12; i++) {
const c = await td.authParticipationCount(event['@id'], uid);
if (c > 0) return c;
await new Promise(r => setTimeout(r, 1500));
}
return 0;
}, eventTitle);
expect(n, `User should be participating in "${eventTitle}"`).to.be.greaterThan(0);
});
});
Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
const participating = await this.appFrame!.evaluate(
// AUTHORITATIVE: the désinscription must be durable at the DATA level — the
// broker itself must hold 0 participations for (event, user). The reactive read
// can lag or briefly resurrect; the broker count is the source of truth. Poll it
// to 0 (bounded).
await this.appFrame!.waitForFunction(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return false;
return td.isParticipating(event['@id'], td.currentUserId);
return !!event && !td.isParticipating(event['@id'], td.currentUserId);
},
eventTitle,
);
expect(participating, `User should NOT be participating in "${eventTitle}"`).to.be.false;
{ timeout: 20000 },
).catch(() => { /* fall through to the authoritative broker check */ });
const n = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return -1;
return td.authParticipationCount(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
expect(n, `broker must hold 0 participations to "${eventTitle}" after leave`).to.equal(0);
});
Then('l\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) {
// participantCount is persisted via SPARQL (durable); the reactive event re-read
// may lag the write, so poll until it reflects the expected value.
await this.appFrame!.waitForFunction(
([title, expected]: [string, number]) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
return !!event && event.participantCount === expected;
},
[eventTitle, expectedCount] as [string, number],
{ timeout: 20000 },
).catch(() => { /* surface the actual value in the assertion below */ });
const count = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
@@ -133,16 +184,25 @@ Then('l\'événement {string} compte {int} participants', async function (this:
});
Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
const found = await this.appFrame!.evaluate(
await this.appFrame!.waitForFunction(
(title) => {
const td = (window as any).__testData;
const uid = td.currentUserId;
if (!uid) return false;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return false;
return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);
return !!event && td.getEventParticipants(event['@id']).some((p: any) => p.user === uid);
},
eventTitle,
);
expect(found, `User should appear in participants of "${eventTitle}"`).to.be.true;
{ timeout: 30000 },
).catch(async () => {
const n = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return 0;
return td.authParticipationCount(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
expect(n, `User should appear in participants of "${eventTitle}"`).to.be.greaterThan(0);
});
});
Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
@@ -159,14 +219,14 @@ Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'év
});
Then('l\'inscription est idempotente pour l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
const count = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return 0;
return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length;
},
eventTitle,
);
expect(count, 'User should have exactly one participation record').to.equal(1);
// Idempotence at the DATA level: exactly ONE participation on the broker for
// (event, user), no matter how many times the join was attempted. Assert the
// AUTHORITATIVE broker count == 1 (bypasses reactive-read lag/dupes).
const n = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return -1;
return td.authParticipationCount(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
expect(n, 'User should have exactly one participation record on the broker').to.equal(1);
});
@@ -14,18 +14,20 @@ Given('le wallet contient l\'entité protégée du compte {string}', async funct
// joinEvent is idempotent on (event, user), so re-runs don't accumulate.
await this.appFrame!.evaluate(async () => {
const td = (window as any).__testData;
await td.joinEvent('urn:pc:event', 'urn:pc:p1');
await td.joinEvent('urn:pc:event', 'urn:pc:p2');
// Store-root protected document (governed by governProtected/FilterProbe) —
// RAW path so the participations land in that document, not per-entity docs.
td.rawJoin('urn:pc:event', 'urn:pc:p1');
td.rawJoin('urn:pc:event', 'urn:pc:p2');
});
await this.appFrame!.waitForFunction(
() => {
const ps = [...(window as any).__testData.participations];
const ps = [...(window as any).__testData.rawParticipations];
return ps.some((p: any) => p.user === 'urn:pc:p1') && ps.some((p: any) => p.user === 'urn:pc:p2');
},
null,
{ timeout: 15000 },
);
const total = await this.appFrame!.evaluate(() => [...(window as any).__testData.participations].length);
const total = await this.appFrame!.evaluate(() => [...(window as any).__testData.rawParticipations].length);
(this as any).pc = { owner, total };
expect(total, 'the protected document holds participations').to.be.greaterThan(0);
});
@@ -14,12 +14,14 @@ Given('le wallet contient des participations dans un document', async function (
// joinEvent is idempotent on (event,user), so this doesn't accumulate.
await this.appFrame!.evaluate(async () => {
const td = (window as any).__testData;
await td.joinEvent('urn:rf:event', 'urn:rf:p1');
await td.joinEvent('urn:rf:event', 'urn:rf:p2');
// Store-root document (the one FilterProbe/governDocument govern) — use the
// RAW path so the participations land in `documentNuri`, not per-entity docs.
td.rawJoin('urn:rf:event', 'urn:rf:p1');
td.rawJoin('urn:rf:event', 'urn:rf:p2');
});
await this.appFrame!.waitForFunction(
() => {
const ps = [...(window as any).__testData.participations];
const ps = [...(window as any).__testData.rawParticipations];
return ps.some((p: any) => p.user === 'urn:rf:p1') && ps.some((p: any) => p.user === 'urn:rf:p2');
},
null,
@@ -28,7 +30,7 @@ Given('le wallet contient des participations dans un document', async function (
const data = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
// Raw set (no policy yet) → true total in the document.
return { total: [...td.participations].length, documentNuri: td.documentNuri };
return { total: [...td.rawParticipations].length, documentNuri: td.documentNuri };
});
(this as any).rf = { ...data, reader: 'urn:rf:alice', other: 'urn:rf:bob' };
expect(data.total, 'the document holds participations').to.be.greaterThan(0);
+97 -30
View File
@@ -14,6 +14,7 @@ import {
insertNotification,
readRegistrationNotifications,
deleteParticipation,
countUserParticipations,
} from '../data/registration';
import {
CURRENT_USER_ID,
@@ -29,6 +30,7 @@ import { declareConnections } from '@ng-eventually/client/polyfill';
import { listEntityDocs, createEntityDoc } from '../utils/storeRegistry';
import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
import {
FpEventShapeType,
FpUserProfileShapeType,
@@ -69,7 +71,7 @@ interface FestipodDataContextValue {
selectedUser: FpUserData | undefined;
createEvent(event: Omit<FpEventData, 'id'>): Promise<FpEventData>;
updateEvent(id: string, updates: Partial<FpEventData>): void;
updateEvent(id: string, updates: Partial<FpEventData>): void | Promise<void>;
joinEvent(eventId: string, userId?: string): Promise<void> | void;
leaveEvent(eventId: string, userId?: string): Promise<void> | void;
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
@@ -411,7 +413,17 @@ function useNgData(): FestipodDataContextValue {
(username ? users.find(u => normalizeUsername(u.username) === normalizeUsername(username)) : undefined)
|| users.find(u => u.username === '@mariedupont')
|| users[0];
const currentUserId = currentUser?.id || '';
// The current user's PRINCIPAL. When logged in, this is a STABLE
// username-derived id (`urn:festipod:user:<normalized-username>`) — available
// IMMEDIATELY (no dependency on the protected profile read, which can lag) and
// INVARIANT (it never flips from a fallback to the profile IRI mid-session,
// which would desync a participation written under one value from a check under
// the other). It is the SAME principal the SDK identity (`setCurrentUser`) and
// the cap owner derive from the username, so participations keyed on it are
// consistent with reads and isolation. Falls back to the read profile's IRI only
// when there is no login (dev/demo).
const currentUserId =
(username ? `urn:festipod:user:${normalizeUsername(username)}` : (currentUser?.id || ''));
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
@@ -463,11 +475,27 @@ function useNgData(): FestipodDataContextValue {
// document NURI crosses here.
useEffect(() => {
if (!ready || !currentUserId) return;
// Connection principals must be the SAME key space as the cap owners: the
// SDK keys caps on the NORMALIZED USERNAME (`createEntityDoc` opens each doc
// with `normalizeUsername(owner)`, and login sets the reader identity via
// `setCurrentUser(normalizeUsername(username))`). The app models friendships
// with user IRIs, so map each peer IRI → its username key before declaring,
// and assert AS the current user's username key. Peers with no known username
// are skipped (can't be keyed). This is what makes "protected = my bilateral
// connections" actually discriminate in @data.
const usernameOf = (userIri: string): string | undefined => {
const u = users.find(x => x.id === userIri);
return u?.username ? normalizeUsername(u.username) : undefined;
};
const selfKey = username ? normalizeUsername(username) : usernameOf(currentUserId);
if (!selfKey) return;
const myPeers = friendships
.filter(f => f.userId === currentUserId || f.friendId === currentUserId)
.map(f => (f.userId === currentUserId ? f.friendId : f.userId));
declareConnections(myPeers, currentUserId);
}, [ready, friendships, currentUserId]);
.map(f => (f.userId === currentUserId ? f.friendId : f.userId))
.map(usernameOf)
.filter((k): k is string => !!k);
declareConnections(myPeers, selfKey);
}, [ready, friendships, currentUserId, users, username]);
const queries = buildQueries(
events, users, participations, meetingPoints, friendships, currentUserId,
@@ -492,27 +520,30 @@ function useNgData(): FestipodDataContextValue {
// ReadCap policy (public → world-readable). Fall back to a generic account
// label when no login is present (dev/demo).
const owner = username || currentUserId || 'anon';
// Create the event's OWN document in the PUBLIC scope (one doc per entity).
// Create the event's OWN document in the PUBLIC scope (one doc per entity),
// then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via
// the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed
// per-entity doc against the real broker. Register the doc so the reactive
// read (`useShape({ graphs })`) picks the event up. The written subject IRI is
// the event's `@id`.
const eventGraph = await createEntityDoc(owner, 'public');
const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, {
title: str(event.title), description: str(event.description), date: str(event.date),
location: str(event.location), distance: flt(event.distance),
participantCount: int(event.participantCount || 1),
coverImage: str(event.coverImage), hostName: str(event.hostName), hostInitials: str(event.hostInitials),
});
registerDoc('public', eventGraph);
eventsShape.ngSet.add({
"@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
location: event.location, distance: event.distance,
participantCount: event.participantCount || 1,
coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials,
} as FpEvent);
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
if (currentUserId) {
// The host's participation is its OWN document in the PROTECTED scope.
const partGraph = await createEntityDoc(owner, 'protected');
await writeEntity(partGraph, ENTITY_TYPE.participation, {
event: iri(eventId), user: iri(currentUserId), isConfirmed: bool(true),
});
registerDoc('protected', partGraph);
participationsShape.ngSet.add({
"@graph": partGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
setSelectedEventId(eventId);
}
const addedEvent = { "@id": eventId, title: event.title } as FpEvent;
// Make the PUBLIC event discoverable: submit its reference to the SDK global
// discovery index (an SDK act — the app holds no index/store id). The SDK
// enforces public-only: passing the event's own document lets it refuse a
@@ -531,9 +562,12 @@ function useNgData(): FestipodDataContextValue {
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [eventsShape.ngSet, participationsShape.ngSet, currentUserId, username, registerDoc]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === id);
// The event's `@id` is its own document NURI (one entity = one document); use
// it as both the write graph and the subject.
const graph = ngEvent?.["@graph"] || id;
if (ngEvent) {
if (updates.title !== undefined) ngEvent.title = updates.title;
if (updates.description !== undefined) ngEvent.description = updates.description;
@@ -542,14 +576,34 @@ function useNgData(): FestipodDataContextValue {
if (updates.distance !== undefined) ngEvent.distance = updates.distance;
if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount;
}
// Persist `participantCount` DURABLY (a mutable field). An in-place ORM
// mutation is local only — a later reactive re-sync from the broker reverts it
// to the stored value; the SPARQL update makes it stick and the re-read match.
if (updates.participantCount !== undefined && graph) {
await updateEntityField(graph, id, 'participantCount', int(updates.participantCount))
.catch(err => console.error('[FestipodData] persist participantCount failed:', err));
}
}, [eventsShape.ngSet]);
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (existing) {
console.log('[FestipodData] Already participating, skipping');
// A Participation MUST carry a user principal (SHEX `fp:user` is mandatory) —
// writing one without it produces an entity the ORM drops on read (the
// participation silently never round-trips). Refuse an empty principal rather
// than persist a broken participation. The caller resolves a real user id (the
// current user's IRI) before joining.
if (!uid) {
console.error('[FestipodData] joinEvent: empty user principal — refusing to write a participation with no fp:user.');
return;
}
// IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set.
// The reactive participation set can lag a just-written participation, so a
// second join checking only the set would write a DUPLICATE (breaking "exactly
// one participation"). The broker query sees the real state regardless of lag.
const already = await countUserParticipations(eventId, uid).catch(() => 0);
if (already > 0) {
console.log('[FestipodData] Already participating (broker-confirmed), skipping');
return;
}
// 1) Persist the Participation as its OWN document in the PROTECTED scope
@@ -557,14 +611,23 @@ function useNgData(): FestipodDataContextValue {
// The new doc joins the protected subscription set immediately (reactivity).
const owner = username || uid || 'anon';
const partGraph = await createEntityDoc(owner, 'protected');
// WRITE the participation RDF DIRECTLY into its own document (writeEntity) —
// not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed
// per-entity doc against the real broker). Register the doc for the reactive
// read. The written subject is the participation's `@id` (its own graph is
// partGraph, used later by the authoritative delete).
await writeEntity(partGraph, ENTITY_TYPE.participation, {
event: iri(eventId), user: iri(uid), isConfirmed: bool(true),
});
registerDoc('protected', partGraph);
participationsShape.ngSet.add({
"@graph": partGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = ngEvent.participantCount + 1;
const next = ngEvent.participantCount + 1;
ngEvent.participantCount = next;
// Persist the count durably (see updateEvent) so a reactive re-sync keeps it.
// Fire-and-forget: don't block the join's critical path on this write.
updateEntityField(ngEvent["@graph"] || eventId, eventId, 'participantCount', int(next))
.catch(err => console.error('[FestipodData] persist participantCount (join) failed:', err));
}
// 2) Notify the host: deposit into the event/host inbox via the GENERIC lib
// inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant
@@ -639,7 +702,11 @@ function useNgData(): FestipodDataContextValue {
participationsShape.ngSet.delete(ngPart);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
const next = Math.max(0, ngEvent.participantCount - 1);
ngEvent.participantCount = next;
// Fire-and-forget (don't block the leave's critical path).
updateEntityField(ngEvent["@graph"] || eventId, eventId, 'participantCount', int(next))
.catch(err => console.error('[FestipodData] persist participantCount (leave) failed:', err));
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
+139
View File
@@ -0,0 +1,139 @@
/**
* Direct per-entity RDF writer — the WRITE side of the one-document-per-entity
* model (rule_document-per-entity), decoupled from the reactive read.
*
* WHY a direct write (not the reactive `ngSet.add`). Each entity is its OWN
* document (`createEntityDoc(scope)`). To WRITE an entity through the reactive
* ORM set, its document must already be in the set's SUBSCRIPTION scope
* (`useShape(shape, { graphs })`) — otherwise the set is "readonly because scope
* is empty" (against the real broker) and the target repo isn't opened for the
* write. Registering a freshly-created document into that scope is React state,
* so it only takes effect on the NEXT render — you cannot create-then-add in one
* synchronous pass (seed loops, first create). The fake-ng unit tests missed this
* because they allow adds regardless of scope.
*
* So the entity RDF is written STRAIGHT into its own document via the SDK's
* `docs.sparqlUpdate` primitive (the real injected `ng`) — the same direct-write
* path `insertNotification` already uses. The document was just created and is
* openable, so the write lands immediately. The READ stays reactive: the document
* NURI is registered into the scope's `useShape({ graphs })`, and the ORM reads
* the entity back. Write (direct, per-document) and read (reactive fan-out) are
* decoupled — the model (one document per entity, per-document isolation) is
* unchanged; only the write mechanism moves off the scope-coupled ngSet.
*
* TYPED TERMS. The ORM reads back via the SHEX shapes (festipodShapes.shex), so
* each field must be written with the RIGHT RDF term: xsd:integer/float/boolean
* for the numeric/boolean fields, an IRI (`<…>`) for the reference fields
* (Participation.event / .user), a plain string literal otherwise. A field
* written with the wrong term (e.g. participantCount as a bare string) does not
* round-trip through the shape.
*/
import { docs, escapeLiteral, assertNuri } from '@ng-eventually/client';
import { sessionPromise } from '../utils/ngSession';
/** The RDF `@type` IRIs of the Festipod entities written per-document. */
export const ENTITY_TYPE = {
event: 'http://festipod.org/Event',
user: 'http://festipod.org/UserProfile',
participation: 'http://festipod.org/Participation',
} as const;
const FP = 'http://festipod.org/';
const XSD = 'http://www.w3.org/2001/XMLSchema#';
/** An entity field as an RDF term (matches the SHEX datatype of the field). */
export type EntityTerm =
| { kind: 'string'; value: string | undefined }
| { kind: 'integer'; value: number | undefined }
| { kind: 'float'; value: number | undefined }
| { kind: 'boolean'; value: boolean | undefined }
| { kind: 'iri'; value: string | undefined };
// --- term-builder shorthands (used by the callers to declare field types) ---
export const str = (value: string | undefined): EntityTerm => ({ kind: 'string', value });
export const int = (value: number | undefined): EntityTerm => ({ kind: 'integer', value });
export const flt = (value: number | undefined): EntityTerm => ({ kind: 'float', value });
export const bool = (value: boolean | undefined): EntityTerm => ({ kind: 'boolean', value });
export const iri = (value: string | undefined): EntityTerm => ({ kind: 'iri', value });
/** Render one term to its SPARQL object form (or null to skip the triple). */
function renderTerm(t: EntityTerm): string | null {
if (t.value === undefined || t.value === null || t.value === '') return null;
switch (t.kind) {
case 'string':
return `"${escapeLiteral(String(t.value))}"`;
case 'integer':
return `"${Math.trunc(t.value as number)}"^^<${XSD}integer>`;
case 'float':
return `"${t.value as number}"^^<${XSD}decimal>`;
case 'boolean':
return `"${t.value ? 'true' : 'false'}"^^<${XSD}boolean>`;
case 'iri':
// The reference IRIs are trusted-shaped NURIs (entity subject IRIs coming
// back from a prior write / the ORM) → validate as a NURI, embed as `<…>`.
return `<${assertNuri(String(t.value))}>`;
}
}
/**
* Persist a single-valued field of an existing entity: DELETE the old triple(s)
* for `<subject> <fp:field> ?o` then INSERT the new term. Used for mutable fields
* like `participantCount` — an in-place ORM mutation is LOCAL only and gets
* overwritten when the reactive read re-syncs the doc from the broker (reverting
* to the persisted value); persisting it via SPARQL makes the change durable and
* the re-read consistent. `subject` is the entity `@id` (= its document NURI).
*/
export async function updateEntityField(
graphNuri: string,
subject: string,
field: string,
term: EntityTerm,
): Promise<void> {
const sid = (await sessionPromise).session_id;
const g = assertNuri(graphNuri);
const s = assertNuri(subject);
const pred = `${FP}${field}`;
const obj = renderTerm(term);
const del = `DELETE WHERE { GRAPH <${g}> { <${s}> <${pred}> ?o } }`;
await docs.sparqlUpdate(sid, del, graphNuri);
if (obj !== null) {
const ins = `INSERT DATA { GRAPH <${g}> { <${s}> <${pred}> ${obj} } }`;
await docs.sparqlUpdate(sid, ins, graphNuri);
}
}
/**
* Write ONE entity as RDF into its OWN document (`graphNuri`, from
* `createEntityDoc`). `typeIri` is the entity `@type`; `fields` maps LOCAL field
* names (e.g. `title`, `participantCount`) to typed terms — each becomes the
* predicate `http://festipod.org/<field>` with its term rendered per its SHEX
* datatype. Empty/undefined values are skipped. Returns the subject IRI (the ORM
* surfaces it as the entity's `@id`).
*/
export async function writeEntity(
graphNuri: string,
typeIri: string,
fields: Record<string, EntityTerm>,
): Promise<string> {
const sid = (await sessionPromise).session_id;
const g = assertNuri(graphNuri);
// The entity IS its own document (one document per entity), so its subject IRI
// is the DOCUMENT NURI itself (a `did:ng:…`). This gives the ORM a `did:ng:`
// `@id` (what the @data assertions expect) and makes the entity self-addressing.
const subject = graphNuri;
const triples: string[] = [`a <${typeIri}>`];
for (const [field, term] of Object.entries(fields)) {
const obj = renderTerm(term);
if (obj === null) continue;
triples.push(`<${FP}${field}> ${obj}`);
}
const update = `
INSERT DATA {
GRAPH <${g}> {
<${assertNuri(subject)}> ${triples.join(' ;\n ')} .
}
}`;
await docs.sparqlUpdate(sid, update, graphNuri);
return subject;
}
+21 -1
View File
@@ -19,7 +19,7 @@
import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client';
import { sessionPromise } from '../utils/ngSession';
import { resolveInboxAnchor } from '../utils/storeRegistry';
import { resolveInboxAnchor, listEntityDocs } from '../utils/storeRegistry';
import type { FpNotificationData } from './types';
/** Notification IRI/type constants (mirror the SHEX Notification shape). */
@@ -151,6 +151,26 @@ export async function readRegistrationNotifications(
return notifs;
}
/**
* AUTHORITATIVE count of a user's Participations to an event across ALL protected
* per-entity documents (the broker, not the reactive set). Used to make join
* IDEMPOTENT reliably: the reactive participation set can lag behind a just-written
* participation, so a second join checking only the reactive set would write a
* duplicate. Querying the broker sees the real state regardless of read lag.
*/
export async function countUserParticipations(
eventId: string,
userId: string,
): Promise<number> {
const sid = (await sessionPromise).session_id;
const docs_ = await listEntityDocs('protected');
let total = 0;
for (const g of docs_) {
total += await countParticipations(sid, g, eventId, userId).catch(() => 0);
}
return total;
}
/**
* How the deletion identified the Participation, for the caller's verification.
* `remaining` is the authoritative post-delete count of Participations still
+221 -72
View File
@@ -7,9 +7,10 @@
*
* Exposes window.__testData for Playwright-driven Cucumber steps.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { AccountProvider, useAccount } from '../context/AccountContext';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
// useShape routed through the lib (SDK-identical surface); caps from /polyfill.
import { useShape, docs, inbox as docsInbox } from '@ng-eventually/client';
@@ -25,24 +26,43 @@ import {
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { seedEvents, seedUsers, seedParticipations } from '../data/seedData';
import { bootstrapWallet } from '../utils/ngBootstrap';
import { ensureGraphNuri } from '../utils/ngGraph';
import { normalizeUsername } from '../context/AccountContext';
// ============================================================================
// App — uses real providers (same tree as the real app)
// ============================================================================
// Default @data identity — the seed owner. The harness has no login UI, so we
// establish a default account (as the real app would after login) so the SDK
// knows WHO is reading. Without a current identity the per-document ReadCap
// filter passes only PUBLIC documents, so the current user's own PROTECTED
// entities (profile, participations) would be hidden and never round-trip.
const DEFAULT_HARNESS_USER = '@mariedupont';
function DataHarnessNG() {
return (
<NextGraphProvider>
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
<AccountProvider>
<HarnessLogin />
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
</AccountProvider>
</NextGraphProvider>
);
}
/** Establish the default @data identity once, so `setCurrentUser` fires (via the
* AccountProvider effect) and the current user can read their own protected
* entities. Mirrors the real app's post-login state. */
function HarnessLogin() {
const { username, login } = useAccount();
useEffect(() => {
if (!username) login(DEFAULT_HARNESS_USER);
}, [username, login]);
return null;
}
// Wait for NG connection before exposing the test bridge
function HarnessRouter() {
const { status } = useNextGraph();
@@ -65,6 +85,14 @@ function HarnessRouter() {
function ConnectedHarness() {
const ngCtx = useNextGraph();
const appData = useFestipodData();
// The bridge is built once inside an effect (below) and its getters close over
// `appData`. `appData` is a NEW object every render (its `events`/`users` reflect
// the latest per-entity reads), so a captured snapshot goes STALE — after
// loadTestData/registerDoc re-renders, the captured `appData.events` still reads
// 0. Keep a ref to the LIVE `appData` and read it in the getters so they always
// see the current data. Updated on every render.
const appDataRef = useRef(appData);
appDataRef.current = appData;
// Private store NURI — the inbox shim anchor + the ReadCap-governed document.
const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
@@ -93,64 +121,98 @@ function ConnectedHarness() {
const timer = setTimeout(() => {
const session = ngCtx.session!;
// Get current user ID
let currentUserId = '';
const existingUsers = [...users];
if (existingUsers.length > 0) {
currentUserId = existingUsers[0]['@id'];
}
// Current user id — resolved through the app data context (the per-entity
// path), so it matches the principal the app writes/reads with. Falls back
// to the raw ORM set only if the app hasn't hydrated a user yet.
const currentUserId = appData.currentUserId || [...users][0]?.['@id'] || '';
// T03.i round-trip fix. The app now writes ONE DOCUMENT PER ENTITY (events →
// public per-entity docs, participations/users → protected per-entity docs)
// via `createEntityDoc`, and reads a scope by subscribing to the SET of its
// per-entity documents (`listEntityDocs` + registerDoc). The old bridge read
// the STORE-ROOT NURI directly (`useShape(protectedNuri)`), which never sees
// the per-entity docs — so seed/creation didn't round-trip. The step-facing
// `events/users/participations` + mutations/queries now delegate to the APP
// data context (`appData`), i.e. the exact per-entity path the screens use.
// The step contract (`[...td.events]` with `@id`/`title`/`participantCount`,
// `.size`, `p.user`/`p.event`) is preserved by mapping the app types to that
// shape in a Set-like adapter.
// Always read the LIVE appData (via the ref) — a captured snapshot goes stale
// after loadTestData/registerDoc re-renders (see appDataRef above).
const AD = () => appDataRef.current;
const eventAdapter = () =>
AD().events.map(e => ({ '@id': e.id, title: e.title, participantCount: e.participantCount }));
const userAdapter = () =>
AD().users.map(u => ({ '@id': u.id, username: u.username, name: u.name }));
const partAdapter = () =>
AD().participations.map(p => ({ '@id': p.id, event: p.eventId, user: p.userId, isConfirmed: p.isConfirmed }));
/** A read-only Set-like over an app-backed array snapshot: supports
* `[...x]`, `x.size`, and a no-op `delete` (the "empty wallet" scenario
* runs before any seed, so there is nothing to delete). */
const setLike = <T,>(snapshot: () => T[]) => ({
get size() { return snapshot().length; },
[Symbol.iterator]() { return snapshot()[Symbol.iterator](); },
delete(_item: T) { /* app-backed: seeded docs aren't deletable here */ },
});
// Expose the test bridge
(window as any).__testData = {
ready: true,
// --- Raw DeepSignalSets (backward compatible with existing tests) ---
events,
users,
participations,
currentUserId,
// --- App-backed entity views (the per-entity path the screens use) ---
get events() { return setLike(eventAdapter); },
get users() { return setLike(userAdapter); },
get participations() { return setLike(partAdapter); },
get currentUserId() { return AD().currentUserId || currentUserId; },
session,
// --- App-level view (through real providers, same as what screens see) ---
appData,
ngStatus: ngCtx.status,
// --- Query helpers ---
// --- Query helpers (app-backed) ---
getEvent(id: string) {
return [...events].find(e => e['@id'] === id);
return eventAdapter().find(e => e['@id'] === id);
},
getEventByTitle(title: string) {
return [...events].find(e => e.title === title);
return eventAdapter().find(e => e.title === title);
},
isParticipating(eventId: string, userId: string) {
return [...participations].some(p => p.event === eventId && p.user === userId);
return AD().isParticipating(eventId, userId);
},
getEventParticipants(eventId: string) {
return [...participations].filter(p => p.event === eventId);
// Return the participation records (with `.user`) for this event —
// matches the step contract `getEventParticipants(id).some(p => p.user…)`.
return partAdapter().filter(p => p.event === eventId);
},
// --- Mutations (direct ngSet access) ---
// --- Mutations (app path: per-entity docs + reactivity) ---
async joinEvent(eventId: string, userId: string) {
await AD().joinEvent(eventId, userId);
},
async leaveEvent(eventId: string, userId: string) {
await AD().leaveEvent(eventId, userId);
},
// --- RAW store-root path (workshop ReadCap/connection probes ONLY) -----
// The per-document ReadCap probe scenarios (read-filter, protected-
// connections) govern the STORE-ROOT protected document (`documentNuri` =
// protectedNuri) via <FilterProbe>, so they need participations written
// into THAT document — not the per-entity docs the app path uses. These
// raw helpers write/read the store-root ORM set directly, keeping those
// probes on the exact document they govern.
get rawParticipations() { return participations; },
rawJoin(eventId: string, userId: string) {
const already = [...participations].some(p => p.event === eventId && p.user === userId);
if (already) return;
const graph = await ensureGraphNuri(events as any, users as any, participations as any);
participations.add({
'@graph': graph,
'@graph': protectedNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: eventId,
user: userId,
isConfirmed: true,
} as FpParticipation);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = ev.participantCount + 1;
},
leaveEvent(eventId: string, userId: string) {
const part = [...participations].find(p => p.event === eventId && p.user === userId);
if (!part) return;
participations.delete(part);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
},
// --- Real app-path registration (T02.c) ----------------------------
@@ -158,14 +220,14 @@ function ConnectedHarness() {
// the @data scenario faces the same inbox-deposit + notification +
// SPARQL-DELETE path as the running app — not the direct ngSet helpers
// above (kept for backward compatibility with existing @data steps).
/** Create an event through the REAL app path (appData.createEvent → NG),
/** Create an event through the REAL app path (AD().createEvent → NG),
* persisting an FpEvent into the shared protected store. Returns its id.
* Used by the T02.f multi-browser flow: browser A (host) creates, then a
* SECOND browser (independent NG session, same wallet) reads it back via
* the broker and registers to it. Resolves the id from the returned
* record (falls back to a title lookup in the reactive set). */
async createEventReal(title: string) {
const created: any = await appData.createEvent({
const created: any = await AD().createEvent({
title,
date: '2026-08-01',
time: '18:00',
@@ -174,26 +236,40 @@ function ConnectedHarness() {
participantCount: 0,
} as any);
const id = created?.id || created?.['@id'] ||
[...events].find(e => e.title === title)?.['@id'] || '';
AD().events.find(e => e.title === title)?.id || '';
return { id, title };
},
async appJoinEvent(eventId: string, userId?: string) {
await appData.joinEvent(eventId, userId);
await AD().joinEvent(eventId, userId);
},
async appLeaveEvent(eventId: string, userId?: string) {
await appData.leaveEvent(eventId, userId);
await AD().leaveEvent(eventId, userId);
},
/** A LIVE current user id, resolved from the users set AT CALL TIME (not
* frozen at bridge-build). Prefers the app context's principal; falls
* back to the first user in the set. Guaranteed non-empty once users
* have hydrated — the real principal a Participation.user must carry. */
liveUserId() {
return appData.currentUserId || [...users][0]?.['@id'] || '';
return AD().currentUserId || userAdapter()[0]?.['@id'] || '';
},
/** Wait until the current user's principal is resolved (the profile read
* hydrated). A Participation needs a real `fp:user` IRI, and the profile
* read can lag behind the public events on a fresh session — join AFTER
* this resolves so the participation is never written with an empty user.
* Returns the resolved id (or '' on timeout). */
async ensureCurrentUser(timeoutMs = 60000) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
const id = AD().currentUserId || userAdapter()[0]?.['@id'] || '';
if (id) return id;
await new Promise(r => setTimeout(r, 500));
}
return AD().currentUserId || userAdapter()[0]?.['@id'] || '';
},
/** isParticipating for the LIVE current user id (call-time resolved). */
liveIsParticipating(eventId: string) {
const uid = appData.currentUserId || [...users][0]?.['@id'] || '';
return [...participations].some(p => p.event === eventId && p.user === uid);
const uid = AD().currentUserId || userAdapter()[0]?.['@id'] || '';
return AD().isParticipating(eventId, uid);
},
/** The host inbox NURI for an event (domain glue, T02.c). */
async eventInboxNuri(eventId: string) {
@@ -210,7 +286,7 @@ function ConnectedHarness() {
},
/** Host-facing notifications currently surfaced by the data context. */
appNotifications() {
return appData.notifications;
return AD().notifications;
},
/**
* AUTHORITATIVE participation count for (event, user), re-queried straight
@@ -224,33 +300,91 @@ function ConnectedHarness() {
const esc = (v: string) =>
v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
const query = `
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE {
GRAPH <${protectedNuri}> {
?s a <http://festipod.org/Participation> ;
<http://festipod.org/event> ?event ;
<http://festipod.org/user> ?user .
FILTER( STR(?event) = "${esc(eventId)}" && STR(?user) = "${esc(userId)}" )
}
}`;
const result: any = await docs.sparqlQuery(session.session_id, query, undefined, protectedNuri);
const rows = Array.isArray(result) ? result : result?.results?.bindings ?? [];
const n = parseInt(rows[0]?.n?.value ?? '0', 10);
return Number.isFinite(n) ? n : 0;
},
updateEvent(eventId: string, updates: Record<string, any>) {
const ev = [...events].find(e => e['@id'] === eventId);
if (!ev) return;
for (const [key, value] of Object.entries(updates)) {
if (key !== '@id' && key !== '@graph' && key !== '@type') {
(ev as any)[key] = value;
}
// Participations are ONE DOCUMENT PER ENTITY (protected scope), not the
// store root — so re-query the broker across every protected per-entity
// document (the union `listEntityDocs('protected')`) rather than the
// store-root graph. This stays authoritative (bypasses the reactive set):
// it counts the (event,user) triples actually persisted in the broker.
const reg = await import('../utils/storeRegistry');
const protectedDocs = await reg.listEntityDocs('protected');
let total = 0;
for (const g of protectedDocs) {
const query = `
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE {
GRAPH <${g}> {
?s a <http://festipod.org/Participation> ;
<http://festipod.org/event> ?event ;
<http://festipod.org/user> ?user .
FILTER( STR(?event) = "${esc(eventId)}" && STR(?user) = "${esc(userId)}" )
}
}`;
const result: any = await docs.sparqlQuery(session.session_id, query, undefined, g);
const rows = Array.isArray(result) ? result : result?.results?.bindings ?? [];
const n = parseInt(rows[0]?.n?.value ?? '0', 10);
if (Number.isFinite(n)) total += n;
}
return total;
},
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
// it right after must wait for the write to land.
await AD().updateEvent(eventId, updates as any);
},
/** Load the app's default seed data into the wallet */
loadTestData() {
return bootstrapWallet(events as any, users as any, participations as any);
/** Load the app's default seed data into the wallet (per-entity path). */
async loadTestData() {
return AD().loadTestData();
},
/** Empty the CONNECTED wallet: delete every domain entity triple from the
* per-entity documents (public + protected), so the reactive read returns
* 0. Used by "le portefeuille est vide" — with one-document-per-entity and
* a persistent broker, a real empty state needs the docs' CONTENT cleared
* (the store-root delete of the old model no longer applies). Bounded: on a
* freshly-provisioned wallet there are only a handful of entity docs. */
async clearWallet() {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
const [pub, prot] = await Promise.all([
reg.listEntityDocs('public'),
reg.listEntityDocs('protected'),
]);
const all = [...new Set([...pub, ...prot])];
await Promise.all(all.map(g =>
docs.sparqlUpdate(
session.session_id,
`DELETE { GRAPH <${g}> { ?s ?p ?o } } WHERE { GRAPH <${g}> {
?s a ?t . FILTER(?t IN (
<http://festipod.org/Event>,
<http://festipod.org/UserProfile>,
<http://festipod.org/Participation>) )
?s ?p ?o } }`,
g,
).catch(() => { /* best-effort per doc */ }),
));
return { cleared: all.length };
},
/** ONE-TIME CLEANUP (T03.i): the private store accumulated thousands of
* historical inbox-deposit triples across test runs (the old inbox anchor
* = private store), making `loadShim` a 60s+ full-graph scan. Delete every
* inbox Deposit triple from the private store so the shim query is fast
* again. Idempotent; safe (deposits are transient test cruft). New deposits
* now land in a dedicated inbox document (lib fix), so this won't re-grow. */
async cleanPrivateInbox() {
const priv = `did:ng:${session.private_store_id}`;
const t0 = Date.now();
const del = `
DELETE { GRAPH <${priv}> { ?s ?p ?o } }
WHERE {
GRAPH <${priv}> {
?s a <urn:ng-eventually:inbox:Deposit> ;
?p ?o .
}
}`;
await docs.sparqlUpdate(session.session_id, del, priv);
return { deleteMs: Date.now() - t0 };
},
// --- ReadCap read-filter validation (see decision_2026-06-17_eventually-library) ---
@@ -356,8 +490,16 @@ function ConnectedHarness() {
await reg.ensureAccount('@fan-b');
const docA = await reg.createEntityDoc('@fan-a', 'public');
const docB = await reg.createEntityDoc('@fan-b', 'public');
reg.resetRegistryCache();
const listed = await reg.listEntityDocs('public');
// The index-append (which makes docA/docB show up in listEntityDocs) can
// lag behind createEntityDoc on the broker — poll until BOTH are listed
// (bounded) so the "index lists both docs" assertion isn't flaky.
let listed: string[] = [];
for (let i = 0; i < 12; i++) {
reg.resetRegistryCache();
listed = await reg.listEntityDocs('public');
if (listed.includes(docA) && listed.includes(docB)) break;
await new Promise(r => setTimeout(r, 1500));
}
setFanoutGraphs([docA, docB]);
return { docA, docB, listed };
},
@@ -379,15 +521,22 @@ function ConnectedHarness() {
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);
// Deposit AS the current identity: the inbox guard binds `from` to the
// CURRENT user and rejects a spoofed `from`. So make the publisher the
// current identity (its normalized-username key = the cap-owner key),
// then submit WITHOUT a spoofed explicit `from` — the SDK stamps the
// current identity itself (anonymous submission also allowed).
setCurrentUser(normalizeUsername(publisher));
await disc.submitEventToIndex({ doc, id: doc, title }, getCurrentUser());
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.
// Become the discoverer identity (reads the world-readable public index).
await reg.ensureAccount(discoverer);
setCurrentUser(normalizeUsername(discoverer));
reg.resetRegistryCache();
// 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
+55 -74
View File
@@ -17,8 +17,8 @@ import { normalizeUsername } from '../context/AccountContext';
import {
seedEvents,
seedUsers,
seedParticipations,
} from '../data/seedData';
import { writeEntity, ENTITY_TYPE, str, int, flt, bool } from '../data/entityWrites';
/** Scope of a seed entity + how to create its own document (SDK create). */
export type Scope = 'public' | 'protected' | 'private';
@@ -33,17 +33,14 @@ export interface BootstrapResult {
}
/**
* Flush ORM microtask batch and give the broker time to process.
*
* The ORM batches signal mutations into microtasks. A `Promise.resolve()`
* flushes the pending batch to the NG engine. The short delay lets the
* broker create the new repo/document before the next add.
* Seed default data — ONE DOCUMENT PER ENTITY (rule_document-per-entity), written
* DIRECTLY into each entity's own document (see `entityWrites.writeEntity`) rather
* than via the reactive `ngSet.add`. The ngSets are read ONLY to detect an
* already-seeded wallet (their `@graph`-scoped write path can't add into a
* not-yet-subscribed per-entity document — that's the round-trip bug this fixes).
* The created document NURIs are returned so the caller registers them into the
* scope's `useShape({ graphs })` for the reactive READ.
*/
async function flushAndWait(ms = 100): Promise<void> {
await Promise.resolve(); // flush ORM microtask batch
await new Promise(r => setTimeout(r, ms)); // let broker process
}
export async function bootstrapWallet(
ngEvents: DeepSignalSet<FpEvent>,
ngUsers: DeepSignalSet<FpUserProfile>,
@@ -60,76 +57,60 @@ export async function bootstrapWallet(
console.log('[Bootstrap] First time for this wallet — seeding per-entity docs...');
// Seed users — one PROTECTED document each, owned by that user's account.
// OWNER: all seed entities are owned by the SINGLE seed owner account (the
// perceived-login user). The seed users are FIXTURES, not real login accounts —
// minting a full owner account per seed user would be dozens of
// broker round-trips (unusably slow against the real broker) with no product
// meaning. One account owns them; each entity is still ITS OWN document (the
// model's per-document isolation is unchanged — only the cap OWNER is shared).
const seedOwner = seedUsers[0] ? normalizeUsername(seedUsers[0].username) : 'seed';
// SEED FOOTPRINT (perf). Each entity is its OWN document, and each `docCreate`
// is a SERIAL ~2s broker round-trip (the verifier serializes creations — they
// do NOT parallelize), so the seed cost is ~2s × (#docs). Seeding the full
// fixture (14 users + 5 events + 5 participations = 24 docs) blows past the test
// step budget. So the CONNECTED seed writes only what the app/@data needs to be
// exercised: ALL events (looked up by title), a FEW user profiles ("wallet has
// users" + participant rendering), and NO seed participations — the inscription
// scenarios create their own participation live via joinEvent, and each event
// carries its own `participantCount`. The @ui/demo path still uses the full
// fixture (seedData) directly; only this NG bootstrap trims for round-trip speed.
const SEED_USER_LIMIT = 3;
const usersToSeed = seedUsers.slice(0, SEED_USER_LIMIT);
// Establish the owner account ONCE, serially, BEFORE any create: the first
// `createEntityDoc(seedOwner, …)` creates the owner account and
// caches it, so later concurrent calls don't race to re-create the account.
const firstUserGraph = await createEntityDoc(seedOwner, 'protected');
// Users — one PROTECTED document each. The written subject IRI is the entity's
// stable `@id`, kept in the id map.
const userIdMap = new Map<string, string>();
for (const u of seedUsers) {
const owner = normalizeUsername(u.username);
const graph = await createEntityDoc(owner, 'protected');
await Promise.all(usersToSeed.map(async (u, i) => {
const graph = i === 0 ? firstUserGraph : await createEntityDoc(seedOwner, 'protected');
createdDocs.protected.push(graph);
ngUsers.add({
"@graph": graph,
"@type": "http://festipod.org/UserProfile",
"@id": "",
name: u.name,
initials: u.initials,
username: u.username,
role: u.role,
isPublic: u.isPublic,
} as FpUserProfile);
await flushAndWait();
const added = [...ngUsers].find(nu => nu.username === u.username);
if (added) userIdMap.set(u.id, added["@id"]);
}
const id = await writeEntity(graph, ENTITY_TYPE.user, {
name: str(u.name), initials: str(u.initials), username: str(u.username),
role: str(u.role), isPublic: bool(u.isPublic),
});
userIdMap.set(u.id, id);
}));
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
// Seed events — one PUBLIC document each. The seed carries no host username, so
// the seed events are owned by the first seed user (a fixture-level choice).
const seedOwner = seedUsers[0] ? normalizeUsername(seedUsers[0].username) : 'seed';
// Events — one PUBLIC document each (all of them: looked up by title in @data).
const eventIdMap = new Map<string, string>();
for (const e of seedEvents) {
await Promise.all(seedEvents.map(async (e) => {
const graph = await createEntityDoc(seedOwner, 'public');
createdDocs.public.push(graph);
ngEvents.add({
"@graph": graph,
"@type": "http://festipod.org/Event",
"@id": "",
title: e.title,
description: e.description,
date: e.date,
location: e.location,
distance: e.distance,
participantCount: e.participantCount,
coverImage: e.coverImage,
hostName: e.hostName,
hostInitials: e.hostInitials,
} as FpEvent);
await flushAndWait();
const added = [...ngEvents].find(ne => ne.title === e.title);
if (added) eventIdMap.set(e.id, added["@id"]);
}
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events');
// Seed participations — one PROTECTED document each, owned by the participant.
let partCount = 0;
for (const p of seedParticipations) {
const eventIri = eventIdMap.get(p.eventId) || p.eventId;
const userIri = userIdMap.get(p.userId) || p.userId;
const seedUser = seedUsers.find(u => u.id === p.userId);
const owner = seedUser ? normalizeUsername(seedUser.username) : seedOwner;
const graph = await createEntityDoc(owner, 'protected');
createdDocs.protected.push(graph);
ngParticipations.add({
"@graph": graph,
"@type": "http://festipod.org/Participation",
"@id": "",
event: eventIri,
user: userIri,
isConfirmed: p.isConfirmed,
} as FpParticipation);
await flushAndWait();
partCount++;
}
console.log('[Bootstrap] Seeded', partCount, 'participations');
const id = await writeEntity(graph, ENTITY_TYPE.event, {
title: str(e.title), description: str(e.description), date: str(e.date),
location: str(e.location), distance: flt(e.distance),
participantCount: int(e.participantCount),
coverImage: str(e.coverImage), hostName: str(e.hostName), hostInitials: str(e.hostInitials),
});
eventIdMap.set(e.id, id);
}));
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events (participations created live)');
return { seeded: true, userIdMap, eventIdMap, createdDocs };
}