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:
Sylvain Duchesne
2026-07-04 17:26:31 +02:00
parent 3ad06dfaec
commit 966ba9855c
12 changed files with 745 additions and 252 deletions
@@ -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.