feat(data): union read model — list via anchorless sparql_query, hang eliminated

Replace the reactive-ORM per-entity fan-out read (which HUNG 75s: orm_start_graph
opened every scope graph and RepoNotFound on any fresh/unsynced doc aborted the
subscription) with the read model:
- readEntities.ts → lib readUnion: resolve the by-need doc set (my own scope docs
  via listMyEntityDocs + public events via the discovery index — NOT all-accounts
  fan-out), then ONE anchorless union sparql_query (GRAPH ?g, VALUES-pinned). Map
  to app types. Re-query on a change signal (no reactive union query).
- countUserParticipations no longer fans out over all accounts (own docs only).
- await loadTestData in the seed step; deleted orphaned useShapeWithDefaults;
  removed the old multistore-stopgap fan-out scenarios; added the read-model-probe.
- Doctrine: rule_document-per-entity read half + _overview rewritten to the union
  model (write half unchanged).

Result: the 75s ORM hang is ELIMINATED (0 hangs; build/tsc/lib-93-tests green;
boundary clean). @data is NOT yet fully green: remaining failures are 90s step
timeouts in the test-harness broker data ops (clearWallet / runUnionProbe / seed)
this run — a harness/broker-op issue, not the read path. To finish separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-05 20:49:01 +02:00
parent eafb4403b9
commit 8bb19b687b
17 changed files with 456 additions and 439 deletions
@@ -0,0 +1,7 @@
# Doc-debt — app-architecture
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+10
View File
@@ -0,0 +1,10 @@
# Doc-debt — bdd-testing
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/test-harness/harness-ng.tsx @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/workshop/features/read-model-probe.feature @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/workshop/steps/data/read-model-probe.steps.ts @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/inscription.steps.ts @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+4 -4
View File
@@ -1,14 +1,14 @@
---
type: _overview
summary: Comment Festipod persiste ses données via le SDK @ng-eventually/client — entités stockées comme documents par scope, stack ORM/SHEX, modes connected/demo, seed
summary: Comment Festipod persiste ses données via le SDK @ng-eventually/client — entités stockées comme documents par scope, écriture SPARQL directe + lecture par modèle union, stack SHEX, modes connected/demo, seed
triggers:
keywords: [nextgraph, "@ng-eventually", useShape, ORM, SHEX, shape, scope, "@graph", NURI, sparql, seed, wallet, FestipodData, ngSession, ngGraph, bootstrap, document, entité]
paths: ["src/shared/shapes/**", "src/shared/hooks/useShape*", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/ng*", "src/shared/data/seedData.ts"]
keywords: [nextgraph, "@ng-eventually", union, readUnion, readEntities, SHEX, shape, scope, "@graph", NURI, sparql, seed, wallet, FestipodData, ngSession, ngGraph, bootstrap, document, entité]
paths: ["src/shared/shapes/**", "src/shared/data/readEntities.ts", "src/shared/data/entityWrites.ts", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/ng*", "src/shared/data/seedData.ts"]
---
# Data layer
Comment Festipod **persiste ses données** via NextGraph (P2P, local-first, chiffré de bout en bout). Le SDK de données est **`@ng-eventually/client`** : on le traite comme un SDK NextGraph fini — chaque entité est un **document** placé dans le store de son **scope** (public / protected / private), lu et écrit via l'ORM réactif. Le mapping *quelle entité → quel scope* est un fait **produit** (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]) ; ce concept décrit la **mécanique de persistance**.
Comment Festipod **persiste ses données** via NextGraph (P2P, local-first, chiffré de bout en bout). Le SDK de données est **`@ng-eventually/client`** : on le traite comme un SDK NextGraph fini — chaque entité est un **document** placé dans le store de son **scope** (public / protected / private). L'**écriture** est un SPARQL direct dans le document de l'entité ; la **lecture** est le **modèle union** (résoudre les documents par besoin → ouvrir/sync → **une** requête `sparql_query` sans ancre sur l'union → re-query sur signal), et non un abonnement ORM réactif en fan-out (qui *hang*). Voir [[rule_document-per-entity]]. Le mapping *quelle entité → quel scope* est un fait **produit** (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]) ; ce concept décrit la **mécanique de persistance**.
> **Frontière SDK.** Le SDK de données de Festipod est `@ng-eventually/client` — initialisé/injecté **une seule fois** via `ngSession.configure(...)`. On l'écrit comme un SDK NextGraph **fini** : ne jamais documenter ici l'état courant de NextGraph (contraintes, contournements, internes broker) — cela vit dans le repo `@ng-eventually/client`. Voir [[knowledge_nextgraph-stack]].
@@ -32,30 +32,53 @@ confiance.
- À 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.
- En lecture : passer par le SDK via le **modèle de lecture union** (voir plus bas) — l'app
résout un jeu de documents *par besoin* (index de découverte pour les événements publics ;
ses propres documents de scope pour ses entités) et le SDK ouvre/synchronise puis lit
l'union en **une seule** requête ; pas de résolution de NURI ni de choix union/ancré 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)
## Lecture : modèle union (open/sync + une requête ancrée-libre + re-query)
La **lecture** ne passe **PAS** par un abonnement ORM réactif en fan-out sur un jeu de documents
par-entité (`useShape({ graphs: […] })`) : contre le vrai broker un document fraîchement créé /
non-synchronisé dans ce fan-out fait avorter tout l'abonnement (`RepoNotFound`) → l'abonnement
n'émet jamais son initial → **hang ~75 s**. À la place, la lecture est le **modèle union** du SDK
([[knowledge_nextgraph-stack]], SDK `docs/read-model.md`) :
1. **résoudre par besoin** le jeu de NURIs à lire — événements publics via l'**index de découverte**
(la seule énumération cross-comptes sanctionnée) ; « mes entités » (profil, participations) via
**mes propres** documents de scope (`listMyEntityDocs(username, scope)`, borné à mon compte —
jamais de fan-out sur tous les comptes) ;
2. le SDK **ouvre/synchronise** ces documents puis exécute **UNE** requête `sparql_query`
**sans ancre** sur l'union locale (`GRAPH ?g { … }`) et rend les triplets groupés par sujet
(`src/shared/data/readEntities.ts``readModel.readUnion`) ;
3. il n'y a **pas** de requête union réactive → la **réactivité = re-query** sur un signal de
changement (un document créé/enregistré déclenche `bumpRead`).
Côté app, `FestipodDataContext` collecte les NURIs par besoin puis appelle `readEntities` ;
un document fraîchement créé est aussi enregistré localement (`registerDoc`) pour apparaître
immédiatement, avant que la re-liste ne le rattrape.
## Écriture directe (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).
SPARQL du SDK — `src/shared/data/entityWrites.ts`, `writeEntity`), **pas** via l'ajout à un
ensemble réactif. Raison : un ensemble réactif n'est *inscriptible* que si le document cible est
**déjà** dans son scope d'abonnement ; or enregistrer le document fraîchement créé 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, un `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
**lecture = union + re-query** (ci-dessus). Idem pour la **mutation d'un champ** existant (p. ex. `participantCount`) : muter une valeur
en mémoire ne tient pas — la re-query union relit la valeur **persistée** depuis le broker
(retour à l'ancienne valeur) → 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
manquant ou mal typé fait que la lecture **jette l'entité** (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:…`.
@@ -0,0 +1,7 @@
# Doc-debt — functional-domain
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/modules/workshop/features/read-model-probe.feature @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
@@ -9,9 +9,9 @@ import type { FestipodWorld } from '../../../../shared/support/world';
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
// Ensure wallet has data (seed if empty)
await this.appFrame!.evaluate(() => {
await this.appFrame!.evaluate(async () => {
const td = (window as any).__testData;
if (td.events.size === 0) td.loadTestData();
if (td.events.size === 0) await td.loadTestData();
});
// Wait for event to appear
await this.appFrame!.waitForFunction(
@@ -1,28 +0,0 @@
# language: fr
@WORKSHOP @priority-1
Fonctionnalité: Stopgap multi-store — primitives de données
En tant que développeur
Je veux valider, contre le vrai broker NextGraph, les primitives du stopgap
wallet partagé (création de documents, ORM sur un document créé, aller-retour
du sharedWalletShim) avant d'activer le mode multi-document.
# --- Data (broker réel) ---
@data
Scénario: L'ORM lit et écrit dans un document créé par doc_create
Étant donné un nouveau document de graphe est créé dans le wallet partagé
Quand j'écris une participation dans ce document via l'ORM
Alors la participation est lisible dans ce document
@data
Scénario: Le sharedWalletShim fait l'aller-retour par le wallet
Étant donné un compte "@smoketest" est enregistré dans le shim
Alors le compte "@smoketest" est retrouvé après rechargement du shim
Et le compte "@smoketest" possède trois documents de périmètre distincts
@data
Scénario: Lecture fan-out sur plusieurs documents d'entité (1 doc par entité)
Étant donné deux comptes ayant chacun un document d'événement indexé
Quand j'écris un événement dans chacun de ces deux documents
Alors un abonnement multi-graphes lit les deux événements ensemble
Et l'index public liste les deux documents
@@ -0,0 +1,16 @@
# language: fr
# THROWAWAY probe (T03.k) — pins the read-model union premise on the REAL broker.
# Remove after the read-model refactor lands.
@data @probe
Fonctionnalité: Probe du modèle de lecture (union locale sparql_query)
# VERIFIED on the real broker (T03.k): a GRAPH ?g { } body sans anchor voit
# l'UNION LOCALE de tous les graphes synchronisés c'est la prémisse du modèle
# de lecture (listing = open/sync + une seule requête union sans anchor). Un
# corps GRAPH ?g explicite itère sur TOUS les graphes nommés indépendamment du
# graphe par défaut : l'anchor ne restreint donc PAS un tel motif (il ne borne
# que le graphe par défaut). Le modèle n'a besoin que de l'union sans anchor.
Scénario: sparql_query sans anchor renvoie l'union locale des graphes synchronisés
Étant donné deux documents A et B contenant chacun un triplet distinct
Quand j'interroge l'union locale sans anchor
Alors la requête sans anchor voit A et B
@@ -1,109 +0,0 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// Data-layer validation of the shared-wallet multi-store stopgap, against the
// REAL broker. Exercises the exact app mechanisms: doc_create, a real
// useShape({graphs}) on the created doc (window.__smoke, via <SmokeProbe>), and
// the sharedWalletShim round-trip (window.__testData.validateShim).
// See brief_2026-06-15_shared-wallet-shim.
// --- Scenario 1: ORM on a doc_create'd document ---
Given('un nouveau document de graphe est créé dans le wallet partagé', async function (this: FestipodWorld) {
const nuri = await this.appFrame!.evaluate(async () => {
return await (window as any).__testData.createSmokeDoc();
});
expect(nuri, 'doc_create should return a NURI').to.be.a('string');
expect((nuri as string).length, 'doc_create NURI should be non-empty').to.be.greaterThan(0);
// Wait for <SmokeProbe> to mount the useShape({graphs}) and expose __smoke.
await this.appFrame!.waitForFunction(
() => (window as any).__smoke?.ready === true,
null,
{ timeout: 15000 },
);
});
When('j\'écris une participation dans ce document via l\'ORM', async function (this: FestipodWorld) {
await this.appFrame!.evaluate(() => (window as any).__smoke.add());
});
Then('la participation est lisible dans ce document', async function (this: FestipodWorld) {
await this.appFrame!.waitForFunction(
() => (window as any).__smoke.count() >= 1,
null,
{ timeout: 15000 },
);
const items = await this.appFrame!.evaluate(() => (window as any).__smoke.items());
expect(items.length, 'participation should be readable via ORM on the created doc').to.be.greaterThan(0);
});
// --- Scenario 2: sharedWalletShim round-trip ---
Given('un compte {string} est enregistré dans le shim', async function (this: FestipodWorld, username: string) {
const res = await this.appFrame!.evaluate(
async (u) => await (window as any).__testData.validateShim(u),
username,
);
(this as any).shimResult = res;
expect(res?.created, 'ensureAccount should return a record').to.exist;
});
Then('le compte {string} est retrouvé après rechargement du shim', function (this: FestipodWorld, username: string) {
const res = (this as any).shimResult;
expect(res?.reloaded, `account ${username} should reload from the wallet shim`).to.exist;
expect(res.reloaded.username).to.equal(username);
});
Then('le compte {string} possède trois documents de périmètre distincts', function (this: FestipodWorld, _username: string) {
const r = (this as any).shimResult?.reloaded;
expect(r, 'reloaded account should exist').to.exist;
const docs = [r.docPublic, r.docProtected, r.docPrivate];
for (const d of docs) {
expect(d, 'each scope doc should be a string').to.be.a('string');
expect((d as string).length, 'each scope doc NURI should be non-empty').to.be.greaterThan(0);
}
expect(new Set(docs).size, 'the three scope docs must be distinct').to.equal(3);
});
// --- Scenario 3: per-entity granularity + multi-graph fan-out ---
Given('deux comptes ayant chacun un document d\'événement indexé', async function (this: FestipodWorld) {
const res = await this.appFrame!.evaluate(async () => await (window as any).__testData.setupFanout());
(this as any).fanout = res;
expect(res.docA, 'event doc A').to.be.a('string');
expect(res.docB, 'event doc B').to.be.a('string');
await this.appFrame!.waitForFunction(
() => (window as any).__fanout?.ready === true,
null,
{ timeout: 15000 },
);
});
When('j\'écris un événement dans chacun de ces deux documents', async function (this: FestipodWorld) {
const { docA, docB } = (this as any).fanout;
await this.appFrame!.evaluate(
([a, b]: [string, string]) => {
(window as any).__fanout.addEventTo(a, 'FanA');
(window as any).__fanout.addEventTo(b, 'FanB');
},
[docA, docB] as [string, string],
);
});
Then('un abonnement multi-graphes lit les deux événements ensemble', async function (this: FestipodWorld) {
await this.appFrame!.waitForFunction(
() => (window as any).__fanout.count() >= 2,
null,
{ timeout: 15000 },
);
const titles = await this.appFrame!.evaluate(() => (window as any).__fanout.titles());
expect(titles, 'fan-out should read event from doc A').to.include('FanA');
expect(titles, 'fan-out should read event from doc B').to.include('FanB');
});
Then('l\'index public liste les deux documents', function (this: FestipodWorld) {
const { docA, docB, listed } = (this as any).fanout;
expect(listed, 'public index should list doc A').to.include(docA);
expect(listed, 'public index should list doc B').to.include(docB);
});
@@ -0,0 +1,25 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// THROWAWAY probe steps (T03.k) — assert the read-model union premise against the
// REAL broker via window.__testData.runUnionProbe (harness-ng). Remove with the
// feature after the read-model refactor lands.
Given('deux documents A et B contenant chacun un triplet distinct', async function (this: FestipodWorld) {
const res = await this.appFrame!.evaluate(async () => await (window as any).__testData.runUnionProbe());
(this as any).unionProbe = res;
expect(res?.docA, 'doc A NURI').to.be.a('string');
expect(res?.docB, 'doc B NURI').to.be.a('string');
});
When("j'interroge l'union locale sans anchor", function (this: FestipodWorld) {
// The probe ran the query inside runUnionProbe; nothing more to do here.
expect((this as any).unionProbe, 'probe result').to.exist;
});
Then('la requête sans anchor voit A et B', function (this: FestipodWorld) {
const r = (this as any).unionProbe;
expect(r.unionHasA, `union must see A (objs=${JSON.stringify(r.unionObjs)})`).to.equal(true);
expect(r.unionHasB, `union must see B (objs=${JSON.stringify(r.unionObjs)})`).to.equal(true);
});
+157 -211
View File
@@ -27,16 +27,10 @@ import {
import { useNextGraph } from './NextGraphContext';
import { useAccount, normalizeUsername } from './AccountContext';
import { declareConnections } from '@ng-eventually/client/polyfill';
import { listEntityDocs, createEntityDoc } from '../utils/storeRegistry';
import { listMyEntityDocs, createEntityDoc } from '../utils/storeRegistry';
import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
import { readEntities } from '../data/readEntities';
import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
// ============================================================================
@@ -76,7 +70,7 @@ interface FestipodDataContextValue {
leaveEvent(eventId: string, userId?: string): Promise<void> | void;
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
addFriend(friendId: string): void;
updateProfile(updates: Partial<FpUserData>): void;
updateProfile(updates: Partial<FpUserData>): void | Promise<void>;
loadTestData(): Promise<BootstrapResult>;
}
@@ -91,42 +85,7 @@ function nextId(prefix: string): string {
return `${prefix}-${++idCounter}`;
}
function findNg<T extends { "@id": string }>(set: Set<T>, predicate: (item: T) => boolean): T | undefined {
for (const item of set) {
if (predicate(item)) return item;
}
return undefined;
}
// NG shape → app type mappers
const mapEvent = (e: FpEvent): FpEventData => ({
id: e["@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,
});
const mapUser = (u: FpUserProfile): FpUserData => ({
id: u["@id"],
name: u.name,
initials: u.initials,
username: u.username,
role: u.role,
isPublic: u.isPublic,
});
const mapParticipation = (p: FpParticipation): FpParticipationData => ({
id: p["@id"],
eventId: p.event,
userId: p.user,
isConfirmed: p.isConfirmed,
});
// NG shape → app type mapping now lives in `../data/readEntities` (union read).
// ============================================================================
// Shared queries builder — same logic for both local and NG modes
@@ -253,110 +212,110 @@ function useNgData(): FestipodDataContextValue {
const { username } = useAccount();
// The app speaks ONLY in logical scopes — it holds no store id and builds no
// `did:ng:${…}` NURI. It creates ONE document PER ENTITY in its scope
// (`createEntityDoc(scope)`, the SDK create) and reads a scope by subscribing
// to the set of its per-entity documents (`listEntityDocs(scope)`). The SDK
// owns the physical placement AND the per-document isolation — the app carries
// no access logic (see rule_document-per-entity, knowledge_trust-model).
// (`createEntityDoc(scope)`, the SDK create). It READS by NEED: it asks the SDK
// for the document NURIs it may read (its own scope docs via `listEntityDocs`,
// the discovery index via `readDiscoveredEvents`) and hands them to the SDK's
// UNION READ (`readEntities` → `readModel.readUnion`) — the SDK opens/syncs the
// docs and runs ONE anchorless union `sparql_query`. There is NO reactive union
// query, so reactivity = RE-QUERY on a change signal (see `bumpRead`). This
// replaces the OLD reactive-ORM fan-out (`useShape({ graphs })`), which HUNG
// ~75s on a per-entity fan-out (see readEntities.ts, SDK docs/read-model.md).
// `ready` gates the effects on the session.
const ready = !!session;
// Per-entity document sets, by scope (the SDK create appends here immediately
// so a freshly-created entity is visible without waiting for a re-list). Events
// → public; profiles + participations → protected. Seeded from listEntityDocs.
// The by-need document set to READ (union), by scope. Events → public (my own +
// the index-discovered ones); profiles + participations → protected (my own).
// A freshly-created entity's doc is registered here immediately (reactivity).
const [publicDocs, setPublicDocs] = useState<string[]>([]);
const [protectedDocs, setProtectedDocs] = useState<string[]>([]);
// Re-query signal: bumped after every mutation / doc registration so the union
// read re-runs and picks up the change (there is no reactive union query).
const [readTick, setReadTick] = useState(0);
const bumpRead = useCallback(() => setReadTick(t => t + 1), []);
/** Add a freshly-created entity document to its scope's live subscription set
* (reactivity: the new doc joins the useShape graphs immediately). */
/** Add a freshly-created entity document to its scope's read set AND trigger a
* re-query (reactivity: the new doc joins the union read immediately). */
const registerDoc = useCallback((scope: 'public' | 'protected', nuri: string) => {
const setter = scope === 'public' ? setPublicDocs : setProtectedDocs;
setter(prev => (prev.includes(nuri) ? prev : [...prev, nuri]));
setReadTick(t => t + 1);
}, []);
// Resolve the by-need doc NURIs — READ BY NEED, never an all-accounts fan-out
// (the OLD `listEntityDocs('public'|'protected')` enumerated EVERY account and
// tried to open/sync other accounts' unsynced docs → HANG ~75s; see
// read-model.md). Two bounded sources:
// • PUBLIC events (all) → the GLOBAL DISCOVERY INDEX only (`readDiscoveredEvents`,
// the ONE sanctioned enumeration): it yields the public event-doc NURIs to
// open/sync. No account fan-out for events.
// • MY OWN entities (my profile, my participations) → MY OWN account's scope
// docs only (`listMyEntityDocs(username, scope)`, bounded to the current
// account — NO cross-account enumeration). Freshly-created docs are already
// tracked locally via `registerDoc`, so this only backfills on (re)login.
// The app never fans out an ORM subscription; it collects NURIs to hand to the
// union read. Union with locally-registered docs so a just-created doc isn't
// dropped before the re-list catches up.
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
try {
const [pub, prot] = await Promise.all([
listEntityDocs('public'),
listEntityDocs('protected'),
// Owner key = the account username (what `createEntityDoc`/`setCurrentUser`
// key on). No login (dev/demo) → no "my" docs to backfill; the discovery
// index still yields public events.
const owner = username;
const [myProtected, discovered] = await Promise.all([
owner ? listMyEntityDocs(owner, 'protected') : Promise.resolve<string[]>([]),
readDiscoveredEvents(),
]);
if (cancelled) return;
// Union with any docs already registered locally (don't drop a doc the
// user just created before the re-list caught up).
setPublicDocs(prev => [...new Set([...prev, ...pub])]);
setProtectedDocs(prev => [...new Set([...prev, ...prot])]);
const discDocs = discovered.map(r => r.doc).filter(Boolean) as string[];
// My own public event docs (bounded to my account) so a host reads back
// their own events even before the discovery index materializes.
const myPublic = owner ? await listMyEntityDocs(owner, 'public') : [];
if (cancelled) return;
setPublicDocs(prev => [...new Set([...prev, ...myPublic, ...discDocs])]);
setProtectedDocs(prev => [...new Set([...prev, ...myProtected])]);
setReadTick(t => t + 1);
} catch (err) {
console.error('[FestipodData] entity-doc listing failed:', err);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, username]);
// --- Public discovery (T03.c): read the GLOBAL INDEX ----------------------
// Discovery is "read the global index" (the SDK read). The app asks the SDK
// for the discovered public event references and subscribes to the documents
// they point at — a user sees other accounts' public events *without a
// connection* (Alice sees Bob's public event even if they're not friends).
// The SDK owns the index entirely (how it's stored, who hosts it, how a
// submission is materialized); the app holds NO index document NURI / store id
// and never fans out over accounts. Making an event discoverable is the
// symmetric SDK act on createEvent (`submitEventToIndex`).
//
// Additive & non-regressive: runs in BOTH modes but only contributes when the
// index has entries. In the default path the index is empty (nothing was ever
// submitted → []), so the discovery shape stays empty and the base `events`
// read is untouched. When events HAVE been submitted, discovery unions them in.
const [discoveryGraphs, setDiscoveryGraphs] = useState<string[]>([]);
// --- The UNION READ (replaces the reactive ORM fan-out) -------------------
// Open/sync the by-need docs and run ONE anchorless union query via the SDK,
// mapped to app types. Re-runs whenever the doc set or the re-query tick
// changes. `readReady` flips true after the first read so the empty state
// isn't mistaken for "wallet empty" by the auto-seed.
const [events, setEvents] = useState<FpEventData[]>([]);
const [users, setUsers] = useState<FpUserData[]>([]);
const [participations, setParticipations] = useState<FpParticipationData[]>([]);
const [readReady, setReadReady] = useState(false);
const allReadDocs = React.useMemo(
() => [...new Set([...publicDocs, ...protectedDocs])],
[publicDocs, protectedDocs],
);
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
try {
const refs = await readDiscoveredEvents(); // reads the SDK global index
const docs = [...new Set(refs.map(r => r.doc).filter(Boolean))];
if (!cancelled) setDiscoveryGraphs(docs);
const { events: ev, users: us, participations: pa } = await readEntities(allReadDocs);
if (cancelled) return;
setEvents(ev);
setUsers(us);
setParticipations(pa);
setReadReady(true);
} catch (err) {
console.error('[FestipodData] index-based discovery failed:', err);
console.error('[FestipodData] union read failed:', err);
if (!cancelled) setReadReady(true);
}
})();
return () => { cancelled = true; };
}, [ready, username]);
const discoveryScope: ShapeScope = discoveryGraphs.length ? { graphs: discoveryGraphs } : undefined;
// Scope per entity: events read the PUBLIC scope, profiles + participations the
// PROTECTED scope. Each scope subscribes to the SET of its per-entity documents
// (opaque SDK NURIs — the app never sees a store id). The SDK's per-document
// ReadCap filter returns only the documents the current identity may read.
const publicScope: ShapeScope = publicDocs.length ? { graphs: publicDocs } : undefined;
const protectedScope: ShapeScope = protectedDocs.length ? { graphs: protectedDocs } : undefined;
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyEvents: FpEventData[] = [];
const emptyUsers: FpUserData[] = [];
const emptyParticipations: FpParticipationData[] = [];
const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
// Cross-account public discovery: read the discovered documents as events.
const discoveryShape = useShapeWithDefaults(FpEventShapeType, discoveryScope, emptyEvents, mapEvent, true);
// Union the current-scope events with the cross-account discovered ones,
// de-duplicated by id (an event already read via publicScope must not appear
// twice). Discovery is purely additive — it never hides an existing event.
const events = React.useMemo(() => {
const seen = new Set(eventsShape.items.map(e => e.id));
const merged = [...eventsShape.items];
for (const e of discoveryShape.items) {
if (e.id && !seen.has(e.id)) { seen.add(e.id); merged.push(e); }
}
return merged;
}, [eventsShape.items, discoveryShape.items]);
const users = usersShape.items;
const participations = participationsShape.items;
}, [ready, allReadDocs, readTick]);
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
@@ -377,9 +336,10 @@ function useNgData(): FestipodDataContextValue {
}, [events.length, selectedEventId]);
// Dev auto-seed: if the wallet is still empty 3s after the session is ready,
// bootstrap with seed data. `bootstrapWallet()` self-checks (ngSet.size > 0
// → skip), so this is safe even if shapes finish hydrating after the timer.
// Gated on NODE_ENV so production users see their own (possibly empty) wallet.
// bootstrap with seed data. Guarded on the UNION READ result (events/users
// empty AND the first read has completed), so a slow first read isn't mistaken
// for an empty wallet. Gated on NODE_ENV so production users see their own
// (possibly empty) wallet.
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
@@ -387,24 +347,22 @@ function useNgData(): FestipodDataContextValue {
if (!ready) return;
const t = setTimeout(() => {
hasTriedAutoSeed.current = true;
if (eventsShape.ngSet.size === 0 && usersShape.ngSet.size === 0) {
const walletHasData = events.length > 0 || users.length > 0;
if (!walletHasData) {
console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…');
bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
createEntityDoc,
).then(({ createdDocs }) => {
// Register the seeded per-entity docs into the live subscription sets.
createdDocs.public.forEach(d => registerDoc('public', d));
createdDocs.protected.forEach(d => registerDoc('protected', d));
}).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
bootstrapWallet(walletHasData, createEntityDoc)
.then(({ createdDocs }) => {
// Register the seeded per-entity docs into the read set (+ re-query).
createdDocs.public.forEach(d => registerDoc('public', d));
createdDocs.protected.forEach(d => registerDoc('protected', d));
})
.catch(err => console.error('[FestipodData] Auto-seed failed:', err));
} else {
console.log('[FestipodData] Dev auto-seed: wallet already has data — skip');
}
}, 3000);
return () => clearTimeout(t);
}, [ready, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
}, [ready, events.length, users.length]);
// --- Derived ---
// Resolve current user from the chosen account username (the perceived login);
@@ -543,7 +501,7 @@ function useNgData(): FestipodDataContextValue {
registerDoc('protected', partGraph);
setSelectedEventId(eventId);
}
const addedEvent = { "@id": eventId, title: event.title } as FpEvent;
const addedEvent = { "@id": eventId, title: event.title };
// 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
@@ -560,30 +518,27 @@ function useNgData(): FestipodDataContextValue {
).catch(err => console.error('[FestipodData] submit event to index failed:', err));
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [eventsShape.ngSet, participationsShape.ngSet, currentUserId, username, registerDoc]);
}, [currentUserId, username, registerDoc]);
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;
if (updates.date !== undefined) ngEvent.date = updates.date;
if (updates.location !== undefined) ngEvent.location = updates.location;
if (updates.distance !== undefined) ngEvent.distance = updates.distance;
if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount;
// The event's `@id` IS its own document NURI (one entity = one document), so
// it is both the write graph and the subject. Persist each provided mutable
// field DIRECTLY via SPARQL (the durable write) then re-query so the union
// read reflects it — there is no reactive set to mutate in place anymore.
const graph = id;
const persists: Promise<void>[] = [];
if (updates.participantCount !== undefined) {
persists.push(updateEntityField(graph, id, 'participantCount', int(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]);
if (updates.title !== undefined) persists.push(updateEntityField(graph, id, 'title', str(updates.title)));
if (updates.description !== undefined) persists.push(updateEntityField(graph, id, 'description', str(updates.description)));
if (updates.date !== undefined) persists.push(updateEntityField(graph, id, 'date', str(updates.date)));
if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location)));
if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance)));
await Promise.all(persists).catch(err => console.error('[FestipodData] persist event update failed:', err));
bumpRead();
}, [bumpRead]);
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
@@ -601,7 +556,7 @@ function useNgData(): FestipodDataContextValue {
// 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);
const already = await countUserParticipations(username || uid || 'anon', eventId, uid).catch(() => 0);
if (already > 0) {
console.log('[FestipodData] Already participating (broker-confirmed), skipping');
return;
@@ -620,13 +575,13 @@ function useNgData(): FestipodDataContextValue {
event: iri(eventId), user: iri(uid), isConfirmed: bool(true),
});
registerDoc('protected', partGraph);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
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))
// Bump the event's participantCount durably. The event `@id` is its own doc
// NURI (graph = subject). Read the current count from the union-read `events`;
// persist +1 via SPARQL so the re-query reflects it. Fire-and-forget.
const curEvent = events.find(e => e.id === eventId);
if (curEvent) {
const next = curEvent.participantCount + 1;
updateEntityField(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
@@ -655,38 +610,34 @@ function useNgData(): FestipodDataContextValue {
} catch (err) {
console.error('[FestipodData] joinEvent inbox/notify failed:', err);
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId, username, registerDoc]);
bumpRead();
}, [events, currentUserId, username, registerDoc, bumpRead]);
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid);
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (!ngPart) return;
// DÉSINSCRIPTION FIX (caveat_participation-deletion): `ngSet.delete()` alone
// triggers reactivity but the item RESURRECTS via broker sync. The AUTHORITATIVE
// deletion is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate),
// which removes the Participation server-side so it does NOT come back after
// re-sync. The delete targets the participation's own @graph (the doc it lives
// in) — the participation's OWN per-entity document — and is identified by the
// participation's OWN subject IRI (ngPart["@id"]), not a string-match on the
// object IRIs (the F2 bug: object string-match could hit 0 rows on IRI-form
// drift → silent no-op → resurrection).
const graphNuri = ngPart["@graph"];
const subjectIri = ngPart["@id"];
// Find the participation in the union-read set. Each participation is its OWN
// document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH
// the subject IRI AND the graph NURI it lives in.
const part = participations.find(p => p.eventId === eventId && p.userId === uid);
if (!part) return;
// DÉSINSCRIPTION FIX (caveat_participation-deletion): the AUTHORITATIVE deletion
// is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), which
// removes the Participation server-side so it does NOT resurrect after re-sync.
// The delete targets the participation's own document (part.id) and is
// identified by its OWN subject IRI (part.id), not a string-match on object IRIs.
const graphNuri = part.id;
const subjectIri = part.id;
let result;
try {
result = await deleteParticipation(graphNuri, eventId, uid, subjectIri);
} catch (err) {
console.error('[FestipodData] SPARQL DELETE participation failed:', err);
// Do NOT flip the UI: the broker still holds the triple, so flipping the
// reactive set would resurrect on the next sync. Surface the failure.
throw err instanceof Error ? err : new Error(String(err));
}
// AUTHORITATIVE verification: only flip the UI once the broker RE-QUERY confirms
// the participation is actually gone (remaining === 0). If the delete matched
// nothing (weak match / IRI-form drift / wrong graph), remaining stays > 0 —
// flipping the reactive set here would show "not participating" while the broker
// still holds the triple, and it would resurrect after re-sync. Surface instead.
// AUTHORITATIVE verification: only proceed once the broker RE-QUERY confirms
// the participation is gone (remaining === 0). If the delete matched nothing,
// surface it rather than falsely flip the UI (it would resurrect on re-sync).
if (result.remaining > 0) {
const msg = `[FestipodData] leaveEvent: SPARQL delete removed nothing ` +
`(before=${result.before}, remaining=${result.remaining}, bySubject=${result.bySubject}) ` +
@@ -694,21 +645,17 @@ function useNgData(): FestipodDataContextValue {
console.error(msg);
throw new Error(msg);
}
// Confirmed gone server-side → reflect it in the reactive UI. This is the LOCAL
// reflection of the authoritative delete (not a second persistence path): the
// button flips to not-registered and STAYS so — the broker no longer holds the
// triple to resurrect. `isParticipating` reads this set, so the item must leave
// it for the UI to update immediately.
participationsShape.ngSet.delete(ngPart);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
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))
// Confirmed gone server-side → persist the event's participantCount decrement
// durably, then re-query the union read (the participation leaves the set on
// re-read; `isParticipating` reflects it).
const curEvent = events.find(e => e.id === eventId);
if (curEvent) {
const next = Math.max(0, curEvent.participantCount - 1);
updateEntityField(eventId, eventId, 'participantCount', int(next))
.catch(err => console.error('[FestipodData] persist participantCount (leave) failed:', err));
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
bumpRead();
}, [participations, events, currentUserId, bumpRead]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
@@ -724,31 +671,30 @@ function useNgData(): FestipodDataContextValue {
});
}, [currentUserId]);
const updateProfile = useCallback((updates: Partial<FpUserData>) => {
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
console.log('[FestipodData] updateProfile (NG):', updates);
const ngUser = findNg(usersShape.ngSet as any as Set<FpUserProfile>, u => u.username === '@mariedupont')
|| [...usersShape.ngSet][0];
if (ngUser) {
if (updates.name !== undefined) ngUser.name = updates.name;
if (updates.initials !== undefined) ngUser.initials = updates.initials;
if (updates.username !== undefined) ngUser.username = updates.username;
if (updates.role !== undefined) ngUser.role = updates.role;
if (updates.isPublic !== undefined) ngUser.isPublic = updates.isPublic;
}
}, [usersShape.ngSet]);
// The current user's profile is its own document (subject IRI = doc NURI).
const target = currentUser ?? users[0];
if (!target) return;
const graph = target.id;
const persists: Promise<void>[] = [];
if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name)));
if (updates.initials !== undefined) persists.push(updateEntityField(graph, graph, 'initials', str(updates.initials)));
if (updates.username !== undefined) persists.push(updateEntityField(graph, graph, 'username', str(updates.username)));
if (updates.role !== undefined) persists.push(updateEntityField(graph, graph, 'role', str(updates.role)));
if (updates.isPublic !== undefined) persists.push(updateEntityField(graph, graph, 'isPublic', bool(updates.isPublic)));
await Promise.all(persists).catch(err => console.error('[FestipodData] persist profile update failed:', err));
bumpRead();
}, [currentUser, users, bumpRead]);
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (NG)');
const result = await bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
createEntityDoc,
);
const walletHasData = events.length > 0 || users.length > 0;
const result = await bootstrapWallet(walletHasData, createEntityDoc);
result.createdDocs.public.forEach(d => registerDoc('public', d));
result.createdDocs.protected.forEach(d => registerDoc('protected', d));
return result;
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet, registerDoc]);
}, [events.length, users.length, registerDoc]);
return {
currentUserId, currentUser,
+119
View File
@@ -0,0 +1,119 @@
/**
* readEntities — the READ side of the one-document-per-entity model, mapping the
* SDK's union read (`readModel.readUnion`) to app types. This is the LISTING
* path: it asks the SDK to open/sync a set of documents and run ONE anchorless
* union `sparql_query`, then maps each returned subject's property bag to the
* corresponding Fp* type.
*
* WHY this replaces the ORM `useShape({ graphs })` fan-out: subscribing a fan-out
* of per-entity documents through the reactive ORM HANGS (~75s) — a freshly
* created / not-yet-synced doc makes `RepoNotFound` abort the whole subscription
* (see the SDK's docs/read-model.md, verified on the real broker in T03.k). The
* union query is one-shot, so there is no reactive union: reactivity = RE-QUERY on
* a change signal (a doc was created / registered).
*
* The app asks the SDK by NEED — it passes the document NURIs to read (from the
* discovery index for public events, or its own scope docs for my-entities) and
* never builds a store id or picks the union-vs-anchor mode. Placement + the
* union mechanism live in the SDK (read-model.ts); this file is only the Festipod
* domain mapping (fp: predicates → Fp* fields).
*/
import { readModel } from '@ng-eventually/client';
import type { UnionSubject } from '@ng-eventually/client';
import type { FpEventData, FpUserData, FpParticipationData } from './types';
const FP = 'http://festipod.org/';
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
const TYPE = {
event: `${FP}Event`,
user: `${FP}UserProfile`,
participation: `${FP}Participation`,
} as const;
/** First object value of a predicate on a subject (or `fallback`). */
function one(s: UnionSubject, field: string, fallback = ''): string {
return s.props[`${FP}${field}`]?.[0] ?? fallback;
}
function num(s: UnionSubject, field: string, fallback = 0): number {
const v = s.props[`${FP}${field}`]?.[0];
const n = v === undefined ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
}
function boolOf(s: UnionSubject, field: string): boolean {
return (s.props[`${FP}${field}`]?.[0] ?? 'false') === 'true';
}
function typeOf(s: UnionSubject): string | undefined {
return s.props[RDF_TYPE]?.[0];
}
function mapEvent(s: UnionSubject): FpEventData {
return {
id: s.subject,
title: one(s, 'title'),
description: one(s, 'description'),
date: one(s, 'date'),
location: one(s, 'location'),
distance: s.props[`${FP}distance`] ? num(s, 'distance') : undefined,
participantCount: num(s, 'participantCount'),
coverImage: one(s, 'coverImage') || undefined,
hostName: one(s, 'hostName') || undefined,
hostInitials: one(s, 'hostInitials') || undefined,
};
}
function mapUser(s: UnionSubject): FpUserData {
return {
id: s.subject,
name: one(s, 'name'),
initials: one(s, 'initials'),
username: one(s, 'username'),
role: one(s, 'role') || undefined,
isPublic: s.props[`${FP}isPublic`] ? boolOf(s, 'isPublic') : undefined,
};
}
function mapParticipation(s: UnionSubject): FpParticipationData {
return {
id: s.subject,
eventId: one(s, 'event'),
userId: one(s, 'user'),
isConfirmed: boolOf(s, 'isConfirmed'),
};
}
/** All entities read from `docs` (union), split by RDF `@type`. */
export interface ReadEntities {
events: FpEventData[];
users: FpUserData[];
participations: FpParticipationData[];
}
/**
* Open/sync `docs` and run ONE union query (SDK `readModel.readUnion`), then map
* each subject to its Fp* type by RDF `@type`. `docs` is the by-need set of
* document NURIs to read (the app resolves it: index-discovered event docs +
* my own scope docs). A subject whose participation carries no `fp:user` is
* dropped (the SHEX `fp:user` is mandatory — matches the ORM read).
*/
export async function readEntities(docs: string[]): Promise<ReadEntities> {
const subjects = await readModel.readUnion(docs);
const out: ReadEntities = { events: [], users: [], participations: [] };
for (const s of subjects) {
switch (typeOf(s)) {
case TYPE.event:
out.events.push(mapEvent(s));
break;
case TYPE.user:
out.users.push(mapUser(s));
break;
case TYPE.participation: {
const p = mapParticipation(s);
if (p.userId) out.participations.push(p);
break;
}
}
}
return out;
}
+12 -7
View File
@@ -19,7 +19,7 @@
import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client';
import { sessionPromise } from '../utils/ngSession';
import { resolveInboxAnchor, listEntityDocs } from '../utils/storeRegistry';
import { resolveInboxAnchor, listMyEntityDocs } from '../utils/storeRegistry';
import type { FpNotificationData } from './types';
/** Notification IRI/type constants (mirror the SHEX Notification shape). */
@@ -152,18 +152,23 @@ export async function readRegistrationNotifications(
}
/**
* 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.
* AUTHORITATIVE count of a user's Participations to an event across the user's OWN
* 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.
*
* Scoped to the CURRENT account (`username`) via `listMyEntityDocs` — a user's own
* participations live in their own account, so there is NO need to fan out over all
* accounts (which would open/sync other accounts' unsynced docs → the ~75s hang).
*/
export async function countUserParticipations(
username: string,
eventId: string,
userId: string,
): Promise<number> {
const sid = (await sessionPromise).session_id;
const docs_ = await listEntityDocs('protected');
const docs_ = await listMyEntityDocs(username, 'protected');
let total = 0;
for (const g of docs_) {
total += await countParticipations(sid, g, eventId, userId).catch(() => 0);
-42
View File
@@ -1,42 +0,0 @@
/**
* useShapeWithDefaults — wrapper around the SDK ORM's useShape.
*
* Subscribes to a SCOPE-resolved graph NURI (obtained from the SDK by logical
* scope — the app holds no store id), which opens the repo in the verifier
* (required for writes). Maps results to app types. If the NG set is empty,
* returns defaults.
*
* Must only be called when NG is connected (inside NgDataProvider).
*/
import { useShape } from '@ng-eventually/client';
import type { ShapeType, BaseType, DeepSignalSet } from '@ng-eventually/client';
export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
/** Mapped items from NG store */
items: AppT[];
/** Raw NG signal set for mutations */
ngSet: DeepSignalSet<NgT>;
}
/**
* `scope` is either a single scope-resolved graph NURI (from the SDK) or a
* `{ graphs }` set of document NURIs (a read fan-out). `useShape` accepts both
* natively. Either way the value is opaque to the app — it never builds it.
*/
export type ShapeScope = string | { graphs: string[] } | undefined;
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
shapeType: ShapeType<NgT>,
storeNuri: ShapeScope,
defaults: AppT[],
mapFromNg: (item: NgT) => AppT,
shapesReady: boolean,
): ShapeWithDefaults<NgT, AppT> {
// A single scope-resolved graph NURI opens the repo in the verifier (enables
// writes); a { graphs } scope subscribes to several docs (read fan-out).
const ngSet = useShape(shapeType, storeNuri as any) as DeepSignalSet<NgT>;
const usingDefaults = !shapesReady;
const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT));
return { items, ngSet };
}
+49 -8
View File
@@ -126,14 +126,14 @@ function ConnectedHarness() {
// 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 app writes ONE DOCUMENT PER ENTITY (events → public per-entity docs,
// participations/users → protected per-entity docs) via `createEntityDoc`,
// and READS by the union model (T03.k): resolve the by-need doc NURIs (my own
// scope docs + the discovery index) then run ONE anchorless union
// `sparql_query` (`readEntities` → `readModel.readUnion`), re-querying on a
// change signal — never the reactive per-entity ORM fan-out (that HANGS). The
// step-facing `events/users/participations` + mutations/queries delegate to the
// APP data context (`appData`), i.e. the exact union-read 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.
@@ -510,6 +510,47 @@ function ConnectedHarness() {
return nuri;
},
/**
* T03.k PROBE — pins down the read-model union premise against the REAL
* broker (docs/read-model.md § Minimal broker probe). Creates two graph
* docs A and B, writes a DISTINCT triple into each (anchored per-doc),
* then queries GRAPH ?g { ?s ?p ?o } twice: once with NO anchor (expect
* BOTH A and B — the LOCAL UNION) and once anchored to A (expect ONLY A).
* Returns the graphs seen in each mode so the step can assert the model.
*/
async runUnionProbe() {
const sid = session.session_id;
const docA = await docs.docCreate(sid, 'Graph', 'data:graph', 'store', undefined);
const docB = await docs.docCreate(sid, 'Graph', 'data:graph', 'store', undefined);
const sA = `urn:probe:s:${Date.now().toString(36)}:a`;
const sB = `urn:probe:s:${Date.now().toString(36)}:b`;
await docs.sparqlUpdate(sid, `INSERT DATA { GRAPH <${docA}> { <${sA}> <urn:probe:p> "A" } }`, docA);
await docs.sparqlUpdate(sid, `INSERT DATA { GRAPH <${docB}> { <${sB}> <urn:probe:p> "B" } }`, docB);
// Query our OWN probe subjects (sA/sB) so the assertion is by triple,
// not by the repo_graph_name (which carries an overlay suffix and won't
// string-equal the doc NURI). ?g is still selected for observability.
const q = `SELECT ?g ?s ?o WHERE { GRAPH ?g { ?s <urn:probe:p> ?o . FILTER(?s IN (<${sA}>, <${sB}>)) } }`;
const readObjs = (res: any): string[] => {
const rows = Array.isArray(res) ? res : res?.results?.bindings ?? [];
return rows.map((r: any) => r?.o?.value).filter(Boolean);
};
// NO anchor → local union across all opened graphs.
const unionRes = await docs.sparqlQuery(sid, q, undefined, undefined);
const unionObjs = readObjs(unionRes);
// Anchor = A → one repo only.
const anchorRes = await docs.sparqlQuery(sid, q, undefined, docA);
const anchorObjs = readObjs(anchorRes);
return {
docA, docB,
unionObjs,
anchorObjs,
unionHasA: unionObjs.includes('A'),
unionHasB: unionObjs.includes('B'),
anchorHasA: anchorObjs.includes('A'),
anchorHasB: anchorObjs.includes('B'),
};
},
/**
* Round-trip the sharedWalletShim through the wallet: create an account
* (3 docs + SPARQL INSERT), drop the cache, reload from the wallet via
+10 -14
View File
@@ -11,8 +11,6 @@
* them to the live subscription set (reactivity).
*/
import type { DeepSignalSet } from '@ng-eventually/client';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { normalizeUsername } from '../context/AccountContext';
import {
seedEvents,
@@ -34,24 +32,22 @@ export interface BootstrapResult {
/**
* 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.
* DIRECTLY into each entity's own document (see `entityWrites.writeEntity`). The
* created document NURIs are returned so the caller registers them into the read
* model's doc set for the union READ.
*
* `walletHasData` tells the seed whether the wallet already carries entities (a
* returning user → skip). The caller computes it from the union read (no ORM set
* needed — the read side is now the one-shot union query, not a reactive fan-out).
*/
export async function bootstrapWallet(
ngEvents: DeepSignalSet<FpEvent>,
ngUsers: DeepSignalSet<FpUserProfile>,
ngParticipations: DeepSignalSet<FpParticipation>,
walletHasData: boolean,
createEntityDoc: CreateEntityDoc,
): Promise<BootstrapResult> {
const createdDocs = { public: [] as string[], protected: [] as string[] };
// Already has data → returning user, nothing to seed
if (ngEvents.size > 0 || ngUsers.size > 0) {
console.log('[Bootstrap] Wallet already has data — events:', ngEvents.size,
'users:', ngUsers.size, 'participations:', ngParticipations.size);
if (walletHasData) {
console.log('[Bootstrap] Wallet already has data — skipping seed');
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs };
}
+1
View File
@@ -62,6 +62,7 @@ export const {
ensureAccount,
resolveWriteGraph,
listEntityDocs,
listMyEntityDocs,
allAccounts,
resolveReadGraphs,
resetRegistryCache,