From 8ca79c6d16533139e76cd3d50f8ec6858ec488af Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Sun, 5 Jul 2026 22:50:15 +0200 Subject: [PATCH] refactor(data): per-doc anchored reads over the virtual wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read each by-need entity document with its own anchored query (bounded to the current account's virtual wallet), never an anchorless scan of the physical shared wallet. The 75s ORM hang stays gone; a non-empty PHYSICAL wallet now costs nothing (never scanned). Removed the throwaway anchorless-union probe. Known remaining (test-infra, not the product): the @data suite still times out because THIS test account's VIRTUAL wallet is bloated (hundreds of docs accumulated across this session's many runs) → per-doc reads are O(my docs), and `clearWallet` still enumerates all accounts. Needs per-scenario test isolation (fresh/small virtual wallet) + a virtual-wallet-scoped clear to validate green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .project/concepts/data-layer/_debt.md | 8 +++ .../features/read-model-probe.feature | 16 ------ .../steps/data/read-model-probe.steps.ts | 25 --------- src/shared/context/FestipodDataContext.tsx | 12 ++--- src/shared/data/readEntities.ts | 22 ++++---- src/shared/test-harness/harness-ng.tsx | 52 +++---------------- 6 files changed, 31 insertions(+), 104 deletions(-) create mode 100644 .project/concepts/data-layer/_debt.md delete mode 100644 src/modules/workshop/features/read-model-probe.feature delete mode 100644 src/modules/workshop/steps/data/read-model-probe.steps.ts diff --git a/.project/concepts/data-layer/_debt.md b/.project/concepts/data-layer/_debt.md new file mode 100644 index 0000000..46eb004 --- /dev/null +++ b/.project/concepts/data-layer/_debt.md @@ -0,0 +1,8 @@ +# Doc-debt — data-layer + +> 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/data/readEntities.ts @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) +- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/src/modules/workshop/features/read-model-probe.feature b/src/modules/workshop/features/read-model-probe.feature deleted file mode 100644 index 1960f5a..0000000 --- a/src/modules/workshop/features/read-model-probe.feature +++ /dev/null @@ -1,16 +0,0 @@ -# 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/read-model-probe.steps.ts b/src/modules/workshop/steps/data/read-model-probe.steps.ts deleted file mode 100644 index 1d0f5f7..0000000 --- a/src/modules/workshop/steps/data/read-model-probe.steps.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 957b8eb..b30f68c 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -215,9 +215,9 @@ function useNgData(): FestipodDataContextValue { // (`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 + // BY-NEED READ (`readEntities` → `readModel.readUnion`) — the SDK reads each of + // those docs by need (fast, per-document, independent of wallet size). There is NO + // reactive read, 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. @@ -285,9 +285,9 @@ function useNgData(): FestipodDataContextValue { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, username]); - // --- 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 + // --- The BY-NEED READ (replaces the reactive ORM fan-out) ----------------- + // Read the bounded by-need docs via the SDK (per-document, independent of wallet + // size), 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([]); diff --git a/src/shared/data/readEntities.ts b/src/shared/data/readEntities.ts index 134b884..1f1a806 100644 --- a/src/shared/data/readEntities.ts +++ b/src/shared/data/readEntities.ts @@ -1,22 +1,22 @@ /** * 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. + * SDK's read (`readModel.readUnion`) to app types. This is the LISTING path: it + * asks the SDK to read a BOUNDED, by-need set of documents, 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). + * (see the SDK's docs/read-model.md). The SDK read is one-shot, so there is no + * reactive read: 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). + * trusts the returned set. HOW the SDK reads those docs (fast, per-document, + * independent of how much the wallet holds) is entirely internal to the SDK + * (read-model.ts); this file is only the Festipod domain mapping (fp: predicates + * → Fp* fields). */ import { readModel } from '@ng-eventually/client'; @@ -91,7 +91,7 @@ export interface ReadEntities { } /** - * Open/sync `docs` and run ONE union query (SDK `readModel.readUnion`), then map + * Read the by-need `docs` via the 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 diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index f74a863..87b1abb 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -128,12 +128,13 @@ function ConnectedHarness() { // 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 + // and READS by need: resolve the bounded by-need doc NURIs (my own scope docs + // + the discovery index) then read EACH doc with its OWN anchored `sparql_query` + // (`readEntities` → `readModel.readUnion`), re-querying on a change signal — + // never the reactive per-entity ORM fan-out (that HANGS), and never an + // anchorless scan of all graphs (O(wallet), times out on a bloated wallet). 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. + // APP data context (`appData`), i.e. the exact 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,47 +511,6 @@ 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