@@ -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/readEntitie s' ;
import { submitEventToIndex } from '../data/discovery' ;
import { useShapeQuery } from '../data/useShapeQuery ' ;
import { adaptEvents , adaptUsers , adaptParticipations } from '../data/shapeAdapter s' ;
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 a uto-seed: wallet empty, bootstrapping…' ) ;
bootstrapWallet ( walletHasData , createEntityDoc , username || undefined )
. then ( ( { createdDocs } ) = > {
// Register the seeded per-entity docs i nto 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] A uto-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 e nter 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 reactiv e
// 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 th e
// 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 reactiv e
// 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 th e
// 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 ,