From 8bb19b687b708edd31b001755133fae7c8e02dd2 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Sun, 5 Jul 2026 20:49:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(data):=20union=20read=20model=20=E2=80=94?= =?UTF-8?q?=20list=20via=20anchorless=20sparql=5Fquery,=20hang=20eliminate?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .project/concepts/app-architecture/_debt.md | 7 + .project/concepts/bdd-testing/_debt.md | 10 + .project/concepts/data-layer/_overview.md | 8 +- .../data-layer/rule_document-per-entity.md | 51 ++- .project/concepts/functional-domain/_debt.md | 7 + .../event/steps/data/inscription.steps.ts | 4 +- .../features/multistore-stopgap.feature | 28 -- .../features/read-model-probe.feature | 16 + .../workshop/steps/data/multistore.steps.ts | 109 ------ .../steps/data/read-model-probe.steps.ts | 25 ++ src/shared/context/FestipodDataContext.tsx | 368 ++++++++---------- src/shared/data/readEntities.ts | 119 ++++++ src/shared/data/registration.ts | 19 +- src/shared/hooks/useShapeWithDefaults.ts | 42 -- src/shared/test-harness/harness-ng.tsx | 57 ++- src/shared/utils/ngBootstrap.ts | 24 +- src/shared/utils/storeRegistry.ts | 1 + 17 files changed, 456 insertions(+), 439 deletions(-) create mode 100644 .project/concepts/app-architecture/_debt.md create mode 100644 .project/concepts/bdd-testing/_debt.md create mode 100644 .project/concepts/functional-domain/_debt.md delete mode 100644 src/modules/workshop/features/multistore-stopgap.feature create mode 100644 src/modules/workshop/features/read-model-probe.feature delete mode 100644 src/modules/workshop/steps/data/multistore.steps.ts create mode 100644 src/modules/workshop/steps/data/read-model-probe.steps.ts create mode 100644 src/shared/data/readEntities.ts delete mode 100644 src/shared/hooks/useShapeWithDefaults.ts diff --git a/.project/concepts/app-architecture/_debt.md b/.project/concepts/app-architecture/_debt.md new file mode 100644 index 0000000..25be45e --- /dev/null +++ b/.project/concepts/app-architecture/_debt.md @@ -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) diff --git a/.project/concepts/bdd-testing/_debt.md b/.project/concepts/bdd-testing/_debt.md new file mode 100644 index 0000000..6e978b9 --- /dev/null +++ b/.project/concepts/bdd-testing/_debt.md @@ -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) diff --git a/.project/concepts/data-layer/_overview.md b/.project/concepts/data-layer/_overview.md index 3aebc19..0e3e129 100644 --- a/.project/concepts/data-layer/_overview.md +++ b/.project/concepts/data-layer/_overview.md @@ -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]]. diff --git a/.project/concepts/data-layer/rule_document-per-entity.md b/.project/concepts/data-layer/rule_document-per-entity.md index d1e61b4..2eeb4e0 100644 --- a/.project/concepts/data-layer/rule_document-per-entity.md +++ b/.project/concepts/data-layer/rule_document-per-entity.md @@ -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:…`. diff --git a/.project/concepts/functional-domain/_debt.md b/.project/concepts/functional-domain/_debt.md new file mode 100644 index 0000000..d2a5c5e --- /dev/null +++ b/.project/concepts/functional-domain/_debt.md @@ -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) diff --git a/src/modules/event/steps/data/inscription.steps.ts b/src/modules/event/steps/data/inscription.steps.ts index 9c6525d..bde7af1 100644 --- a/src/modules/event/steps/data/inscription.steps.ts +++ b/src/modules/event/steps/data/inscription.steps.ts @@ -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( diff --git a/src/modules/workshop/features/multistore-stopgap.feature b/src/modules/workshop/features/multistore-stopgap.feature deleted file mode 100644 index 627f889..0000000 --- a/src/modules/workshop/features/multistore-stopgap.feature +++ /dev/null @@ -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 diff --git a/src/modules/workshop/features/read-model-probe.feature b/src/modules/workshop/features/read-model-probe.feature new file mode 100644 index 0000000..1960f5a --- /dev/null +++ b/src/modules/workshop/features/read-model-probe.feature @@ -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 diff --git a/src/modules/workshop/steps/data/multistore.steps.ts b/src/modules/workshop/steps/data/multistore.steps.ts deleted file mode 100644 index 5443a70..0000000 --- a/src/modules/workshop/steps/data/multistore.steps.ts +++ /dev/null @@ -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 ), 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 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); -}); diff --git a/src/modules/workshop/steps/data/read-model-probe.steps.ts b/src/modules/workshop/steps/data/read-model-probe.steps.ts new file mode 100644 index 0000000..1d0f5f7 --- /dev/null +++ b/src/modules/workshop/steps/data/read-model-probe.steps.ts @@ -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); +}); diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 7ca50b2..957b8eb 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -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; addMeetingPoint(mp: Omit): void; addFriend(friendId: string): void; - updateProfile(updates: Partial): void; + updateProfile(updates: Partial): void | Promise; loadTestData(): Promise; } @@ -91,42 +85,7 @@ function nextId(prefix: string): string { return `${prefix}-${++idCounter}`; } -function findNg(set: Set, 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([]); const [protectedDocs, setProtectedDocs] = useState([]); + // 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([]), + 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([]); + // --- 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([]); + const [users, setUsers] = useState([]); + const [participations, setParticipations] = useState([]); + 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([]); @@ -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) => { console.log('[FestipodData] updateEvent (NG):', id, updates); - const ngEvent = findNg(eventsShape.ngSet as any as Set, 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[] = []; + 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, 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, 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) => { setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); @@ -724,31 +671,30 @@ function useNgData(): FestipodDataContextValue { }); }, [currentUserId]); - const updateProfile = useCallback((updates: Partial) => { + const updateProfile = useCallback(async (updates: Partial) => { console.log('[FestipodData] updateProfile (NG):', updates); - const ngUser = findNg(usersShape.ngSet as any as Set, 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[] = []; + 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 => { 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, diff --git a/src/shared/data/readEntities.ts b/src/shared/data/readEntities.ts new file mode 100644 index 0000000..134b884 --- /dev/null +++ b/src/shared/data/readEntities.ts @@ -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 { + 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; +} diff --git a/src/shared/data/registration.ts b/src/shared/data/registration.ts index 462a751..c529377 100644 --- a/src/shared/data/registration.ts +++ b/src/shared/data/registration.ts @@ -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 { 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); diff --git a/src/shared/hooks/useShapeWithDefaults.ts b/src/shared/hooks/useShapeWithDefaults.ts deleted file mode 100644 index 71ccd7a..0000000 --- a/src/shared/hooks/useShapeWithDefaults.ts +++ /dev/null @@ -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 { - /** Mapped items from NG store */ - items: AppT[]; - /** Raw NG signal set for mutations */ - ngSet: DeepSignalSet; -} - -/** - * `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( - shapeType: ShapeType, - storeNuri: ShapeScope, - defaults: AppT[], - mapFromNg: (item: NgT) => AppT, - shapesReady: boolean, -): ShapeWithDefaults { - // 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; - const usingDefaults = !shapesReady; - const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT)); - - return { items, ngSet }; -} diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index da485f9..f74a863 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -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}> "A" } }`, docA); + await docs.sparqlUpdate(sid, `INSERT DATA { GRAPH <${docB}> { <${sB}> "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 ?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 diff --git a/src/shared/utils/ngBootstrap.ts b/src/shared/utils/ngBootstrap.ts index a8551f6..50d746a 100644 --- a/src/shared/utils/ngBootstrap.ts +++ b/src/shared/utils/ngBootstrap.ts @@ -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, - ngUsers: DeepSignalSet, - ngParticipations: DeepSignalSet, + walletHasData: boolean, createEntityDoc: CreateEntityDoc, ): Promise { 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 }; } diff --git a/src/shared/utils/storeRegistry.ts b/src/shared/utils/storeRegistry.ts index 0efa59d..c898bfd 100644 --- a/src/shared/utils/storeRegistry.ts +++ b/src/shared/utils/storeRegistry.ts @@ -62,6 +62,7 @@ export const { ensureAccount, resolveWriteGraph, listEntityDocs, + listMyEntityDocs, allAccounts, resolveReadGraphs, resetRegistryCache,