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
+221 -72
View File
@@ -7,9 +7,10 @@
*
* Exposes window.__testData for Playwright-driven Cucumber steps.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { AccountProvider, useAccount } from '../context/AccountContext';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
// useShape routed through the lib (SDK-identical surface); caps from /polyfill.
import { useShape, docs, inbox as docsInbox } from '@ng-eventually/client';
@@ -25,24 +26,43 @@ import {
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { seedEvents, seedUsers, seedParticipations } from '../data/seedData';
import { bootstrapWallet } from '../utils/ngBootstrap';
import { ensureGraphNuri } from '../utils/ngGraph';
import { normalizeUsername } from '../context/AccountContext';
// ============================================================================
// App — uses real providers (same tree as the real app)
// ============================================================================
// Default @data identity — the seed owner. The harness has no login UI, so we
// establish a default account (as the real app would after login) so the SDK
// knows WHO is reading. Without a current identity the per-document ReadCap
// filter passes only PUBLIC documents, so the current user's own PROTECTED
// entities (profile, participations) would be hidden and never round-trip.
const DEFAULT_HARNESS_USER = '@mariedupont';
function DataHarnessNG() {
return (
<NextGraphProvider>
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
<AccountProvider>
<HarnessLogin />
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
</AccountProvider>
</NextGraphProvider>
);
}
/** Establish the default @data identity once, so `setCurrentUser` fires (via the
* AccountProvider effect) and the current user can read their own protected
* entities. Mirrors the real app's post-login state. */
function HarnessLogin() {
const { username, login } = useAccount();
useEffect(() => {
if (!username) login(DEFAULT_HARNESS_USER);
}, [username, login]);
return null;
}
// Wait for NG connection before exposing the test bridge
function HarnessRouter() {
const { status } = useNextGraph();
@@ -65,6 +85,14 @@ function HarnessRouter() {
function ConnectedHarness() {
const ngCtx = useNextGraph();
const appData = useFestipodData();
// The bridge is built once inside an effect (below) and its getters close over
// `appData`. `appData` is a NEW object every render (its `events`/`users` reflect
// the latest per-entity reads), so a captured snapshot goes STALE — after
// loadTestData/registerDoc re-renders, the captured `appData.events` still reads
// 0. Keep a ref to the LIVE `appData` and read it in the getters so they always
// see the current data. Updated on every render.
const appDataRef = useRef(appData);
appDataRef.current = appData;
// Private store NURI — the inbox shim anchor + the ReadCap-governed document.
const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
@@ -93,64 +121,98 @@ function ConnectedHarness() {
const timer = setTimeout(() => {
const session = ngCtx.session!;
// Get current user ID
let currentUserId = '';
const existingUsers = [...users];
if (existingUsers.length > 0) {
currentUserId = existingUsers[0]['@id'];
}
// Current user id — resolved through the app data context (the per-entity
// path), so it matches the principal the app writes/reads with. Falls back
// to the raw ORM set only if the app hasn't hydrated a user yet.
const currentUserId = appData.currentUserId || [...users][0]?.['@id'] || '';
// T03.i round-trip fix. The app now writes ONE DOCUMENT PER ENTITY (events →
// public per-entity docs, participations/users → protected per-entity docs)
// via `createEntityDoc`, and reads a scope by subscribing to the SET of its
// per-entity documents (`listEntityDocs` + registerDoc). The old bridge read
// the STORE-ROOT NURI directly (`useShape(protectedNuri)`), which never sees
// the per-entity docs — so seed/creation didn't round-trip. The step-facing
// `events/users/participations` + mutations/queries now delegate to the APP
// data context (`appData`), i.e. the exact per-entity path the screens use.
// The step contract (`[...td.events]` with `@id`/`title`/`participantCount`,
// `.size`, `p.user`/`p.event`) is preserved by mapping the app types to that
// shape in a Set-like adapter.
// Always read the LIVE appData (via the ref) — a captured snapshot goes stale
// after loadTestData/registerDoc re-renders (see appDataRef above).
const AD = () => appDataRef.current;
const eventAdapter = () =>
AD().events.map(e => ({ '@id': e.id, title: e.title, participantCount: e.participantCount }));
const userAdapter = () =>
AD().users.map(u => ({ '@id': u.id, username: u.username, name: u.name }));
const partAdapter = () =>
AD().participations.map(p => ({ '@id': p.id, event: p.eventId, user: p.userId, isConfirmed: p.isConfirmed }));
/** A read-only Set-like over an app-backed array snapshot: supports
* `[...x]`, `x.size`, and a no-op `delete` (the "empty wallet" scenario
* runs before any seed, so there is nothing to delete). */
const setLike = <T,>(snapshot: () => T[]) => ({
get size() { return snapshot().length; },
[Symbol.iterator]() { return snapshot()[Symbol.iterator](); },
delete(_item: T) { /* app-backed: seeded docs aren't deletable here */ },
});
// Expose the test bridge
(window as any).__testData = {
ready: true,
// --- Raw DeepSignalSets (backward compatible with existing tests) ---
events,
users,
participations,
currentUserId,
// --- App-backed entity views (the per-entity path the screens use) ---
get events() { return setLike(eventAdapter); },
get users() { return setLike(userAdapter); },
get participations() { return setLike(partAdapter); },
get currentUserId() { return AD().currentUserId || currentUserId; },
session,
// --- App-level view (through real providers, same as what screens see) ---
appData,
ngStatus: ngCtx.status,
// --- Query helpers ---
// --- Query helpers (app-backed) ---
getEvent(id: string) {
return [...events].find(e => e['@id'] === id);
return eventAdapter().find(e => e['@id'] === id);
},
getEventByTitle(title: string) {
return [...events].find(e => e.title === title);
return eventAdapter().find(e => e.title === title);
},
isParticipating(eventId: string, userId: string) {
return [...participations].some(p => p.event === eventId && p.user === userId);
return AD().isParticipating(eventId, userId);
},
getEventParticipants(eventId: string) {
return [...participations].filter(p => p.event === eventId);
// Return the participation records (with `.user`) for this event —
// matches the step contract `getEventParticipants(id).some(p => p.user…)`.
return partAdapter().filter(p => p.event === eventId);
},
// --- Mutations (direct ngSet access) ---
// --- Mutations (app path: per-entity docs + reactivity) ---
async joinEvent(eventId: string, userId: string) {
await AD().joinEvent(eventId, userId);
},
async leaveEvent(eventId: string, userId: string) {
await AD().leaveEvent(eventId, userId);
},
// --- RAW store-root path (workshop ReadCap/connection probes ONLY) -----
// The per-document ReadCap probe scenarios (read-filter, protected-
// connections) govern the STORE-ROOT protected document (`documentNuri` =
// protectedNuri) via <FilterProbe>, so they need participations written
// into THAT document — not the per-entity docs the app path uses. These
// raw helpers write/read the store-root ORM set directly, keeping those
// probes on the exact document they govern.
get rawParticipations() { return participations; },
rawJoin(eventId: string, userId: string) {
const already = [...participations].some(p => p.event === eventId && p.user === userId);
if (already) return;
const graph = await ensureGraphNuri(events as any, users as any, participations as any);
participations.add({
'@graph': graph,
'@graph': protectedNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: eventId,
user: userId,
isConfirmed: true,
} as FpParticipation);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = ev.participantCount + 1;
},
leaveEvent(eventId: string, userId: string) {
const part = [...participations].find(p => p.event === eventId && p.user === userId);
if (!part) return;
participations.delete(part);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
},
// --- Real app-path registration (T02.c) ----------------------------
@@ -158,14 +220,14 @@ function ConnectedHarness() {
// the @data scenario faces the same inbox-deposit + notification +
// SPARQL-DELETE path as the running app — not the direct ngSet helpers
// above (kept for backward compatibility with existing @data steps).
/** Create an event through the REAL app path (appData.createEvent → NG),
/** Create an event through the REAL app path (AD().createEvent → NG),
* persisting an FpEvent into the shared protected store. Returns its id.
* Used by the T02.f multi-browser flow: browser A (host) creates, then a
* SECOND browser (independent NG session, same wallet) reads it back via
* the broker and registers to it. Resolves the id from the returned
* record (falls back to a title lookup in the reactive set). */
async createEventReal(title: string) {
const created: any = await appData.createEvent({
const created: any = await AD().createEvent({
title,
date: '2026-08-01',
time: '18:00',
@@ -174,26 +236,40 @@ function ConnectedHarness() {
participantCount: 0,
} as any);
const id = created?.id || created?.['@id'] ||
[...events].find(e => e.title === title)?.['@id'] || '';
AD().events.find(e => e.title === title)?.id || '';
return { id, title };
},
async appJoinEvent(eventId: string, userId?: string) {
await appData.joinEvent(eventId, userId);
await AD().joinEvent(eventId, userId);
},
async appLeaveEvent(eventId: string, userId?: string) {
await appData.leaveEvent(eventId, userId);
await AD().leaveEvent(eventId, userId);
},
/** A LIVE current user id, resolved from the users set AT CALL TIME (not
* frozen at bridge-build). Prefers the app context's principal; falls
* back to the first user in the set. Guaranteed non-empty once users
* have hydrated — the real principal a Participation.user must carry. */
liveUserId() {
return appData.currentUserId || [...users][0]?.['@id'] || '';
return AD().currentUserId || userAdapter()[0]?.['@id'] || '';
},
/** Wait until the current user's principal is resolved (the profile read
* hydrated). A Participation needs a real `fp:user` IRI, and the profile
* read can lag behind the public events on a fresh session — join AFTER
* this resolves so the participation is never written with an empty user.
* Returns the resolved id (or '' on timeout). */
async ensureCurrentUser(timeoutMs = 60000) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
const id = AD().currentUserId || userAdapter()[0]?.['@id'] || '';
if (id) return id;
await new Promise(r => setTimeout(r, 500));
}
return AD().currentUserId || userAdapter()[0]?.['@id'] || '';
},
/** isParticipating for the LIVE current user id (call-time resolved). */
liveIsParticipating(eventId: string) {
const uid = appData.currentUserId || [...users][0]?.['@id'] || '';
return [...participations].some(p => p.event === eventId && p.user === uid);
const uid = AD().currentUserId || userAdapter()[0]?.['@id'] || '';
return AD().isParticipating(eventId, uid);
},
/** The host inbox NURI for an event (domain glue, T02.c). */
async eventInboxNuri(eventId: string) {
@@ -210,7 +286,7 @@ function ConnectedHarness() {
},
/** Host-facing notifications currently surfaced by the data context. */
appNotifications() {
return appData.notifications;
return AD().notifications;
},
/**
* AUTHORITATIVE participation count for (event, user), re-queried straight
@@ -224,33 +300,91 @@ function ConnectedHarness() {
const esc = (v: string) =>
v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
const query = `
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE {
GRAPH <${protectedNuri}> {
?s a <http://festipod.org/Participation> ;
<http://festipod.org/event> ?event ;
<http://festipod.org/user> ?user .
FILTER( STR(?event) = "${esc(eventId)}" && STR(?user) = "${esc(userId)}" )
}
}`;
const result: any = await docs.sparqlQuery(session.session_id, query, undefined, protectedNuri);
const rows = Array.isArray(result) ? result : result?.results?.bindings ?? [];
const n = parseInt(rows[0]?.n?.value ?? '0', 10);
return Number.isFinite(n) ? n : 0;
},
updateEvent(eventId: string, updates: Record<string, any>) {
const ev = [...events].find(e => e['@id'] === eventId);
if (!ev) return;
for (const [key, value] of Object.entries(updates)) {
if (key !== '@id' && key !== '@graph' && key !== '@type') {
(ev as any)[key] = value;
}
// Participations are ONE DOCUMENT PER ENTITY (protected scope), not the
// store root — so re-query the broker across every protected per-entity
// document (the union `listEntityDocs('protected')`) rather than the
// store-root graph. This stays authoritative (bypasses the reactive set):
// it counts the (event,user) triples actually persisted in the broker.
const reg = await import('../utils/storeRegistry');
const protectedDocs = await reg.listEntityDocs('protected');
let total = 0;
for (const g of protectedDocs) {
const query = `
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE {
GRAPH <${g}> {
?s a <http://festipod.org/Participation> ;
<http://festipod.org/event> ?event ;
<http://festipod.org/user> ?user .
FILTER( STR(?event) = "${esc(eventId)}" && STR(?user) = "${esc(userId)}" )
}
}`;
const result: any = await docs.sparqlQuery(session.session_id, query, undefined, g);
const rows = Array.isArray(result) ? result : result?.results?.bindings ?? [];
const n = parseInt(rows[0]?.n?.value ?? '0', 10);
if (Number.isFinite(n)) total += n;
}
return total;
},
async updateEvent(eventId: string, updates: Record<string, any>) {
// Set the event's "au départ" fields through the app path (per-entity doc).
// Awaited: participantCount is persisted via SPARQL, so callers that read
// it right after must wait for the write to land.
await AD().updateEvent(eventId, updates as any);
},
/** Load the app's default seed data into the wallet */
loadTestData() {
return bootstrapWallet(events as any, users as any, participations as any);
/** Load the app's default seed data into the wallet (per-entity path). */
async loadTestData() {
return AD().loadTestData();
},
/** Empty the CONNECTED wallet: delete every domain entity triple from the
* per-entity documents (public + protected), so the reactive read returns
* 0. Used by "le portefeuille est vide" — with one-document-per-entity and
* a persistent broker, a real empty state needs the docs' CONTENT cleared
* (the store-root delete of the old model no longer applies). Bounded: on a
* freshly-provisioned wallet there are only a handful of entity docs. */
async clearWallet() {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
const [pub, prot] = await Promise.all([
reg.listEntityDocs('public'),
reg.listEntityDocs('protected'),
]);
const all = [...new Set([...pub, ...prot])];
await Promise.all(all.map(g =>
docs.sparqlUpdate(
session.session_id,
`DELETE { GRAPH <${g}> { ?s ?p ?o } } WHERE { GRAPH <${g}> {
?s a ?t . FILTER(?t IN (
<http://festipod.org/Event>,
<http://festipod.org/UserProfile>,
<http://festipod.org/Participation>) )
?s ?p ?o } }`,
g,
).catch(() => { /* best-effort per doc */ }),
));
return { cleared: all.length };
},
/** ONE-TIME CLEANUP (T03.i): the private store accumulated thousands of
* historical inbox-deposit triples across test runs (the old inbox anchor
* = private store), making `loadShim` a 60s+ full-graph scan. Delete every
* inbox Deposit triple from the private store so the shim query is fast
* again. Idempotent; safe (deposits are transient test cruft). New deposits
* now land in a dedicated inbox document (lib fix), so this won't re-grow. */
async cleanPrivateInbox() {
const priv = `did:ng:${session.private_store_id}`;
const t0 = Date.now();
const del = `
DELETE { GRAPH <${priv}> { ?s ?p ?o } }
WHERE {
GRAPH <${priv}> {
?s a <urn:ng-eventually:inbox:Deposit> ;
?p ?o .
}
}`;
await docs.sparqlUpdate(session.session_id, del, priv);
return { deleteMs: Date.now() - t0 };
},
// --- ReadCap read-filter validation (see decision_2026-06-17_eventually-library) ---
@@ -356,8 +490,16 @@ function ConnectedHarness() {
await reg.ensureAccount('@fan-b');
const docA = await reg.createEntityDoc('@fan-a', 'public');
const docB = await reg.createEntityDoc('@fan-b', 'public');
reg.resetRegistryCache();
const listed = await reg.listEntityDocs('public');
// The index-append (which makes docA/docB show up in listEntityDocs) can
// lag behind createEntityDoc on the broker — poll until BOTH are listed
// (bounded) so the "index lists both docs" assertion isn't flaky.
let listed: string[] = [];
for (let i = 0; i < 12; i++) {
reg.resetRegistryCache();
listed = await reg.listEntityDocs('public');
if (listed.includes(docA) && listed.includes(docB)) break;
await new Promise(r => setTimeout(r, 1500));
}
setFanoutGraphs([docA, docB]);
return { docA, docB, listed };
},
@@ -379,15 +521,22 @@ function ConnectedHarness() {
reg.resetRegistryCache();
await reg.ensureAccount(publisher);
const doc = await reg.createEntityDoc(publisher, 'public');
// Make it discoverable: submit the event reference to the global index.
await disc.submitEventToIndex({ doc, id: doc, title }, publisher);
// Deposit AS the current identity: the inbox guard binds `from` to the
// CURRENT user and rejects a spoofed `from`. So make the publisher the
// current identity (its normalized-username key = the cap-owner key),
// then submit WITHOUT a spoofed explicit `from` — the SDK stamps the
// current identity itself (anonymous submission also allowed).
setCurrentUser(normalizeUsername(publisher));
await disc.submitEventToIndex({ doc, id: doc, title }, getCurrentUser());
return { doc };
},
async discoverPublicEventsAs(discoverer: string) {
const reg = await import('../utils/storeRegistry');
const disc = await import('../data/discovery');
// The discoverer account exists but is NOT connected to the publisher.
// Become the discoverer identity (reads the world-readable public index).
await reg.ensureAccount(discoverer);
setCurrentUser(normalizeUsername(discoverer));
reg.resetRegistryCache();
// Read the GLOBAL INDEX (not a cross-account fan-out) to discover. The
// submit deposit needs a moment to land in the broker's queryable graph