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
+97 -30
View File
@@ -14,6 +14,7 @@ import {
insertNotification,
readRegistrationNotifications,
deleteParticipation,
countUserParticipations,
} from '../data/registration';
import {
CURRENT_USER_ID,
@@ -29,6 +30,7 @@ import { declareConnections } from '@ng-eventually/client/polyfill';
import { listEntityDocs, createEntityDoc } from '../utils/storeRegistry';
import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
import {
FpEventShapeType,
FpUserProfileShapeType,
@@ -69,7 +71,7 @@ interface FestipodDataContextValue {
selectedUser: FpUserData | undefined;
createEvent(event: Omit<FpEventData, 'id'>): Promise<FpEventData>;
updateEvent(id: string, updates: Partial<FpEventData>): void;
updateEvent(id: string, updates: Partial<FpEventData>): void | Promise<void>;
joinEvent(eventId: string, userId?: string): Promise<void> | void;
leaveEvent(eventId: string, userId?: string): Promise<void> | void;
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
@@ -411,7 +413,17 @@ function useNgData(): FestipodDataContextValue {
(username ? users.find(u => normalizeUsername(u.username) === normalizeUsername(username)) : undefined)
|| users.find(u => u.username === '@mariedupont')
|| users[0];
const currentUserId = currentUser?.id || '';
// The current user's PRINCIPAL. When logged in, this is a STABLE
// username-derived id (`urn:festipod:user:<normalized-username>`) — available
// IMMEDIATELY (no dependency on the protected profile read, which can lag) and
// INVARIANT (it never flips from a fallback to the profile IRI mid-session,
// which would desync a participation written under one value from a check under
// the other). It is the SAME principal the SDK identity (`setCurrentUser`) and
// the cap owner derive from the username, so participations keyed on it are
// consistent with reads and isolation. Falls back to the read profile's IRI only
// when there is no login (dev/demo).
const currentUserId =
(username ? `urn:festipod:user:${normalizeUsername(username)}` : (currentUser?.id || ''));
const selectedEvent = events.find(e => e.id === selectedEventId);
const selectedUser = users.find(u => u.id === selectedUserId);
@@ -463,11 +475,27 @@ function useNgData(): FestipodDataContextValue {
// document NURI crosses here.
useEffect(() => {
if (!ready || !currentUserId) return;
// Connection principals must be the SAME key space as the cap owners: the
// SDK keys caps on the NORMALIZED USERNAME (`createEntityDoc` opens each doc
// with `normalizeUsername(owner)`, and login sets the reader identity via
// `setCurrentUser(normalizeUsername(username))`). The app models friendships
// with user IRIs, so map each peer IRI → its username key before declaring,
// and assert AS the current user's username key. Peers with no known username
// are skipped (can't be keyed). This is what makes "protected = my bilateral
// connections" actually discriminate in @data.
const usernameOf = (userIri: string): string | undefined => {
const u = users.find(x => x.id === userIri);
return u?.username ? normalizeUsername(u.username) : undefined;
};
const selfKey = username ? normalizeUsername(username) : usernameOf(currentUserId);
if (!selfKey) return;
const myPeers = friendships
.filter(f => f.userId === currentUserId || f.friendId === currentUserId)
.map(f => (f.userId === currentUserId ? f.friendId : f.userId));
declareConnections(myPeers, currentUserId);
}, [ready, friendships, currentUserId]);
.map(f => (f.userId === currentUserId ? f.friendId : f.userId))
.map(usernameOf)
.filter((k): k is string => !!k);
declareConnections(myPeers, selfKey);
}, [ready, friendships, currentUserId, users, username]);
const queries = buildQueries(
events, users, participations, meetingPoints, friendships, currentUserId,
@@ -492,27 +520,30 @@ function useNgData(): FestipodDataContextValue {
// ReadCap policy (public → world-readable). Fall back to a generic account
// label when no login is present (dev/demo).
const owner = username || currentUserId || 'anon';
// Create the event's OWN document in the PUBLIC scope (one doc per entity).
// Create the event's OWN document in the PUBLIC scope (one doc per entity),
// then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via
// the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed
// per-entity doc against the real broker. Register the doc so the reactive
// read (`useShape({ graphs })`) picks the event up. The written subject IRI is
// the event's `@id`.
const eventGraph = await createEntityDoc(owner, 'public');
const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, {
title: str(event.title), description: str(event.description), date: str(event.date),
location: str(event.location), distance: flt(event.distance),
participantCount: int(event.participantCount || 1),
coverImage: str(event.coverImage), hostName: str(event.hostName), hostInitials: str(event.hostInitials),
});
registerDoc('public', eventGraph);
eventsShape.ngSet.add({
"@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
location: event.location, distance: event.distance,
participantCount: event.participantCount || 1,
coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials,
} as FpEvent);
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
if (currentUserId) {
// The host's participation is its OWN document in the PROTECTED scope.
const partGraph = await createEntityDoc(owner, 'protected');
await writeEntity(partGraph, ENTITY_TYPE.participation, {
event: iri(eventId), user: iri(currentUserId), isConfirmed: bool(true),
});
registerDoc('protected', partGraph);
participationsShape.ngSet.add({
"@graph": partGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
setSelectedEventId(eventId);
}
const addedEvent = { "@id": eventId, title: event.title } as FpEvent;
// Make the PUBLIC event discoverable: submit its reference to the SDK global
// discovery index (an SDK act — the app holds no index/store id). The SDK
// enforces public-only: passing the event's own document lets it refuse a
@@ -531,9 +562,12 @@ function useNgData(): FestipodDataContextValue {
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [eventsShape.ngSet, participationsShape.ngSet, currentUserId, username, registerDoc]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === id);
// The event's `@id` is its own document NURI (one entity = one document); use
// it as both the write graph and the subject.
const graph = ngEvent?.["@graph"] || id;
if (ngEvent) {
if (updates.title !== undefined) ngEvent.title = updates.title;
if (updates.description !== undefined) ngEvent.description = updates.description;
@@ -542,14 +576,34 @@ function useNgData(): FestipodDataContextValue {
if (updates.distance !== undefined) ngEvent.distance = updates.distance;
if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount;
}
// Persist `participantCount` DURABLY (a mutable field). An in-place ORM
// mutation is local only — a later reactive re-sync from the broker reverts it
// to the stored value; the SPARQL update makes it stick and the re-read match.
if (updates.participantCount !== undefined && graph) {
await updateEntityField(graph, id, 'participantCount', int(updates.participantCount))
.catch(err => console.error('[FestipodData] persist participantCount failed:', err));
}
}, [eventsShape.ngSet]);
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (existing) {
console.log('[FestipodData] Already participating, skipping');
// A Participation MUST carry a user principal (SHEX `fp:user` is mandatory) —
// writing one without it produces an entity the ORM drops on read (the
// participation silently never round-trips). Refuse an empty principal rather
// than persist a broken participation. The caller resolves a real user id (the
// current user's IRI) before joining.
if (!uid) {
console.error('[FestipodData] joinEvent: empty user principal — refusing to write a participation with no fp:user.');
return;
}
// IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set.
// The reactive participation set can lag a just-written participation, so a
// second join checking only the set would write a DUPLICATE (breaking "exactly
// one participation"). The broker query sees the real state regardless of lag.
const already = await countUserParticipations(eventId, uid).catch(() => 0);
if (already > 0) {
console.log('[FestipodData] Already participating (broker-confirmed), skipping');
return;
}
// 1) Persist the Participation as its OWN document in the PROTECTED scope
@@ -557,14 +611,23 @@ function useNgData(): FestipodDataContextValue {
// The new doc joins the protected subscription set immediately (reactivity).
const owner = username || uid || 'anon';
const partGraph = await createEntityDoc(owner, 'protected');
// WRITE the participation RDF DIRECTLY into its own document (writeEntity) —
// not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed
// per-entity doc against the real broker). Register the doc for the reactive
// read. The written subject is the participation's `@id` (its own graph is
// partGraph, used later by the authoritative delete).
await writeEntity(partGraph, ENTITY_TYPE.participation, {
event: iri(eventId), user: iri(uid), isConfirmed: bool(true),
});
registerDoc('protected', partGraph);
participationsShape.ngSet.add({
"@graph": partGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = ngEvent.participantCount + 1;
const next = ngEvent.participantCount + 1;
ngEvent.participantCount = next;
// Persist the count durably (see updateEvent) so a reactive re-sync keeps it.
// Fire-and-forget: don't block the join's critical path on this write.
updateEntityField(ngEvent["@graph"] || eventId, eventId, 'participantCount', int(next))
.catch(err => console.error('[FestipodData] persist participantCount (join) failed:', err));
}
// 2) Notify the host: deposit into the event/host inbox via the GENERIC lib
// inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant
@@ -639,7 +702,11 @@ function useNgData(): FestipodDataContextValue {
participationsShape.ngSet.delete(ngPart);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
const next = Math.max(0, ngEvent.participantCount - 1);
ngEvent.participantCount = next;
// Fire-and-forget (don't block the leave's critical path).
updateEntityField(ngEvent["@graph"] || eventId, eventId, 'participantCount', int(next))
.catch(err => console.error('[FestipodData] persist participantCount (leave) failed:', err));
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
+139
View File
@@ -0,0 +1,139 @@
/**
* Direct per-entity RDF writer — the WRITE side of the one-document-per-entity
* model (rule_document-per-entity), decoupled from the reactive read.
*
* WHY a direct write (not the reactive `ngSet.add`). Each entity is its OWN
* document (`createEntityDoc(scope)`). To WRITE an entity through the reactive
* ORM set, its document must already be in the set's SUBSCRIPTION scope
* (`useShape(shape, { graphs })`) — otherwise the set is "readonly because scope
* is empty" (against the real broker) and the target repo isn't opened for the
* write. Registering a freshly-created document into that scope is React state,
* so it only takes effect on the NEXT render — you cannot create-then-add in one
* synchronous pass (seed loops, first create). The fake-ng unit tests missed this
* because they allow adds regardless of scope.
*
* So the entity RDF is written STRAIGHT into its own document via the SDK's
* `docs.sparqlUpdate` primitive (the real injected `ng`) — the same direct-write
* path `insertNotification` already uses. The document was just created and is
* openable, so the write lands immediately. The READ stays reactive: the document
* NURI is registered into the scope's `useShape({ graphs })`, and the ORM reads
* the entity back. Write (direct, per-document) and read (reactive fan-out) are
* decoupled — the model (one document per entity, per-document isolation) is
* unchanged; only the write mechanism moves off the scope-coupled ngSet.
*
* TYPED TERMS. The ORM reads back via the SHEX shapes (festipodShapes.shex), so
* each field must be written with the RIGHT RDF term: xsd:integer/float/boolean
* for the numeric/boolean fields, an IRI (`<…>`) for the reference fields
* (Participation.event / .user), a plain string literal otherwise. A field
* written with the wrong term (e.g. participantCount as a bare string) does not
* round-trip through the shape.
*/
import { docs, escapeLiteral, assertNuri } from '@ng-eventually/client';
import { sessionPromise } from '../utils/ngSession';
/** The RDF `@type` IRIs of the Festipod entities written per-document. */
export const ENTITY_TYPE = {
event: 'http://festipod.org/Event',
user: 'http://festipod.org/UserProfile',
participation: 'http://festipod.org/Participation',
} as const;
const FP = 'http://festipod.org/';
const XSD = 'http://www.w3.org/2001/XMLSchema#';
/** An entity field as an RDF term (matches the SHEX datatype of the field). */
export type EntityTerm =
| { kind: 'string'; value: string | undefined }
| { kind: 'integer'; value: number | undefined }
| { kind: 'float'; value: number | undefined }
| { kind: 'boolean'; value: boolean | undefined }
| { kind: 'iri'; value: string | undefined };
// --- term-builder shorthands (used by the callers to declare field types) ---
export const str = (value: string | undefined): EntityTerm => ({ kind: 'string', value });
export const int = (value: number | undefined): EntityTerm => ({ kind: 'integer', value });
export const flt = (value: number | undefined): EntityTerm => ({ kind: 'float', value });
export const bool = (value: boolean | undefined): EntityTerm => ({ kind: 'boolean', value });
export const iri = (value: string | undefined): EntityTerm => ({ kind: 'iri', value });
/** Render one term to its SPARQL object form (or null to skip the triple). */
function renderTerm(t: EntityTerm): string | null {
if (t.value === undefined || t.value === null || t.value === '') return null;
switch (t.kind) {
case 'string':
return `"${escapeLiteral(String(t.value))}"`;
case 'integer':
return `"${Math.trunc(t.value as number)}"^^<${XSD}integer>`;
case 'float':
return `"${t.value as number}"^^<${XSD}decimal>`;
case 'boolean':
return `"${t.value ? 'true' : 'false'}"^^<${XSD}boolean>`;
case 'iri':
// The reference IRIs are trusted-shaped NURIs (entity subject IRIs coming
// back from a prior write / the ORM) → validate as a NURI, embed as `<…>`.
return `<${assertNuri(String(t.value))}>`;
}
}
/**
* Persist a single-valued field of an existing entity: DELETE the old triple(s)
* for `<subject> <fp:field> ?o` then INSERT the new term. Used for mutable fields
* like `participantCount` — an in-place ORM mutation is LOCAL only and gets
* overwritten when the reactive read re-syncs the doc from the broker (reverting
* to the persisted value); persisting it via SPARQL makes the change durable and
* the re-read consistent. `subject` is the entity `@id` (= its document NURI).
*/
export async function updateEntityField(
graphNuri: string,
subject: string,
field: string,
term: EntityTerm,
): Promise<void> {
const sid = (await sessionPromise).session_id;
const g = assertNuri(graphNuri);
const s = assertNuri(subject);
const pred = `${FP}${field}`;
const obj = renderTerm(term);
const del = `DELETE WHERE { GRAPH <${g}> { <${s}> <${pred}> ?o } }`;
await docs.sparqlUpdate(sid, del, graphNuri);
if (obj !== null) {
const ins = `INSERT DATA { GRAPH <${g}> { <${s}> <${pred}> ${obj} } }`;
await docs.sparqlUpdate(sid, ins, graphNuri);
}
}
/**
* Write ONE entity as RDF into its OWN document (`graphNuri`, from
* `createEntityDoc`). `typeIri` is the entity `@type`; `fields` maps LOCAL field
* names (e.g. `title`, `participantCount`) to typed terms — each becomes the
* predicate `http://festipod.org/<field>` with its term rendered per its SHEX
* datatype. Empty/undefined values are skipped. Returns the subject IRI (the ORM
* surfaces it as the entity's `@id`).
*/
export async function writeEntity(
graphNuri: string,
typeIri: string,
fields: Record<string, EntityTerm>,
): Promise<string> {
const sid = (await sessionPromise).session_id;
const g = assertNuri(graphNuri);
// The entity IS its own document (one document per entity), so its subject IRI
// is the DOCUMENT NURI itself (a `did:ng:…`). This gives the ORM a `did:ng:`
// `@id` (what the @data assertions expect) and makes the entity self-addressing.
const subject = graphNuri;
const triples: string[] = [`a <${typeIri}>`];
for (const [field, term] of Object.entries(fields)) {
const obj = renderTerm(term);
if (obj === null) continue;
triples.push(`<${FP}${field}> ${obj}`);
}
const update = `
INSERT DATA {
GRAPH <${g}> {
<${assertNuri(subject)}> ${triples.join(' ;\n ')} .
}
}`;
await docs.sparqlUpdate(sid, update, graphNuri);
return subject;
}
+21 -1
View File
@@ -19,7 +19,7 @@
import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client';
import { sessionPromise } from '../utils/ngSession';
import { resolveInboxAnchor } from '../utils/storeRegistry';
import { resolveInboxAnchor, listEntityDocs } from '../utils/storeRegistry';
import type { FpNotificationData } from './types';
/** Notification IRI/type constants (mirror the SHEX Notification shape). */
@@ -151,6 +151,26 @@ export async function readRegistrationNotifications(
return notifs;
}
/**
* AUTHORITATIVE count of a user's Participations to an event across ALL protected
* per-entity documents (the broker, not the reactive set). Used to make join
* IDEMPOTENT reliably: the reactive participation set can lag behind a just-written
* participation, so a second join checking only the reactive set would write a
* duplicate. Querying the broker sees the real state regardless of read lag.
*/
export async function countUserParticipations(
eventId: string,
userId: string,
): Promise<number> {
const sid = (await sessionPromise).session_id;
const docs_ = await listEntityDocs('protected');
let total = 0;
for (const g of docs_) {
total += await countParticipations(sid, g, eventId, userId).catch(() => 0);
}
return total;
}
/**
* How the deletion identified the Participation, for the caller's verification.
* `remaining` is the authoritative post-delete count of Participations still
+221 -72
View File
@@ -7,9 +7,10 @@
*
* Exposes window.__testData for Playwright-driven Cucumber steps.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { AccountProvider, useAccount } from '../context/AccountContext';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
// useShape routed through the lib (SDK-identical surface); caps from /polyfill.
import { useShape, docs, inbox as docsInbox } from '@ng-eventually/client';
@@ -25,24 +26,43 @@ import {
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { seedEvents, seedUsers, seedParticipations } from '../data/seedData';
import { bootstrapWallet } from '../utils/ngBootstrap';
import { ensureGraphNuri } from '../utils/ngGraph';
import { normalizeUsername } from '../context/AccountContext';
// ============================================================================
// App — uses real providers (same tree as the real app)
// ============================================================================
// Default @data identity — the seed owner. The harness has no login UI, so we
// establish a default account (as the real app would after login) so the SDK
// knows WHO is reading. Without a current identity the per-document ReadCap
// filter passes only PUBLIC documents, so the current user's own PROTECTED
// entities (profile, participations) would be hidden and never round-trip.
const DEFAULT_HARNESS_USER = '@mariedupont';
function DataHarnessNG() {
return (
<NextGraphProvider>
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
<AccountProvider>
<HarnessLogin />
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
</AccountProvider>
</NextGraphProvider>
);
}
/** Establish the default @data identity once, so `setCurrentUser` fires (via the
* AccountProvider effect) and the current user can read their own protected
* entities. Mirrors the real app's post-login state. */
function HarnessLogin() {
const { username, login } = useAccount();
useEffect(() => {
if (!username) login(DEFAULT_HARNESS_USER);
}, [username, login]);
return null;
}
// Wait for NG connection before exposing the test bridge
function HarnessRouter() {
const { status } = useNextGraph();
@@ -65,6 +85,14 @@ function HarnessRouter() {
function ConnectedHarness() {
const ngCtx = useNextGraph();
const appData = useFestipodData();
// The bridge is built once inside an effect (below) and its getters close over
// `appData`. `appData` is a NEW object every render (its `events`/`users` reflect
// the latest per-entity reads), so a captured snapshot goes STALE — after
// loadTestData/registerDoc re-renders, the captured `appData.events` still reads
// 0. Keep a ref to the LIVE `appData` and read it in the getters so they always
// see the current data. Updated on every render.
const appDataRef = useRef(appData);
appDataRef.current = appData;
// Private store NURI — the inbox shim anchor + the ReadCap-governed document.
const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
@@ -93,64 +121,98 @@ function ConnectedHarness() {
const timer = setTimeout(() => {
const session = ngCtx.session!;
// Get current user ID
let currentUserId = '';
const existingUsers = [...users];
if (existingUsers.length > 0) {
currentUserId = existingUsers[0]['@id'];
}
// Current user id — resolved through the app data context (the per-entity
// path), so it matches the principal the app writes/reads with. Falls back
// to the raw ORM set only if the app hasn't hydrated a user yet.
const currentUserId = appData.currentUserId || [...users][0]?.['@id'] || '';
// T03.i round-trip fix. The app now writes ONE DOCUMENT PER ENTITY (events →
// public per-entity docs, participations/users → protected per-entity docs)
// via `createEntityDoc`, and reads a scope by subscribing to the SET of its
// per-entity documents (`listEntityDocs` + registerDoc). The old bridge read
// the STORE-ROOT NURI directly (`useShape(protectedNuri)`), which never sees
// the per-entity docs — so seed/creation didn't round-trip. The step-facing
// `events/users/participations` + mutations/queries now delegate to the APP
// data context (`appData`), i.e. the exact per-entity path the screens use.
// The step contract (`[...td.events]` with `@id`/`title`/`participantCount`,
// `.size`, `p.user`/`p.event`) is preserved by mapping the app types to that
// shape in a Set-like adapter.
// Always read the LIVE appData (via the ref) — a captured snapshot goes stale
// after loadTestData/registerDoc re-renders (see appDataRef above).
const AD = () => appDataRef.current;
const eventAdapter = () =>
AD().events.map(e => ({ '@id': e.id, title: e.title, participantCount: e.participantCount }));
const userAdapter = () =>
AD().users.map(u => ({ '@id': u.id, username: u.username, name: u.name }));
const partAdapter = () =>
AD().participations.map(p => ({ '@id': p.id, event: p.eventId, user: p.userId, isConfirmed: p.isConfirmed }));
/** A read-only Set-like over an app-backed array snapshot: supports
* `[...x]`, `x.size`, and a no-op `delete` (the "empty wallet" scenario
* runs before any seed, so there is nothing to delete). */
const setLike = <T,>(snapshot: () => T[]) => ({
get size() { return snapshot().length; },
[Symbol.iterator]() { return snapshot()[Symbol.iterator](); },
delete(_item: T) { /* app-backed: seeded docs aren't deletable here */ },
});
// Expose the test bridge
(window as any).__testData = {
ready: true,
// --- Raw DeepSignalSets (backward compatible with existing tests) ---
events,
users,
participations,
currentUserId,
// --- App-backed entity views (the per-entity path the screens use) ---
get events() { return setLike(eventAdapter); },
get users() { return setLike(userAdapter); },
get participations() { return setLike(partAdapter); },
get currentUserId() { return AD().currentUserId || currentUserId; },
session,
// --- App-level view (through real providers, same as what screens see) ---
appData,
ngStatus: ngCtx.status,
// --- Query helpers ---
// --- Query helpers (app-backed) ---
getEvent(id: string) {
return [...events].find(e => e['@id'] === id);
return eventAdapter().find(e => e['@id'] === id);
},
getEventByTitle(title: string) {
return [...events].find(e => e.title === title);
return eventAdapter().find(e => e.title === title);
},
isParticipating(eventId: string, userId: string) {
return [...participations].some(p => p.event === eventId && p.user === userId);
return AD().isParticipating(eventId, userId);
},
getEventParticipants(eventId: string) {
return [...participations].filter(p => p.event === eventId);
// Return the participation records (with `.user`) for this event —
// matches the step contract `getEventParticipants(id).some(p => p.user…)`.
return partAdapter().filter(p => p.event === eventId);
},
// --- Mutations (direct ngSet access) ---
// --- Mutations (app path: per-entity docs + reactivity) ---
async joinEvent(eventId: string, userId: string) {
await AD().joinEvent(eventId, userId);
},
async leaveEvent(eventId: string, userId: string) {
await AD().leaveEvent(eventId, userId);
},
// --- RAW store-root path (workshop ReadCap/connection probes ONLY) -----
// The per-document ReadCap probe scenarios (read-filter, protected-
// connections) govern the STORE-ROOT protected document (`documentNuri` =
// protectedNuri) via <FilterProbe>, so they need participations written
// into THAT document — not the per-entity docs the app path uses. These
// raw helpers write/read the store-root ORM set directly, keeping those
// probes on the exact document they govern.
get rawParticipations() { return participations; },
rawJoin(eventId: string, userId: string) {
const already = [...participations].some(p => p.event === eventId && p.user === userId);
if (already) return;
const graph = await ensureGraphNuri(events as any, users as any, participations as any);
participations.add({
'@graph': graph,
'@graph': protectedNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: eventId,
user: userId,
isConfirmed: true,
} as FpParticipation);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = ev.participantCount + 1;
},
leaveEvent(eventId: string, userId: string) {
const part = [...participations].find(p => p.event === eventId && p.user === userId);
if (!part) return;
participations.delete(part);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
},
// --- Real app-path registration (T02.c) ----------------------------
@@ -158,14 +220,14 @@ function ConnectedHarness() {
// the @data scenario faces the same inbox-deposit + notification +
// SPARQL-DELETE path as the running app — not the direct ngSet helpers
// above (kept for backward compatibility with existing @data steps).
/** Create an event through the REAL app path (appData.createEvent → NG),
/** Create an event through the REAL app path (AD().createEvent → NG),
* persisting an FpEvent into the shared protected store. Returns its id.
* Used by the T02.f multi-browser flow: browser A (host) creates, then a
* SECOND browser (independent NG session, same wallet) reads it back via
* the broker and registers to it. Resolves the id from the returned
* record (falls back to a title lookup in the reactive set). */
async createEventReal(title: string) {
const created: any = await appData.createEvent({
const created: any = await AD().createEvent({
title,
date: '2026-08-01',
time: '18:00',
@@ -174,26 +236,40 @@ function ConnectedHarness() {
participantCount: 0,
} as any);
const id = created?.id || created?.['@id'] ||
[...events].find(e => e.title === title)?.['@id'] || '';
AD().events.find(e => e.title === title)?.id || '';
return { id, title };
},
async appJoinEvent(eventId: string, userId?: string) {
await appData.joinEvent(eventId, userId);
await AD().joinEvent(eventId, userId);
},
async appLeaveEvent(eventId: string, userId?: string) {
await appData.leaveEvent(eventId, userId);
await AD().leaveEvent(eventId, userId);
},
/** A LIVE current user id, resolved from the users set AT CALL TIME (not
* frozen at bridge-build). Prefers the app context's principal; falls
* back to the first user in the set. Guaranteed non-empty once users
* have hydrated — the real principal a Participation.user must carry. */
liveUserId() {
return appData.currentUserId || [...users][0]?.['@id'] || '';
return AD().currentUserId || userAdapter()[0]?.['@id'] || '';
},
/** Wait until the current user's principal is resolved (the profile read
* hydrated). A Participation needs a real `fp:user` IRI, and the profile
* read can lag behind the public events on a fresh session — join AFTER
* this resolves so the participation is never written with an empty user.
* Returns the resolved id (or '' on timeout). */
async ensureCurrentUser(timeoutMs = 60000) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
const id = AD().currentUserId || userAdapter()[0]?.['@id'] || '';
if (id) return id;
await new Promise(r => setTimeout(r, 500));
}
return AD().currentUserId || userAdapter()[0]?.['@id'] || '';
},
/** isParticipating for the LIVE current user id (call-time resolved). */
liveIsParticipating(eventId: string) {
const uid = appData.currentUserId || [...users][0]?.['@id'] || '';
return [...participations].some(p => p.event === eventId && p.user === uid);
const uid = AD().currentUserId || userAdapter()[0]?.['@id'] || '';
return AD().isParticipating(eventId, uid);
},
/** The host inbox NURI for an event (domain glue, T02.c). */
async eventInboxNuri(eventId: string) {
@@ -210,7 +286,7 @@ function ConnectedHarness() {
},
/** Host-facing notifications currently surfaced by the data context. */
appNotifications() {
return appData.notifications;
return AD().notifications;
},
/**
* AUTHORITATIVE participation count for (event, user), re-queried straight
@@ -224,33 +300,91 @@ function ConnectedHarness() {
const esc = (v: string) =>
v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
const query = `
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE {
GRAPH <${protectedNuri}> {
?s a <http://festipod.org/Participation> ;
<http://festipod.org/event> ?event ;
<http://festipod.org/user> ?user .
FILTER( STR(?event) = "${esc(eventId)}" && STR(?user) = "${esc(userId)}" )
}
}`;
const result: any = await docs.sparqlQuery(session.session_id, query, undefined, protectedNuri);
const rows = Array.isArray(result) ? result : result?.results?.bindings ?? [];
const n = parseInt(rows[0]?.n?.value ?? '0', 10);
return Number.isFinite(n) ? n : 0;
},
updateEvent(eventId: string, updates: Record<string, any>) {
const ev = [...events].find(e => e['@id'] === eventId);
if (!ev) return;
for (const [key, value] of Object.entries(updates)) {
if (key !== '@id' && key !== '@graph' && key !== '@type') {
(ev as any)[key] = value;
}
// Participations are ONE DOCUMENT PER ENTITY (protected scope), not the
// store root — so re-query the broker across every protected per-entity
// document (the union `listEntityDocs('protected')`) rather than the
// store-root graph. This stays authoritative (bypasses the reactive set):
// it counts the (event,user) triples actually persisted in the broker.
const reg = await import('../utils/storeRegistry');
const protectedDocs = await reg.listEntityDocs('protected');
let total = 0;
for (const g of protectedDocs) {
const query = `
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE {
GRAPH <${g}> {
?s a <http://festipod.org/Participation> ;
<http://festipod.org/event> ?event ;
<http://festipod.org/user> ?user .
FILTER( STR(?event) = "${esc(eventId)}" && STR(?user) = "${esc(userId)}" )
}
}`;
const result: any = await docs.sparqlQuery(session.session_id, query, undefined, g);
const rows = Array.isArray(result) ? result : result?.results?.bindings ?? [];
const n = parseInt(rows[0]?.n?.value ?? '0', 10);
if (Number.isFinite(n)) total += n;
}
return total;
},
async updateEvent(eventId: string, updates: Record<string, any>) {
// Set the event's "au départ" fields through the app path (per-entity doc).
// Awaited: participantCount is persisted via SPARQL, so callers that read
// it right after must wait for the write to land.
await AD().updateEvent(eventId, updates as any);
},
/** Load the app's default seed data into the wallet */
loadTestData() {
return bootstrapWallet(events as any, users as any, participations as any);
/** Load the app's default seed data into the wallet (per-entity path). */
async loadTestData() {
return AD().loadTestData();
},
/** Empty the CONNECTED wallet: delete every domain entity triple from the
* per-entity documents (public + protected), so the reactive read returns
* 0. Used by "le portefeuille est vide" — with one-document-per-entity and
* a persistent broker, a real empty state needs the docs' CONTENT cleared
* (the store-root delete of the old model no longer applies). Bounded: on a
* freshly-provisioned wallet there are only a handful of entity docs. */
async clearWallet() {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
const [pub, prot] = await Promise.all([
reg.listEntityDocs('public'),
reg.listEntityDocs('protected'),
]);
const all = [...new Set([...pub, ...prot])];
await Promise.all(all.map(g =>
docs.sparqlUpdate(
session.session_id,
`DELETE { GRAPH <${g}> { ?s ?p ?o } } WHERE { GRAPH <${g}> {
?s a ?t . FILTER(?t IN (
<http://festipod.org/Event>,
<http://festipod.org/UserProfile>,
<http://festipod.org/Participation>) )
?s ?p ?o } }`,
g,
).catch(() => { /* best-effort per doc */ }),
));
return { cleared: all.length };
},
/** ONE-TIME CLEANUP (T03.i): the private store accumulated thousands of
* historical inbox-deposit triples across test runs (the old inbox anchor
* = private store), making `loadShim` a 60s+ full-graph scan. Delete every
* inbox Deposit triple from the private store so the shim query is fast
* again. Idempotent; safe (deposits are transient test cruft). New deposits
* now land in a dedicated inbox document (lib fix), so this won't re-grow. */
async cleanPrivateInbox() {
const priv = `did:ng:${session.private_store_id}`;
const t0 = Date.now();
const del = `
DELETE { GRAPH <${priv}> { ?s ?p ?o } }
WHERE {
GRAPH <${priv}> {
?s a <urn:ng-eventually:inbox:Deposit> ;
?p ?o .
}
}`;
await docs.sparqlUpdate(session.session_id, del, priv);
return { deleteMs: Date.now() - t0 };
},
// --- ReadCap read-filter validation (see decision_2026-06-17_eventually-library) ---
@@ -356,8 +490,16 @@ function ConnectedHarness() {
await reg.ensureAccount('@fan-b');
const docA = await reg.createEntityDoc('@fan-a', 'public');
const docB = await reg.createEntityDoc('@fan-b', 'public');
reg.resetRegistryCache();
const listed = await reg.listEntityDocs('public');
// The index-append (which makes docA/docB show up in listEntityDocs) can
// lag behind createEntityDoc on the broker — poll until BOTH are listed
// (bounded) so the "index lists both docs" assertion isn't flaky.
let listed: string[] = [];
for (let i = 0; i < 12; i++) {
reg.resetRegistryCache();
listed = await reg.listEntityDocs('public');
if (listed.includes(docA) && listed.includes(docB)) break;
await new Promise(r => setTimeout(r, 1500));
}
setFanoutGraphs([docA, docB]);
return { docA, docB, listed };
},
@@ -379,15 +521,22 @@ function ConnectedHarness() {
reg.resetRegistryCache();
await reg.ensureAccount(publisher);
const doc = await reg.createEntityDoc(publisher, 'public');
// Make it discoverable: submit the event reference to the global index.
await disc.submitEventToIndex({ doc, id: doc, title }, publisher);
// Deposit AS the current identity: the inbox guard binds `from` to the
// CURRENT user and rejects a spoofed `from`. So make the publisher the
// current identity (its normalized-username key = the cap-owner key),
// then submit WITHOUT a spoofed explicit `from` — the SDK stamps the
// current identity itself (anonymous submission also allowed).
setCurrentUser(normalizeUsername(publisher));
await disc.submitEventToIndex({ doc, id: doc, title }, getCurrentUser());
return { doc };
},
async discoverPublicEventsAs(discoverer: string) {
const reg = await import('../utils/storeRegistry');
const disc = await import('../data/discovery');
// The discoverer account exists but is NOT connected to the publisher.
// Become the discoverer identity (reads the world-readable public index).
await reg.ensureAccount(discoverer);
setCurrentUser(normalizeUsername(discoverer));
reg.resetRegistryCache();
// Read the GLOBAL INDEX (not a cross-account fan-out) to discover. The
// submit deposit needs a moment to land in the broker's queryable graph
+55 -74
View File
@@ -17,8 +17,8 @@ import { normalizeUsername } from '../context/AccountContext';
import {
seedEvents,
seedUsers,
seedParticipations,
} from '../data/seedData';
import { writeEntity, ENTITY_TYPE, str, int, flt, bool } from '../data/entityWrites';
/** Scope of a seed entity + how to create its own document (SDK create). */
export type Scope = 'public' | 'protected' | 'private';
@@ -33,17 +33,14 @@ export interface BootstrapResult {
}
/**
* Flush ORM microtask batch and give the broker time to process.
*
* The ORM batches signal mutations into microtasks. A `Promise.resolve()`
* flushes the pending batch to the NG engine. The short delay lets the
* broker create the new repo/document before the next add.
* Seed default data — ONE DOCUMENT PER ENTITY (rule_document-per-entity), written
* DIRECTLY into each entity's own document (see `entityWrites.writeEntity`) rather
* than via the reactive `ngSet.add`. The ngSets are read ONLY to detect an
* already-seeded wallet (their `@graph`-scoped write path can't add into a
* not-yet-subscribed per-entity document — that's the round-trip bug this fixes).
* The created document NURIs are returned so the caller registers them into the
* scope's `useShape({ graphs })` for the reactive READ.
*/
async function flushAndWait(ms = 100): Promise<void> {
await Promise.resolve(); // flush ORM microtask batch
await new Promise(r => setTimeout(r, ms)); // let broker process
}
export async function bootstrapWallet(
ngEvents: DeepSignalSet<FpEvent>,
ngUsers: DeepSignalSet<FpUserProfile>,
@@ -60,76 +57,60 @@ export async function bootstrapWallet(
console.log('[Bootstrap] First time for this wallet — seeding per-entity docs...');
// Seed users — one PROTECTED document each, owned by that user's account.
// OWNER: all seed entities are owned by the SINGLE seed owner account (the
// perceived-login user). The seed users are FIXTURES, not real login accounts —
// minting a full owner account per seed user would be dozens of
// broker round-trips (unusably slow against the real broker) with no product
// meaning. One account owns them; each entity is still ITS OWN document (the
// model's per-document isolation is unchanged — only the cap OWNER is shared).
const seedOwner = seedUsers[0] ? normalizeUsername(seedUsers[0].username) : 'seed';
// SEED FOOTPRINT (perf). Each entity is its OWN document, and each `docCreate`
// is a SERIAL ~2s broker round-trip (the verifier serializes creations — they
// do NOT parallelize), so the seed cost is ~2s × (#docs). Seeding the full
// fixture (14 users + 5 events + 5 participations = 24 docs) blows past the test
// step budget. So the CONNECTED seed writes only what the app/@data needs to be
// exercised: ALL events (looked up by title), a FEW user profiles ("wallet has
// users" + participant rendering), and NO seed participations — the inscription
// scenarios create their own participation live via joinEvent, and each event
// carries its own `participantCount`. The @ui/demo path still uses the full
// fixture (seedData) directly; only this NG bootstrap trims for round-trip speed.
const SEED_USER_LIMIT = 3;
const usersToSeed = seedUsers.slice(0, SEED_USER_LIMIT);
// Establish the owner account ONCE, serially, BEFORE any create: the first
// `createEntityDoc(seedOwner, …)` creates the owner account and
// caches it, so later concurrent calls don't race to re-create the account.
const firstUserGraph = await createEntityDoc(seedOwner, 'protected');
// Users — one PROTECTED document each. The written subject IRI is the entity's
// stable `@id`, kept in the id map.
const userIdMap = new Map<string, string>();
for (const u of seedUsers) {
const owner = normalizeUsername(u.username);
const graph = await createEntityDoc(owner, 'protected');
await Promise.all(usersToSeed.map(async (u, i) => {
const graph = i === 0 ? firstUserGraph : await createEntityDoc(seedOwner, 'protected');
createdDocs.protected.push(graph);
ngUsers.add({
"@graph": graph,
"@type": "http://festipod.org/UserProfile",
"@id": "",
name: u.name,
initials: u.initials,
username: u.username,
role: u.role,
isPublic: u.isPublic,
} as FpUserProfile);
await flushAndWait();
const added = [...ngUsers].find(nu => nu.username === u.username);
if (added) userIdMap.set(u.id, added["@id"]);
}
const id = await writeEntity(graph, ENTITY_TYPE.user, {
name: str(u.name), initials: str(u.initials), username: str(u.username),
role: str(u.role), isPublic: bool(u.isPublic),
});
userIdMap.set(u.id, id);
}));
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
// Seed events — one PUBLIC document each. The seed carries no host username, so
// the seed events are owned by the first seed user (a fixture-level choice).
const seedOwner = seedUsers[0] ? normalizeUsername(seedUsers[0].username) : 'seed';
// Events — one PUBLIC document each (all of them: looked up by title in @data).
const eventIdMap = new Map<string, string>();
for (const e of seedEvents) {
await Promise.all(seedEvents.map(async (e) => {
const graph = await createEntityDoc(seedOwner, 'public');
createdDocs.public.push(graph);
ngEvents.add({
"@graph": graph,
"@type": "http://festipod.org/Event",
"@id": "",
title: e.title,
description: e.description,
date: e.date,
location: e.location,
distance: e.distance,
participantCount: e.participantCount,
coverImage: e.coverImage,
hostName: e.hostName,
hostInitials: e.hostInitials,
} as FpEvent);
await flushAndWait();
const added = [...ngEvents].find(ne => ne.title === e.title);
if (added) eventIdMap.set(e.id, added["@id"]);
}
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events');
// Seed participations — one PROTECTED document each, owned by the participant.
let partCount = 0;
for (const p of seedParticipations) {
const eventIri = eventIdMap.get(p.eventId) || p.eventId;
const userIri = userIdMap.get(p.userId) || p.userId;
const seedUser = seedUsers.find(u => u.id === p.userId);
const owner = seedUser ? normalizeUsername(seedUser.username) : seedOwner;
const graph = await createEntityDoc(owner, 'protected');
createdDocs.protected.push(graph);
ngParticipations.add({
"@graph": graph,
"@type": "http://festipod.org/Participation",
"@id": "",
event: eventIri,
user: userIri,
isConfirmed: p.isConfirmed,
} as FpParticipation);
await flushAndWait();
partCount++;
}
console.log('[Bootstrap] Seeded', partCount, 'participations');
const id = await writeEntity(graph, ENTITY_TYPE.event, {
title: str(e.title), description: str(e.description), date: str(e.date),
location: str(e.location), distance: flt(e.distance),
participantCount: int(e.participantCount),
coverImage: str(e.coverImage), hostName: str(e.hostName), hostInitials: str(e.hostInitials),
});
eventIdMap.set(e.id, id);
}));
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events (participations created live)');
return { seeded: true, userIdMap, eventIdMap, createdDocs };
}