fix(data): restore per-entity write round-trip against the real broker
The per-document isolation refactor (one doc per entity) broke every @data
round-trip against the real broker (0 events readable) — fake-ng unit tests
missed it. Root causes + fixes:
- ngSet.add cannot write to an empty subscription scope ("Set is readonly
because scope is empty") → write each entity DIRECTLY into its own document via
SPARQL (new data/entityWrites.ts: writeEntity/updateEntityField), typing each
field with the correct RDF term per the SHEX shape (else the ORM drops the
entity on read). Reactive set stays read-only; the doc NURI is registered into
useShape({graphs}) for reactive reads.
- Current principal made STABLE and username-derived (urn:festipod:user:<name>),
available immediately at login and invariant — so a Participation's mandatory
fp:user is never empty and identity/cap-owner/connections all key on the same
value.
- Discovery deposits AS the current identity (harness sets current user first).
- Idempotence/deregistration checks made authoritative against the broker;
participantCount persisted via SPARQL. rule_document-per-entity enriched with
these write/read + stable-principal lessons.
Round-trip restored (seed readable, inscription+notif, persistent deregistration,
public discovery all pass in isolation). NOT yet stably green as a full suite:
@data oscillates 15–20/21 — residual failures are environmental (participation-
read fan-out lag on an accumulating persistent test wallet), same class as the
Chromium saturation; not a logic bug. Durable fix (follow-up): non-fan-out
materialized read + per-scenario test-wallet isolation. app build+tsc + lib 89
tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user