refactor(app): l'app lit via watchShape (useShapeQuery), plus de machinerie bespoke

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-09 23:46:31 +02:00
parent 2295af610a
commit 38266d96f8
7 changed files with 288 additions and 409 deletions
+127 -272
View File
@@ -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<string[]>([]);
const [protectedDocs, setProtectedDocs] = useState<string[]>([]);
// Re-query signal: bumped after every mutation / doc registration so the union
// read re-runs and picks up the change (there is no reactive union query).
const [readTick, setReadTick] = useState(0);
const bumpRead = useCallback(() => setReadTick(t => t + 1), []);
// 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<string[]>([]),
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<FpEventData[]>([]);
const [users, setUsers] = useState<FpUserData[]>([]);
const [participations, setParticipations] = useState<FpParticipationData[]>([]);
const [readReady, setReadReady] = useState(false);
const allReadDocs = React.useMemo(
() => [...new Set([...publicDocs, ...protectedDocs])],
[publicDocs, protectedDocs],
);
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
try {
const { 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<string[]>([]);
// 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<FpMeetingPointData[]>([]);
const [friendships, setFriendships] = useState<FpFriendshipData[]>([]);
@@ -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<FpEventData, 'id'>): Promise<FpEventData> => {
@@ -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<FpEventData>) => {
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<void>[] = [];
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<FpMeetingPointData, 'id'>) => {
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<BootstrapResult> => {
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,
-119
View File
@@ -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<ReadEntities> {
const subjects = await readModel.readUnion(docs);
const out: ReadEntities = { events: [], users: [], participations: [] };
for (const s of subjects) {
switch (typeOf(s)) {
case TYPE.event:
out.events.push(mapEvent(s));
break;
case TYPE.user:
out.users.push(mapUser(s));
break;
case TYPE.participation: {
const p = mapParticipation(s);
if (p.userId) out.participations.push(p);
break;
}
}
}
return out;
}
+94
View File
@@ -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;
}
+50
View File
@@ -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<T>` 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<T>; 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<T>` (`{ 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<T = UnionSubject>(
shapeType: unknown,
scope: Scope,
): ShapeQuery<T> {
// 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<T> = useMemo(
() => watchShape<T>(shapeType, scope),
// eslint-disable-next-line react-hooks/exhaustive-deps
[shapeKey, scope],
);
return useSyncExternalStore(obs.subscribe, obs.getSnapshot);
}
+7 -11
View File
@@ -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 }));