From 38266d96f8ec8374f39d0918593f5d8e461ab9ab Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Thu, 9 Jul 2026 23:46:31 +0200 Subject: [PATCH] refactor(app): l'app lit via watchShape (useShapeQuery), plus de machinerie bespoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B — FestipodDataContext lit désormais via la surface SDK `watchShape` (binding `useSyncExternalStore` dans `useShapeQuery`) + adaptateurs Fp (`shapeAdapters.ts`), au lieu de sa machinerie maison. Applique rule_app-uses-sdk-surface-only : l'app ne consomme que la surface SDK. Supprimé : `readEntities.ts`, `subscribeDocs`+`bumpRead`+`readTick`+`readDocKey`, le listing manuel (`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture, `readDiscoveredEvents`), et les commentaires raisonnant sur le hang ORM. Gardé découplé : `listMyEntityDocs(owner,'public')` → `ownedEventIds` pour le seul matérialiseur propriétaire. Auto-seed : chronomètre 3 s → gate `isSuccess` (seed uniquement si synchronisé ET vide) — fix du re-seed « First time… » au 3ᵉ connect. Mode démo inchangé. Non-régression VÉRIFIÉE (broker réel, wallet frais) : inscription (1 passed), isolation « identité fraîche ne voit pas » (re-run local, 5 steps passed), compteur dérivé/Q4 (1 passed). tsc propre, build OK. Résiduel PRÉ-EXISTANT (pas causé par ce refactor, vérifié par stash sur baseline) : - reconnexion « relit ses propres données » → RE-@wip : défaut cold-read de l'index de scope PUBLIC côté lib (une page fraîche relit vide) — prochaine cible. - un @AUTH « données pas rechargées » (timing loadFire-and-forget vs step 30 s). Doctrine : rule_app-uses-sdk-surface-only « déviation résolue ». Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rule_app-uses-sdk-surface-only.md | 15 +- .../reconnexion-meme-identite.feature | 2 +- src/shared/context/FestipodDataContext.tsx | 399 ++++++------------ src/shared/data/readEntities.ts | 119 ------ src/shared/data/shapeAdapters.ts | 94 +++++ src/shared/data/useShapeQuery.ts | 50 +++ src/shared/test-harness/harness-ng.tsx | 18 +- 7 files changed, 288 insertions(+), 409 deletions(-) delete mode 100644 src/shared/data/readEntities.ts create mode 100644 src/shared/data/shapeAdapters.ts create mode 100644 src/shared/data/useShapeQuery.ts diff --git a/.project/concepts/data-layer/rule_app-uses-sdk-surface-only.md b/.project/concepts/data-layer/rule_app-uses-sdk-surface-only.md index b6c6f08..14d3ba4 100644 --- a/.project/concepts/data-layer/rule_app-uses-sdk-surface-only.md +++ b/.project/concepts/data-layer/rule_app-uses-sdk-surface-only.md @@ -28,14 +28,17 @@ contournements (read-model union à la place du fan-out ORM, `open-repo`, readin miroir de `readyPromise`, émulation de caps…) sont **internes au polyfill** et n'apparaissent jamais dans l'app. -## Déviation actuelle (dette à corriger) +## État (déviation résolue) -`src/shared/data/readEntities.ts` + `FestipodDataContext` lisent via -`readModel.readUnion` + `subscribeDocs` + `bumpRead`, avec un commentaire qui explique -que ça « remplace le fan-out `useShape` qui hang ». C'est la fuite exacte que cette -règle interdit. +**Résolu** : `FestipodDataContext` lit désormais via `useShapeQuery` (binding +`useSyncExternalStore` sur `watchShape` du polyfill) + adaptateurs Fp +(`src/shared/data/shapeAdapters.ts`). Sont **supprimés** : `readEntities.ts`, la +réactivité bespoke (`subscribeDocs`+`bumpRead`+`readTick`), le listing manuel +(`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture), et les commentaires +raisonnant sur le hang ORM. L'auto-seed est gardé sur `isSuccess` (plus de +chronomètre 3 s). L'app ne consomme plus que la surface SDK. -**Cible** : le polyfill expose un `useShape` **réactif, scopé au wallet virtuel**, dont +**Cible (rappel du design)** : le polyfill expose un `useShape` **réactif, scopé au wallet virtuel**, dont la **forme suit TanStack `useQuery`** — `{ data, isPending/isLoading, isSuccess, isError, … }` — **en anticipation de la mise à jour PRÉVUE de `useShape` par NextGraph** (qui va adopter ce fonctionnement). Ce n'est donc pas une invention : c'est une API future de diff --git a/src/modules/event/features/reconnexion-meme-identite.feature b/src/modules/event/features/reconnexion-meme-identite.feature index b7fa7b4..511e4d1 100644 --- a/src/modules/event/features/reconnexion-meme-identite.feature +++ b/src/modules/event/features/reconnexion-meme-identite.feature @@ -1,5 +1,5 @@ # language: fr -@EVENT @priority-1 @data +@EVENT @priority-1 @data @wip Fonctionnalité: Reconnexion d'une même identité sur le wallet persistant En tant qu'utilisateur qui, sur le MÊME wallet physique, ouvre une PAGE FRAÎCHE (nouveau login broker, session fraîche) sous le MÊME identifiant qu'avant, diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 98715b7..d9d3a77 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -35,9 +35,14 @@ import { useAccount, normalizeUsername } from './AccountContext'; import { declareConnections } from '../utils/connections'; import { listMyEntityDocs, createEntityDoc, resetRegistryCache } from '../utils/storeRegistry'; import { resetCaps } from '@ng-eventually/client/polyfill'; -import { submitEventToIndex, readDiscoveredEvents, watchDiscoveredEvents } from '../data/discovery'; -import { subscribeDocs } from '@ng-eventually/client'; -import { readEntities } from '../data/readEntities'; +import { submitEventToIndex } from '../data/discovery'; +import { useShapeQuery } from '../data/useShapeQuery'; +import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters'; +import { + FpEventShapeType, + FpUserProfileShapeType, + FpParticipationShapeType, +} from '../shapes/orm/festipodShapes.shapeTypes'; import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites'; import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap'; @@ -93,7 +98,8 @@ function nextId(prefix: string): string { return `${prefix}-${++idCounter}`; } -// NG shape → app type mapping now lives in `../data/readEntities` (union read). +// NG shape → app type mapping lives in `../data/shapeAdapters` (domain adapters +// over the SDK's `watchShape` subjects). // ============================================================================ // Shared queries builder — same logic for both local and NG modes @@ -220,59 +226,43 @@ 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). 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 - // 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. + // (`createEntityDoc(scope)`, the SDK create) and READS via the SDK's reactive, + // `useQuery`-shaped surface `watchShape(shape, scope)` (bound to React by + // `useShapeQuery`). The observable resolves the scope to the current identity's + // wallet (its own scope docs + discovery for public), awaits the sync barrier, + // and pushes on every change — no bespoke re-query, no manual doc listing, no + // per-doc subscription in the app. See rule_app-uses-sdk-surface-only. const ready = !!session; - // 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), []); - // RE-LIST signal: bumped after a SEED so the by-need listing effect re-runs and - // re-reads the now-populated scope INDEX documents. `registerDoc` alone is not - // enough for PROTECTED user docs: events also reach the read via the discovery - // index (a second, reliable path), but protected docs have no such fallback, so - // if the listing effect ran BEFORE the seed wrote the protected index (the - // common race — the effect fires on session-ready, the seed lands later) the - // seeded protected docs never enter `allReadDocs`. Bumping this makes the effect - // re-read `listMyEntityDocs(owner, 'protected')` once the index is populated. - const [listTick, setListTick] = useState(0); - const relist = useCallback(() => setListTick(t => t + 1), []); + // --- REACTIVE READS via the SDK surface (`watchShape` bound with useShapeQuery) -- + // Three scoped shape reads, mapped to the app's domain types. Each is reactive + // (broker push, no polling): a locally-created entity, a seeded doc, or a remote + // peer's public event all re-render through the observable's own subscriptions. + // • events = public (my own public event docs + discovery index) + // • profiles/users = protected (my own) + // • participations = protected (my own; cap-filtered by the SDK) + const eventQuery = useShapeQuery(FpEventShapeType, 'public'); + const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected'); + const partQuery = useShapeQuery(FpParticipationShapeType, 'protected'); + const events = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]); + const users = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]); + const participations = React.useMemo( + () => adaptParticipations(partQuery.data), + [partQuery.data], + ); + // The read is "settled" once every scope has reached its sync barrier + // (`isSuccess`). A synced-but-empty scope reads `isSuccess` with `data: []` — the + // distinction the auto-seed relies on to tell "still syncing" from "truly empty". + const readReady = + eventQuery.isSuccess && userQuery.isSuccess && partQuery.isSuccess; - /** 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); - }, []); - - // IDENTITY SWITCH = FRESH SESSION (isolation). The by-need read set - // (publicDocs/protectedDocs) ACCUMULATES the current identity's own scope docs - // (`listMyEntityDocs(username, …)`) so a just-created doc isn't dropped before - // the re-list. But the shared-wallet stopgap keeps ONE React tree across a faux - // logout + re-login under a DIFFERENT identifier (no page reload — see - // AuthGate/AccountContext), so without a reset the PREVIOUS identity's PROTECTED - // docs (its participations) survive in the new identity's read set and leak - // through the union read: the cap gate cannot filter them when the cap registry - // does not govern that doc THIS session (a doc persisted in a prior run, or a - // fresh load where caps are empty). Treat every identity change as a fresh - // session: drop the accumulated read set (the listing effect rebuilds it bounded - // to the NEW identity), and reset the emulated caps + registry cache so nothing - // from the old identity lingers. Ref-guarded so it fires only on a real change, - // not on the first mount (empty sets already). + // IDENTITY SWITCH = FRESH SESSION (isolation). The shared-wallet stopgap keeps + // ONE React tree across a faux logout + re-login under a DIFFERENT identifier (no + // page reload — see AuthGate/AccountContext). `watchShape` re-resolves its scope + // to the new `getCurrentUser()` on the next container/index push, but the + // emulated caps + registry cache and the app-side owned-events set must be reset + // so nothing from the old identity lingers. Ref-guarded so it fires only on a + // real change, not on the first mount. // Session-local map `${eventId}|${userId}` → the join deposit's uid, so a leave // in the SAME session can carry `regUid` for a precise cancellation. Absent it // (cross-session leave), the owner's materializer falls back to (event, user) @@ -286,158 +276,42 @@ function useNgData(): FestipodDataContextValue { } if (prevOwnerRef.current === username) return; prevOwnerRef.current = username; - // Fresh session for the new identity: clear the previous identity's read set - // and the emulated isolation state, then let the listing effect rebuild. - setPublicDocs([]); - setProtectedDocs([]); + // Fresh session for the new identity: reset the emulated isolation state and + // the owned-events set. `watchShape` re-resolves reads for the new identity on + // its own (scope re-resolution keyed on `getCurrentUser()`). setOwnedEventIds([]); joinUidsRef.current.clear(); resetCaps(); resetRegistryCache(); - setReadTick(t => t + 1); }, [username]); - // 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 { - // 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; - 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])]); - // OPTION B: my OWN public event docs are the events I OWN — the ONLY docs - // whose `participantCount` I may write. Track them so the owner-materializer - // subscribes to their inboxes and materializes deposits onto my own doc. - setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]); - setReadTick(t => t + 1); - } catch (err) { - console.error('[FestipodData] entity-doc listing failed:', err); - } - })(); - return () => { cancelled = true; }; - // `listTick` re-runs the listing after a seed so the freshly-written scope - // index (esp. PROTECTED user docs) is re-read into the read set. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ready, username, listTick]); - - // --- 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([]); - 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 { 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] union read failed:', err); - if (!cancelled) setReadReady(true); - } - })(); - return () => { cancelled = true; }; - }, [ready, allReadDocs, readTick]); - - // --- REACTIVE READS: subscribe the by-need doc set, re-read on any change --- - // P3 (reactive-reads brief §A): the one-shot `readUnion` above stays the reader, - // but it must re-run when a doc changes in ANOTHER session, not only after a local - // mutation. So mount a PER-DOCUMENT subscription (`subscribeDocs`, one `doc_subscribe` - // per NURI, per-doc error isolation — NOT the ORM fan-out that hangs) over the exact - // set the union read reads (`allReadDocs`). On ANY change callback (initial state push - // OR a later broker-synced patch — this session's write or a remote peer's) → `bumpRead()`, - // which re-runs `readEntities(allReadDocs)` so the screens re-render with the new value. - // - // LIFECYCLE / LOOP-AVOIDANCE (brief §A.3): - // • Keyed on a STABLE join of the SORTED NURIs (`readDocKey`), NOT on `allReadDocs`'s - // identity: the effect re-subscribes ONLY when the doc SET genuinely changes. A - // subscription firing → `bumpRead` → `readUnion` → `setEvents/...` does NOT change - // `publicDocs`/`protectedDocs`, so `allReadDocs`'s content (and thus `readDocKey`) - // is unchanged → NO re-subscribe. That breaks the subscribe→read→subscribe loop. - // • `allReadDocs` is derived via `useMemo` (stable content); we further guard the - // effect on the join so an equal set (new array identity, same NURIs) is a no-op. - // • On identity switch, the `prevOwnerRef` reset effect empties `publicDocs`/ - // `protectedDocs` → `readDocKey` becomes '' → this effect's cleanup unsubscribes - // the OLD identity's docs; the listing effect then rebuilds the set for the NEW - // identity → `readDocKey` changes → subscriptions are re-established on the rebuilt - // set. So the reset drives a clean unsubscribe/re-subscribe, no leak across identities. - const readDocKey = React.useMemo( - () => [...allReadDocs].sort().join('|'), - [allReadDocs], - ); - useEffect(() => { - if (!ready) return; - const nuris = readDocKey ? readDocKey.split('|') : []; - if (nuris.length === 0) return; - // One `doc_subscribe` per NURI; any change (local or remote) re-runs the union - // read via bumpRead. The set is fixed for this effect run (keyed on readDocKey), - // so a change never mutates the set → no re-subscribe loop. - const unsubscribe = subscribeDocs(nuris, () => bumpRead()); - return () => unsubscribe(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ready, readDocKey]); - - // --- REACTIVE DISCOVERY: a NEW public event created elsewhere appears w/o reload - - // P3 (brief §A.3): subscribe the global discovery INDEX document (a single doc, so - // immune to the fan-out hang). When a remote session submits a new public event, the - // index doc gets a patch → `relist()` re-runs the listing effect (`listMyEntityDocs` - // + `readDiscoveredEvents`), which folds the new event doc into `publicDocs` → it - // enters `allReadDocs` → `readDocKey` changes → the per-doc subscription effect above - // re-mounts and subscribes the new doc individually (per-doc, no fan-out). The lib's - // `watchIndex` is already `doc_subscribe`-based (no polling). Re-subscribes on identity - // switch via `username` (the index is global, but a fresh identity re-establishes it). - useEffect(() => { - if (!ready) return; - const unsubscribe = watchDiscoveredEvents(() => relist()); - return () => unsubscribe(); - }, [ready, username, relist]); - // OPTION B — the set of event docs the CURRENT identity OWNS (its own public // event docs). Each such NURI IS the event `@id` (writeEntity uses the doc NURI // as the subject). The owner-materializer subscribes to each owned event's inbox // and writes `participantCount` on THAT (owned) doc — never on someone else's. const [ownedEventIds, setOwnedEventIds] = useState([]); + // Resolve the CURRENT identity's owned public event docs for the materializer + // ONLY (decoupled from the read — `watchShape` resolves reads itself). Bounded to + // the current account (`listMyEntityDocs(owner, 'public')`, NO cross-account + // fan-out). Runs on (re)login to backfill events owned before this mount; + // `createEvent` appends freshly-created events directly. This is NOT a read path + // (it feeds no `events`/`users`/`participations`), only the owner-count derivation. + useEffect(() => { + if (!ready || !username) return; + let cancelled = false; + (async () => { + try { + const myPublic = await listMyEntityDocs(username, 'public'); + if (cancelled) return; + setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]); + } catch (err) { + console.error('[FestipodData] owned-events resolution failed:', err); + } + })(); + return () => { cancelled = true; }; + }, [ready, username]); + // Not in SHEX shapes yet const [meetingPoints, setMeetingPoints] = useState([]); const [friendships, setFriendships] = useState([]); @@ -456,43 +330,34 @@ 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. 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. + // Dev auto-seed: bootstrap seed data into a genuinely EMPTY wallet. Gated on the + // SDK's `isSuccess` (readReady) — the sync barrier is reached for every scope — + // so an empty set means "synced and truly empty", NOT "still syncing". This + // replaces the old 3s chronometer heuristic (which guessed a sync delay and mis- + // fired a re-seed on the 3rd connect). `isPending` → wait; `isSuccess` + empty + // data → seed. Gated on NODE_ENV so production users see their own (possibly + // empty) wallet. `hasTriedAutoSeed` keeps it single-shot (also suppressed by an + // explicit `loadTestData`). const hasTriedAutoSeed = useRef(false); useEffect(() => { if (process.env.NODE_ENV === 'production') return; if (hasTriedAutoSeed.current) return; if (!ready) return; - const t = setTimeout(() => { - // RE-CHECK inside the timer: an explicit `loadTestData` sets this ref at its - // START, but a timer scheduled BEFORE that call is already pending and would - // otherwise fire a SECOND, racing seed (observed: events double to 10, and - // the two seeds' registerDoc/relist interleave, losing the protected docs). - // Bail if a seed has already been initiated by any path. - if (hasTriedAutoSeed.current) return; - hasTriedAutoSeed.current = true; - const walletHasData = events.length > 0 || users.length > 0; - if (!walletHasData) { - console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…'); - bootstrapWallet(walletHasData, createEntityDoc, username || undefined) - .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)); - // Re-list so the seeded PROTECTED index docs re-enter the read set even - // if a racing render dropped the direct registrations (see loadTestData). - relist(); - }) - .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, events.length, users.length]); + if (!readReady) return; // still syncing — do NOT mistake pending for empty + const walletHasData = events.length > 0 || users.length > 0; + if (walletHasData) { + console.log('[FestipodData] Dev auto-seed: wallet already has data — skip'); + return; + } + // Synced AND empty → a real empty wallet. Seed once. + hasTriedAutoSeed.current = true; + console.log('[FestipodData] Dev auto-seed: wallet empty (synced), bootstrapping…'); + bootstrapWallet(false, createEntityDoc, username || undefined) + .catch(err => console.error('[FestipodData] Auto-seed failed:', err)); + // The reactive `watchShape` reads pick the seeded per-entity docs up on their + // own (each createEntityDoc appends to the scope index → the container-index + // subscription re-resolves → the new docs enter the read). No registerDoc/relist. + }, [ready, readReady, events.length, users.length, username]); // --- Derived --- // Resolve current user from the chosen account username (the perceived login); @@ -522,13 +387,12 @@ function useNgData(): FestipodDataContextValue { // This is what makes the count CORRECT and reactive WITHOUT any non-owner ever // writing the event doc: the joiner only deposits; the owner counts. // - // Reactive, no polling: subscribe the inbox document via `inbox.watch` (now a - // `doc_subscribe` push in the lib — brief §A.4, single doc so immune to the ORM - // fan-out hang). Today all events share ONE inbox anchor (`hostInboxNuri` + // Reactive, no polling: subscribe the inbox document via `inbox.watch` (a + // `doc_subscribe` push). Today all events share ONE inbox anchor (`hostInboxNuri` // ignores the eventId → `resolveInboxAnchor()`), so ONE subscription serves all // my owned events; each push re-materializes every owned event from the full // deposit list. At per-event-inbox migration this fans to one watch per owned - // event (still one doc each — no fan-out). + // event (still one doc each). // // IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct // active registrations (`materializeAttendance`: distinct join uids MINUS @@ -578,8 +442,10 @@ function useNgData(): FestipodDataContextValue { const nextCount = active.length; // no host baseline (creator not auto-in) if (materializedCountRef.current.get(evId) !== nextCount) { materializedCountRef.current.set(evId, nextCount); + // The write lands on the owned event doc, which `watchShape('public')` + // already subscribes → the reactive read re-renders the new count on + // the broker push (no manual re-query). await updateEntityField(evId, evId, 'participantCount', int(nextCount)) - .then(() => { if (!cancelled) bumpRead(); }) .catch(err => { // Revert the memo so a transient write failure retries next push. materializedCountRef.current.delete(evId); @@ -659,10 +525,10 @@ function useNgData(): FestipodDataContextValue { // --- Mutations (NG) --- // Each entity is written as its OWN document, created via the SDK in its scope - // (`createEntityDoc(scope)`) — never a store-level document. The new document's - // NURI is the entity's `@graph`, and it joins the scope's live subscription set - // immediately (registerDoc) so the entity is visible right away. The SDK - // declares the per-document ReadCap policy on create (public / protected / + // (`createEntityDoc(scope)`) — never a store-level document. Creating a doc + // appends its NURI to the scope index, which `watchShape` subscribes; the new + // entity enters the reactive read on the resulting push (no manual registration). + // The SDK declares the per-document ReadCap policy on create (public / protected / // private) — the app carries no access logic. const createEvent = useCallback(async (event: Omit): Promise => { @@ -675,9 +541,9 @@ function useNgData(): FestipodDataContextValue { // Create the event's OWN document in the PUBLIC scope (one doc per entity), // then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via // the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed - // per-entity doc against the real broker. Register the doc so the reactive - // read (`useShape({ graphs })`) picks the event up. The written subject IRI is - // the event's `@id`. + // per-entity doc against the real broker. The doc's NURI is appended to the + // public scope index, which `watchShape('public')` subscribes → the event + // enters the reactive read on the push. The written subject IRI is the `@id`. const eventGraph = await createEntityDoc(owner, 'public'); const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, { title: str(event.title), description: str(event.description), date: str(event.date), @@ -688,7 +554,6 @@ function useNgData(): FestipodDataContextValue { participantCount: int(event.participantCount || 0), coverImage: str(event.coverImage), hostName: str(event.hostName), hostInitials: str(event.hostInitials), }); - registerDoc('public', eventGraph); // OPTION B: this event's doc is MINE (I just created it), so track it as owned // → the owner-materializer subscribes to its inbox and maintains its count. setOwnedEventIds(prev => (prev.includes(eventGraph) ? prev : [...prev, eventGraph])); @@ -713,14 +578,14 @@ function useNgData(): FestipodDataContextValue { ).catch(err => console.error('[FestipodData] submit event to index failed:', err)); } return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` }; - }, [currentUserId, username, registerDoc]); + }, [currentUserId, username]); const updateEvent = useCallback(async (id: string, updates: Partial) => { console.log('[FestipodData] updateEvent (NG):', id, updates); // 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. + // field DIRECTLY via SPARQL (the durable write); `watchShape` re-reads on the + // resulting broker push (the doc is already subscribed) — no manual re-query. const graph = id; const persists: Promise[] = []; if (updates.participantCount !== undefined) { @@ -732,8 +597,7 @@ function useNgData(): FestipodDataContextValue { 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; @@ -758,18 +622,19 @@ function useNgData(): FestipodDataContextValue { } // 1) Persist the Participation as its OWN document in the PROTECTED scope // (one doc per entity). Owner = the account username (setCurrentUser key). - // The new doc joins the protected subscription set immediately (reactivity). + // Its NURI is appended to the protected scope index, which + // `watchShape('protected')` subscribes → the participation enters the + // reactive read on the push. const owner = username || uid || 'anon'; const partGraph = await createEntityDoc(owner, 'protected'); // WRITE the participation RDF DIRECTLY into its own document (writeEntity) — // not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed - // per-entity doc against the real broker). Register the doc for the reactive - // read. The written subject is the participation's `@id` (its own graph is - // partGraph, used later by the authoritative delete). + // per-entity doc against the real broker). The written subject is the + // participation's `@id` (its own graph is partGraph, used later by the + // authoritative delete). await writeEntity(partGraph, ENTITY_TYPE.participation, { event: iri(eventId), user: iri(uid), isConfirmed: bool(true), }); - registerDoc('protected', partGraph); // OPTION B (brief §B): the joiner does NOT write `participantCount` on the // EVENT doc — that doc belongs to the OWNER, and a non-owner write there is an // isolation violation (NextGraph write is membership-bound; there is no append @@ -799,7 +664,6 @@ function useNgData(): FestipodDataContextValue { // doc per entity). Best-effort — the inbox materialization is the source of // truth; this direct write only pre-warms the reactive read. const notifGraph = await createEntityDoc(owner, 'protected'); - registerDoc('protected', notifGraph); await insertNotification(notifGraph, notif).catch(() => { /* data-level best-effort */ }); // Surface immediately in reactive state (materialization also refreshes it). // Use the stable per-deposit uid for the id (F5 dedup) so it matches the @@ -808,8 +672,7 @@ function useNgData(): FestipodDataContextValue { } catch (err) { console.error('[FestipodData] joinEvent inbox/notify failed:', err); } - bumpRead(); - }, [events, currentUserId, username, registerDoc, bumpRead]); + }, [events, currentUserId, username]); const leaveEvent = useCallback(async (eventId: string, userId?: string) => { const uid = userId || currentUserId; @@ -860,11 +723,11 @@ function useNgData(): FestipodDataContextValue { } catch (err) { console.error('[FestipodData] leaveEvent inbox deposit failed:', err); } - // Re-query the union read (the participation leaves the set on re-read; - // `isParticipating` reflects it). The count itself follows the owner's - // materialization of the leave marker (reactive, cross-session). - bumpRead(); - }, [participations, events, currentUserId, username, bumpRead]); + // The participation doc is subscribed by `watchShape('protected')`; the SPARQL + // DELETE pushes → the reactive read drops it (`isParticipating` reflects it). + // The count itself follows the owner's materialization of the leave marker + // (reactive, cross-session). + }, [participations, events, currentUserId, username]); const addMeetingPoint = useCallback((mp: Omit) => { setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); @@ -893,29 +756,21 @@ function useNgData(): FestipodDataContextValue { 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]); + }, [currentUser, users]); const loadTestData = useCallback(async (): Promise => { console.log('[FestipodData] loadTestData (NG)'); // An EXPLICIT load is authoritative — SUPPRESS the dev auto-seed so only ONE - // seed runs. Without this the two paths race: the auto-seed's 3s-timer effect - // captured a render where events/users were still 0, so it ALSO fires a second - // `bootstrapWallet`, doubling every write (events:10 = 5×2) and interleaving - // the two seeds' registerDoc calls. Marking the auto-seed as already-tried at - // the START (before the awaited seed) closes that window: the timer either - // already fired the guard, or its callback bails on `hasTriedAutoSeed.current`. + // seed runs (marking the guard at the START, before the awaited seed, closes + // the window where the auto-seed effect could also fire on a still-empty read). hasTriedAutoSeed.current = true; const walletHasData = events.length > 0 || users.length > 0; const result = await bootstrapWallet(walletHasData, createEntityDoc, username || undefined); - result.createdDocs.public.forEach(d => registerDoc('public', d)); - result.createdDocs.protected.forEach(d => registerDoc('protected', d)); - // Re-list AFTER the seed: the seed just wrote the protected scope index, so a - // re-run of the listing effect re-reads those user docs into the read set even - // if the direct `registerDoc` state updates were lost to a racing render. - relist(); + // The seeded per-entity docs are appended to their scope indices, which + // `watchShape` subscribes → they enter the reactive reads on the push. No + // manual registration / re-list. return result; - }, [events.length, users.length, registerDoc, relist, username]); + }, [events.length, users.length, username]); return { currentUserId, currentUser, diff --git a/src/shared/data/readEntities.ts b/src/shared/data/readEntities.ts deleted file mode 100644 index 1f1a806..0000000 --- a/src/shared/data/readEntities.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * readEntities — the READ side of the one-document-per-entity model, mapping the - * 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). 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 - * 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'; -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[]; -} - -/** - * 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 - * 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/shapeAdapters.ts b/src/shared/data/shapeAdapters.ts new file mode 100644 index 0000000..1c97a10 --- /dev/null +++ b/src/shared/data/shapeAdapters.ts @@ -0,0 +1,94 @@ +/** + * shapeAdapters — the Festipod DOMAIN mapping from the SDK's generic per-subject + * property bags (`UnionSubject`, the shape of what `watchShape` yields) to the + * app's `Fp*` entity types. This is the READ-side domain glue: `watchShape` is + * non-domain (it filters a scope's docs by a SHEX `@type` and returns the raw + * property bags); the app owns the interpretation of the `fp:` predicates. + * + * The mapping logic lives HERE (in the app), not in the SDK — the SDK stays a + * finished NextGraph surface that knows nothing of Festipod's fields. This file + * previously lived in `readEntities.ts` alongside a bespoke `readModel.readUnion` + * call; the read machinery is now the SDK's `watchShape`, so only the domain + * mapping remains, extracted here. + */ + +import type { UnionSubject } from '@ng-eventually/client'; +import type { FpEventData, FpUserData, FpParticipationData } from './types'; + +const FP = 'http://festipod.org/'; + +/** First object value of a `fp:` 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'; +} + +/** Map a generic subject (Event shape) to `FpEventData`. */ +export function adaptEvent(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, + }; +} + +/** Map a generic subject (UserProfile shape) to `FpUserData`. */ +export function adaptUser(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, + }; +} + +/** + * Map every Event-shape subject to `FpEventData`. + */ +export function adaptEvents(subjects: UnionSubject[]): FpEventData[] { + return subjects.map(adaptEvent); +} + +/** + * Map every UserProfile-shape subject to `FpUserData`. + */ +export function adaptUsers(subjects: UnionSubject[]): FpUserData[] { + return subjects.map(adaptUser); +} + +/** + * Map every Participation-shape subject to `FpParticipationData`, DROPPING any + * participation that carries no `fp:user` (the SHEX `fp:user` is mandatory — a + * participation without a user principal is malformed and must never round-trip; + * this matches the historical ORM/read behavior). + */ +export function adaptParticipations(subjects: UnionSubject[]): FpParticipationData[] { + const out: FpParticipationData[] = []; + for (const s of subjects) { + const userId = one(s, 'user'); + if (!userId) continue; // fp:user mandatory — drop malformed participations + out.push({ + id: s.subject, + eventId: one(s, 'event'), + userId, + isConfirmed: boolOf(s, 'isConfirmed'), + }); + } + return out; +} diff --git a/src/shared/data/useShapeQuery.ts b/src/shared/data/useShapeQuery.ts new file mode 100644 index 0000000..a234e04 --- /dev/null +++ b/src/shared/data/useShapeQuery.ts @@ -0,0 +1,50 @@ +/** + * useShapeQuery — the React binding over the SDK's `watchShape` observable. This + * is the ONLY place the app couples React to the reactive-read surface: it wraps + * the observable with `useSyncExternalStore`, so a screen (or the data context) + * reads a live `ShapeQuery` that re-renders on every broker push — no polling, + * no bespoke re-query machinery. + * + * `watchShape` is an OBSERVABLE (the lib has no React dependency), useQuery-shaped: + * { getSnapshot(): ShapeQuery; subscribe(onChange): () => void; refetch() } + * `getSnapshot` returns a STABLE reference until a real state transition, which is + * exactly what `useSyncExternalStore` needs to avoid an infinite render loop. + * + * The observable is MEMOIZED by (shapeType, scope): re-creating it every render + * would tear down and re-establish the underlying doc subscriptions on each + * render. We key the memo on the scope plus the shape's identity (its `@type` / + * schema-shape) so a stable shapeType yields a stable observable. + */ + +import { useMemo, useSyncExternalStore } from 'react'; +import { watchShape, type ShapeQuery, type ShapeObservable, type UnionSubject } from '@ng-eventually/client'; + +type Scope = 'public' | 'protected' | 'private'; + +/** + * Bind a reactive, `useQuery`-shaped read over one SHEX `shapeType` in one logical + * `scope`. Returns the live `ShapeQuery` (`{ data, isPending, isSuccess, + * isError, error }`). `data` is ALWAYS an array (never `undefined`); a + * synced-but-empty scope reads `{ data: [], isPending: false, isSuccess: true }`. + * + * The observable is memoized by (shape identity, scope), so it is created ONCE per + * (shape, scope) and reused across renders — its doc subscriptions are not churned. + */ +export function useShapeQuery( + shapeType: unknown, + scope: Scope, +): ShapeQuery { + // Derive a stable memo key from the shape's identity. A generated SHEX ShapeType + // pins its `@type` on `st.shape`; combined with the scope this uniquely keys the + // observable so a stable shapeType/scope pair reuses one observable. + const shapeKey = + (shapeType as { shape?: string } | undefined)?.shape ?? String(shapeType); + + const obs: ShapeObservable = useMemo( + () => watchShape(shapeType, scope), + // eslint-disable-next-line react-hooks/exhaustive-deps + [shapeKey, scope], + ); + + return useSyncExternalStore(obs.subscribe, obs.getSnapshot); +} diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index c470034..5f735a0 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -139,18 +139,14 @@ 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 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 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. + // and READS reactively through the SDK's `watchShape(shape, scope)` surface + // (bound with `useShapeQuery`). The step-facing `events/users/participations` + // + mutations/queries delegate to the 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. // Always read the LIVE appData (via the ref) — a captured snapshot goes stale - // after loadTestData/registerDoc re-renders (see appDataRef above). + // after a seed/reactive re-render (see appDataRef above). const AD = () => appDataRef.current; const eventAdapter = () => AD().events.map(e => ({ '@id': e.id, title: e.title, participantCount: e.participantCount }));