feat(data): union read model — list via anchorless sparql_query, hang eliminated

Replace the reactive-ORM per-entity fan-out read (which HUNG 75s: orm_start_graph
opened every scope graph and RepoNotFound on any fresh/unsynced doc aborted the
subscription) with the read model:
- readEntities.ts → lib readUnion: resolve the by-need doc set (my own scope docs
  via listMyEntityDocs + public events via the discovery index — NOT all-accounts
  fan-out), then ONE anchorless union sparql_query (GRAPH ?g, VALUES-pinned). Map
  to app types. Re-query on a change signal (no reactive union query).
- countUserParticipations no longer fans out over all accounts (own docs only).
- await loadTestData in the seed step; deleted orphaned useShapeWithDefaults;
  removed the old multistore-stopgap fan-out scenarios; added the read-model-probe.
- Doctrine: rule_document-per-entity read half + _overview rewritten to the union
  model (write half unchanged).

Result: the 75s ORM hang is ELIMINATED (0 hangs; build/tsc/lib-93-tests green;
boundary clean). @data is NOT yet fully green: remaining failures are 90s step
timeouts in the test-harness broker data ops (clearWallet / runUnionProbe / seed)
this run — a harness/broker-op issue, not the read path. To finish separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-05 20:49:01 +02:00
parent eafb4403b9
commit 8bb19b687b
17 changed files with 456 additions and 439 deletions
+157 -211
View File
@@ -27,16 +27,10 @@ import {
import { useNextGraph } from './NextGraphContext';
import { useAccount, normalizeUsername } from './AccountContext';
import { declareConnections } from '@ng-eventually/client/polyfill';
import { listEntityDocs, createEntityDoc } from '../utils/storeRegistry';
import { listMyEntityDocs, createEntityDoc } from '../utils/storeRegistry';
import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
import { readEntities } from '../data/readEntities';
import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
// ============================================================================
@@ -76,7 +70,7 @@ interface FestipodDataContextValue {
leaveEvent(eventId: string, userId?: string): Promise<void> | void;
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
addFriend(friendId: string): void;
updateProfile(updates: Partial<FpUserData>): void;
updateProfile(updates: Partial<FpUserData>): void | Promise<void>;
loadTestData(): Promise<BootstrapResult>;
}
@@ -91,42 +85,7 @@ function nextId(prefix: string): string {
return `${prefix}-${++idCounter}`;
}
function findNg<T extends { "@id": string }>(set: Set<T>, predicate: (item: T) => boolean): T | undefined {
for (const item of set) {
if (predicate(item)) return item;
}
return undefined;
}
// NG shape → app type mappers
const mapEvent = (e: FpEvent): FpEventData => ({
id: e["@id"],
title: e.title,
description: e.description || '',
date: e.date,
location: e.location,
distance: e.distance,
participantCount: e.participantCount,
coverImage: e.coverImage,
hostName: e.hostName,
hostInitials: e.hostInitials,
});
const mapUser = (u: FpUserProfile): FpUserData => ({
id: u["@id"],
name: u.name,
initials: u.initials,
username: u.username,
role: u.role,
isPublic: u.isPublic,
});
const mapParticipation = (p: FpParticipation): FpParticipationData => ({
id: p["@id"],
eventId: p.event,
userId: p.user,
isConfirmed: p.isConfirmed,
});
// NG shape → app type mapping now lives in `../data/readEntities` (union read).
// ============================================================================
// Shared queries builder — same logic for both local and NG modes
@@ -253,110 +212,110 @@ function useNgData(): FestipodDataContextValue {
const { username } = useAccount();
// The app speaks ONLY in logical scopes — it holds no store id and builds no
// `did:ng:${…}` NURI. It creates ONE document PER ENTITY in its scope
// (`createEntityDoc(scope)`, the SDK create) and reads a scope by subscribing
// to the set of its per-entity documents (`listEntityDocs(scope)`). The SDK
// owns the physical placement AND the per-document isolation — the app carries
// no access logic (see rule_document-per-entity, knowledge_trust-model).
// (`createEntityDoc(scope)`, the SDK create). It READS by NEED: it asks the SDK
// for the document NURIs it may read (its own scope docs via `listEntityDocs`,
// the discovery index via `readDiscoveredEvents`) and hands them to the SDK's
// UNION READ (`readEntities` → `readModel.readUnion`) — the SDK opens/syncs the
// docs and runs ONE anchorless union `sparql_query`. There is NO reactive union
// query, so reactivity = RE-QUERY on a change signal (see `bumpRead`). This
// replaces the OLD reactive-ORM fan-out (`useShape({ graphs })`), which HUNG
// ~75s on a per-entity fan-out (see readEntities.ts, SDK docs/read-model.md).
// `ready` gates the effects on the session.
const ready = !!session;
// Per-entity document sets, by scope (the SDK create appends here immediately
// so a freshly-created entity is visible without waiting for a re-list). Events
// → public; profiles + participations → protected. Seeded from listEntityDocs.
// The by-need document set to READ (union), by scope. Events → public (my own +
// the index-discovered ones); profiles + participations → protected (my own).
// A freshly-created entity's doc is registered here immediately (reactivity).
const [publicDocs, setPublicDocs] = useState<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), []);
/** Add a freshly-created entity document to its scope's live subscription set
* (reactivity: the new doc joins the useShape graphs immediately). */
/** Add a freshly-created entity document to its scope's read set AND trigger a
* re-query (reactivity: the new doc joins the union read immediately). */
const registerDoc = useCallback((scope: 'public' | 'protected', nuri: string) => {
const setter = scope === 'public' ? setPublicDocs : setProtectedDocs;
setter(prev => (prev.includes(nuri) ? prev : [...prev, nuri]));
setReadTick(t => t + 1);
}, []);
// Resolve the by-need doc NURIs — READ BY NEED, never an all-accounts fan-out
// (the OLD `listEntityDocs('public'|'protected')` enumerated EVERY account and
// tried to open/sync other accounts' unsynced docs → HANG ~75s; see
// read-model.md). Two bounded sources:
// • PUBLIC events (all) → the GLOBAL DISCOVERY INDEX only (`readDiscoveredEvents`,
// the ONE sanctioned enumeration): it yields the public event-doc NURIs to
// open/sync. No account fan-out for events.
// • MY OWN entities (my profile, my participations) → MY OWN account's scope
// docs only (`listMyEntityDocs(username, scope)`, bounded to the current
// account — NO cross-account enumeration). Freshly-created docs are already
// tracked locally via `registerDoc`, so this only backfills on (re)login.
// The app never fans out an ORM subscription; it collects NURIs to hand to the
// union read. Union with locally-registered docs so a just-created doc isn't
// dropped before the re-list catches up.
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
try {
const [pub, prot] = await Promise.all([
listEntityDocs('public'),
listEntityDocs('protected'),
// Owner key = the account username (what `createEntityDoc`/`setCurrentUser`
// key on). No login (dev/demo) → no "my" docs to backfill; the discovery
// index still yields public events.
const owner = username;
const [myProtected, discovered] = await Promise.all([
owner ? listMyEntityDocs(owner, 'protected') : Promise.resolve<string[]>([]),
readDiscoveredEvents(),
]);
if (cancelled) return;
// Union with any docs already registered locally (don't drop a doc the
// user just created before the re-list caught up).
setPublicDocs(prev => [...new Set([...prev, ...pub])]);
setProtectedDocs(prev => [...new Set([...prev, ...prot])]);
const discDocs = discovered.map(r => r.doc).filter(Boolean) as string[];
// My own public event docs (bounded to my account) so a host reads back
// their own events even before the discovery index materializes.
const myPublic = owner ? await listMyEntityDocs(owner, 'public') : [];
if (cancelled) return;
setPublicDocs(prev => [...new Set([...prev, ...myPublic, ...discDocs])]);
setProtectedDocs(prev => [...new Set([...prev, ...myProtected])]);
setReadTick(t => t + 1);
} catch (err) {
console.error('[FestipodData] entity-doc listing failed:', err);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, username]);
// --- Public discovery (T03.c): read the GLOBAL INDEX ----------------------
// Discovery is "read the global index" (the SDK read). The app asks the SDK
// for the discovered public event references and subscribes to the documents
// they point at — a user sees other accounts' public events *without a
// connection* (Alice sees Bob's public event even if they're not friends).
// The SDK owns the index entirely (how it's stored, who hosts it, how a
// submission is materialized); the app holds NO index document NURI / store id
// and never fans out over accounts. Making an event discoverable is the
// symmetric SDK act on createEvent (`submitEventToIndex`).
//
// Additive & non-regressive: runs in BOTH modes but only contributes when the
// index has entries. In the default path the index is empty (nothing was ever
// submitted → []), so the discovery shape stays empty and the base `events`
// read is untouched. When events HAVE been submitted, discovery unions them in.
const [discoveryGraphs, setDiscoveryGraphs] = useState<string[]>([]);
// --- The UNION READ (replaces the reactive ORM fan-out) -------------------
// Open/sync the by-need docs and run ONE anchorless union query via the SDK,
// mapped to app types. Re-runs whenever the doc set or the re-query tick
// changes. `readReady` flips true after the first read so the empty state
// isn't mistaken for "wallet empty" by the auto-seed.
const [events, setEvents] = useState<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 refs = await readDiscoveredEvents(); // reads the SDK global index
const docs = [...new Set(refs.map(r => r.doc).filter(Boolean))];
if (!cancelled) setDiscoveryGraphs(docs);
const { events: ev, users: us, participations: pa } = await readEntities(allReadDocs);
if (cancelled) return;
setEvents(ev);
setUsers(us);
setParticipations(pa);
setReadReady(true);
} catch (err) {
console.error('[FestipodData] index-based discovery failed:', err);
console.error('[FestipodData] union read failed:', err);
if (!cancelled) setReadReady(true);
}
})();
return () => { cancelled = true; };
}, [ready, username]);
const discoveryScope: ShapeScope = discoveryGraphs.length ? { graphs: discoveryGraphs } : undefined;
// Scope per entity: events read the PUBLIC scope, profiles + participations the
// PROTECTED scope. Each scope subscribes to the SET of its per-entity documents
// (opaque SDK NURIs — the app never sees a store id). The SDK's per-document
// ReadCap filter returns only the documents the current identity may read.
const publicScope: ShapeScope = publicDocs.length ? { graphs: publicDocs } : undefined;
const protectedScope: ShapeScope = protectedDocs.length ? { graphs: protectedDocs } : undefined;
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyEvents: FpEventData[] = [];
const emptyUsers: FpUserData[] = [];
const emptyParticipations: FpParticipationData[] = [];
const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
// Cross-account public discovery: read the discovered documents as events.
const discoveryShape = useShapeWithDefaults(FpEventShapeType, discoveryScope, emptyEvents, mapEvent, true);
// Union the current-scope events with the cross-account discovered ones,
// de-duplicated by id (an event already read via publicScope must not appear
// twice). Discovery is purely additive — it never hides an existing event.
const events = React.useMemo(() => {
const seen = new Set(eventsShape.items.map(e => e.id));
const merged = [...eventsShape.items];
for (const e of discoveryShape.items) {
if (e.id && !seen.has(e.id)) { seen.add(e.id); merged.push(e); }
}
return merged;
}, [eventsShape.items, discoveryShape.items]);
const users = usersShape.items;
const participations = participationsShape.items;
}, [ready, allReadDocs, readTick]);
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
@@ -377,9 +336,10 @@ function useNgData(): FestipodDataContextValue {
}, [events.length, selectedEventId]);
// Dev auto-seed: if the wallet is still empty 3s after the session is ready,
// bootstrap with seed data. `bootstrapWallet()` self-checks (ngSet.size > 0
// → skip), so this is safe even if shapes finish hydrating after the timer.
// Gated on NODE_ENV so production users see their own (possibly empty) wallet.
// bootstrap with seed data. Guarded on the UNION READ result (events/users
// empty AND the first read has completed), so a slow first read isn't mistaken
// for an empty wallet. Gated on NODE_ENV so production users see their own
// (possibly empty) wallet.
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
@@ -387,24 +347,22 @@ function useNgData(): FestipodDataContextValue {
if (!ready) return;
const t = setTimeout(() => {
hasTriedAutoSeed.current = true;
if (eventsShape.ngSet.size === 0 && usersShape.ngSet.size === 0) {
const walletHasData = events.length > 0 || users.length > 0;
if (!walletHasData) {
console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…');
bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
createEntityDoc,
).then(({ createdDocs }) => {
// Register the seeded per-entity docs into the live subscription sets.
createdDocs.public.forEach(d => registerDoc('public', d));
createdDocs.protected.forEach(d => registerDoc('protected', d));
}).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
bootstrapWallet(walletHasData, createEntityDoc)
.then(({ createdDocs }) => {
// Register the seeded per-entity docs into the read set (+ re-query).
createdDocs.public.forEach(d => registerDoc('public', d));
createdDocs.protected.forEach(d => registerDoc('protected', d));
})
.catch(err => console.error('[FestipodData] Auto-seed failed:', err));
} else {
console.log('[FestipodData] Dev auto-seed: wallet already has data — skip');
}
}, 3000);
return () => clearTimeout(t);
}, [ready, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
}, [ready, events.length, users.length]);
// --- Derived ---
// Resolve current user from the chosen account username (the perceived login);
@@ -543,7 +501,7 @@ function useNgData(): FestipodDataContextValue {
registerDoc('protected', partGraph);
setSelectedEventId(eventId);
}
const addedEvent = { "@id": eventId, title: event.title } as FpEvent;
const addedEvent = { "@id": eventId, title: event.title };
// Make the PUBLIC event discoverable: submit its reference to the SDK global
// discovery index (an SDK act — the app holds no index/store id). The SDK
// enforces public-only: passing the event's own document lets it refuse a
@@ -560,30 +518,27 @@ function useNgData(): FestipodDataContextValue {
).catch(err => console.error('[FestipodData] submit event to index failed:', err));
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [eventsShape.ngSet, participationsShape.ngSet, currentUserId, username, registerDoc]);
}, [currentUserId, username, registerDoc]);
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === id);
// The event's `@id` is its own document NURI (one entity = one document); use
// it as both the write graph and the subject.
const graph = ngEvent?.["@graph"] || id;
if (ngEvent) {
if (updates.title !== undefined) ngEvent.title = updates.title;
if (updates.description !== undefined) ngEvent.description = updates.description;
if (updates.date !== undefined) ngEvent.date = updates.date;
if (updates.location !== undefined) ngEvent.location = updates.location;
if (updates.distance !== undefined) ngEvent.distance = updates.distance;
if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount;
// The event's `@id` IS its own document NURI (one entity = one document), so
// it is both the write graph and the subject. Persist each provided mutable
// field DIRECTLY via SPARQL (the durable write) then re-query so the union
// read reflects it — there is no reactive set to mutate in place anymore.
const graph = id;
const persists: Promise<void>[] = [];
if (updates.participantCount !== undefined) {
persists.push(updateEntityField(graph, id, 'participantCount', int(updates.participantCount)));
}
// Persist `participantCount` DURABLY (a mutable field). An in-place ORM
// mutation is local only — a later reactive re-sync from the broker reverts it
// to the stored value; the SPARQL update makes it stick and the re-read match.
if (updates.participantCount !== undefined && graph) {
await updateEntityField(graph, id, 'participantCount', int(updates.participantCount))
.catch(err => console.error('[FestipodData] persist participantCount failed:', err));
}
}, [eventsShape.ngSet]);
if (updates.title !== undefined) persists.push(updateEntityField(graph, id, 'title', str(updates.title)));
if (updates.description !== undefined) persists.push(updateEntityField(graph, id, 'description', str(updates.description)));
if (updates.date !== undefined) persists.push(updateEntityField(graph, id, 'date', str(updates.date)));
if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location)));
if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance)));
await Promise.all(persists).catch(err => console.error('[FestipodData] persist event update failed:', err));
bumpRead();
}, [bumpRead]);
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
@@ -601,7 +556,7 @@ function useNgData(): FestipodDataContextValue {
// The reactive participation set can lag a just-written participation, so a
// second join checking only the set would write a DUPLICATE (breaking "exactly
// one participation"). The broker query sees the real state regardless of lag.
const already = await countUserParticipations(eventId, uid).catch(() => 0);
const already = await countUserParticipations(username || uid || 'anon', eventId, uid).catch(() => 0);
if (already > 0) {
console.log('[FestipodData] Already participating (broker-confirmed), skipping');
return;
@@ -620,13 +575,13 @@ function useNgData(): FestipodDataContextValue {
event: iri(eventId), user: iri(uid), isConfirmed: bool(true),
});
registerDoc('protected', partGraph);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
const next = ngEvent.participantCount + 1;
ngEvent.participantCount = next;
// Persist the count durably (see updateEvent) so a reactive re-sync keeps it.
// Fire-and-forget: don't block the join's critical path on this write.
updateEntityField(ngEvent["@graph"] || eventId, eventId, 'participantCount', int(next))
// Bump the event's participantCount durably. The event `@id` is its own doc
// NURI (graph = subject). Read the current count from the union-read `events`;
// persist +1 via SPARQL so the re-query reflects it. Fire-and-forget.
const curEvent = events.find(e => e.id === eventId);
if (curEvent) {
const next = curEvent.participantCount + 1;
updateEntityField(eventId, eventId, 'participantCount', int(next))
.catch(err => console.error('[FestipodData] persist participantCount (join) failed:', err));
}
// 2) Notify the host: deposit into the event/host inbox via the GENERIC lib
@@ -655,38 +610,34 @@ function useNgData(): FestipodDataContextValue {
} catch (err) {
console.error('[FestipodData] joinEvent inbox/notify failed:', err);
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId, username, registerDoc]);
bumpRead();
}, [events, currentUserId, username, registerDoc, bumpRead]);
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid);
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (!ngPart) return;
// DÉSINSCRIPTION FIX (caveat_participation-deletion): `ngSet.delete()` alone
// triggers reactivity but the item RESURRECTS via broker sync. The AUTHORITATIVE
// deletion is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate),
// which removes the Participation server-side so it does NOT come back after
// re-sync. The delete targets the participation's own @graph (the doc it lives
// in) — the participation's OWN per-entity document — and is identified by the
// participation's OWN subject IRI (ngPart["@id"]), not a string-match on the
// object IRIs (the F2 bug: object string-match could hit 0 rows on IRI-form
// drift → silent no-op → resurrection).
const graphNuri = ngPart["@graph"];
const subjectIri = ngPart["@id"];
// Find the participation in the union-read set. Each participation is its OWN
// document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH
// the subject IRI AND the graph NURI it lives in.
const part = participations.find(p => p.eventId === eventId && p.userId === uid);
if (!part) return;
// DÉSINSCRIPTION FIX (caveat_participation-deletion): the AUTHORITATIVE deletion
// is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), which
// removes the Participation server-side so it does NOT resurrect after re-sync.
// The delete targets the participation's own document (part.id) and is
// identified by its OWN subject IRI (part.id), not a string-match on object IRIs.
const graphNuri = part.id;
const subjectIri = part.id;
let result;
try {
result = await deleteParticipation(graphNuri, eventId, uid, subjectIri);
} catch (err) {
console.error('[FestipodData] SPARQL DELETE participation failed:', err);
// Do NOT flip the UI: the broker still holds the triple, so flipping the
// reactive set would resurrect on the next sync. Surface the failure.
throw err instanceof Error ? err : new Error(String(err));
}
// AUTHORITATIVE verification: only flip the UI once the broker RE-QUERY confirms
// the participation is actually gone (remaining === 0). If the delete matched
// nothing (weak match / IRI-form drift / wrong graph), remaining stays > 0 —
// flipping the reactive set here would show "not participating" while the broker
// still holds the triple, and it would resurrect after re-sync. Surface instead.
// AUTHORITATIVE verification: only proceed once the broker RE-QUERY confirms
// the participation is gone (remaining === 0). If the delete matched nothing,
// surface it rather than falsely flip the UI (it would resurrect on re-sync).
if (result.remaining > 0) {
const msg = `[FestipodData] leaveEvent: SPARQL delete removed nothing ` +
`(before=${result.before}, remaining=${result.remaining}, bySubject=${result.bySubject}) ` +
@@ -694,21 +645,17 @@ function useNgData(): FestipodDataContextValue {
console.error(msg);
throw new Error(msg);
}
// Confirmed gone server-side → reflect it in the reactive UI. This is the LOCAL
// reflection of the authoritative delete (not a second persistence path): the
// button flips to not-registered and STAYS so — the broker no longer holds the
// triple to resurrect. `isParticipating` reads this set, so the item must leave
// it for the UI to update immediately.
participationsShape.ngSet.delete(ngPart);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
const next = Math.max(0, ngEvent.participantCount - 1);
ngEvent.participantCount = next;
// Fire-and-forget (don't block the leave's critical path).
updateEntityField(ngEvent["@graph"] || eventId, eventId, 'participantCount', int(next))
// Confirmed gone server-side → persist the event's participantCount decrement
// durably, then re-query the union read (the participation leaves the set on
// re-read; `isParticipating` reflects it).
const curEvent = events.find(e => e.id === eventId);
if (curEvent) {
const next = Math.max(0, curEvent.participantCount - 1);
updateEntityField(eventId, eventId, 'participantCount', int(next))
.catch(err => console.error('[FestipodData] persist participantCount (leave) failed:', err));
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
bumpRead();
}, [participations, events, currentUserId, bumpRead]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
@@ -724,31 +671,30 @@ function useNgData(): FestipodDataContextValue {
});
}, [currentUserId]);
const updateProfile = useCallback((updates: Partial<FpUserData>) => {
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
console.log('[FestipodData] updateProfile (NG):', updates);
const ngUser = findNg(usersShape.ngSet as any as Set<FpUserProfile>, u => u.username === '@mariedupont')
|| [...usersShape.ngSet][0];
if (ngUser) {
if (updates.name !== undefined) ngUser.name = updates.name;
if (updates.initials !== undefined) ngUser.initials = updates.initials;
if (updates.username !== undefined) ngUser.username = updates.username;
if (updates.role !== undefined) ngUser.role = updates.role;
if (updates.isPublic !== undefined) ngUser.isPublic = updates.isPublic;
}
}, [usersShape.ngSet]);
// The current user's profile is its own document (subject IRI = doc NURI).
const target = currentUser ?? users[0];
if (!target) return;
const graph = target.id;
const persists: Promise<void>[] = [];
if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name)));
if (updates.initials !== undefined) persists.push(updateEntityField(graph, graph, 'initials', str(updates.initials)));
if (updates.username !== undefined) persists.push(updateEntityField(graph, graph, 'username', str(updates.username)));
if (updates.role !== undefined) persists.push(updateEntityField(graph, graph, 'role', str(updates.role)));
if (updates.isPublic !== undefined) persists.push(updateEntityField(graph, graph, 'isPublic', bool(updates.isPublic)));
await Promise.all(persists).catch(err => console.error('[FestipodData] persist profile update failed:', err));
bumpRead();
}, [currentUser, users, bumpRead]);
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (NG)');
const result = await bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
createEntityDoc,
);
const walletHasData = events.length > 0 || users.length > 0;
const result = await bootstrapWallet(walletHasData, createEntityDoc);
result.createdDocs.public.forEach(d => registerDoc('public', d));
result.createdDocs.protected.forEach(d => registerDoc('protected', d));
return result;
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet, registerDoc]);
}, [events.length, users.length, registerDoc]);
return {
currentUserId, currentUser,
+119
View File
@@ -0,0 +1,119 @@
/**
* readEntities — the READ side of the one-document-per-entity model, mapping the
* SDK's union read (`readModel.readUnion`) to app types. This is the LISTING
* path: it asks the SDK to open/sync a set of documents and run ONE anchorless
* union `sparql_query`, then maps each returned subject's property bag to the
* corresponding Fp* type.
*
* WHY this replaces the ORM `useShape({ graphs })` fan-out: subscribing a fan-out
* of per-entity documents through the reactive ORM HANGS (~75s) — a freshly
* created / not-yet-synced doc makes `RepoNotFound` abort the whole subscription
* (see the SDK's docs/read-model.md, verified on the real broker in T03.k). The
* union query is one-shot, so there is no reactive union: reactivity = RE-QUERY on
* a change signal (a doc was created / registered).
*
* The app asks the SDK by NEED — it passes the document NURIs to read (from the
* discovery index for public events, or its own scope docs for my-entities) and
* never builds a store id or picks the union-vs-anchor mode. Placement + the
* union mechanism live in the SDK (read-model.ts); this file is only the Festipod
* domain mapping (fp: predicates → Fp* fields).
*/
import { readModel } from '@ng-eventually/client';
import type { UnionSubject } from '@ng-eventually/client';
import type { FpEventData, FpUserData, FpParticipationData } from './types';
const FP = 'http://festipod.org/';
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
const TYPE = {
event: `${FP}Event`,
user: `${FP}UserProfile`,
participation: `${FP}Participation`,
} as const;
/** First object value of a predicate on a subject (or `fallback`). */
function one(s: UnionSubject, field: string, fallback = ''): string {
return s.props[`${FP}${field}`]?.[0] ?? fallback;
}
function num(s: UnionSubject, field: string, fallback = 0): number {
const v = s.props[`${FP}${field}`]?.[0];
const n = v === undefined ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
}
function boolOf(s: UnionSubject, field: string): boolean {
return (s.props[`${FP}${field}`]?.[0] ?? 'false') === 'true';
}
function typeOf(s: UnionSubject): string | undefined {
return s.props[RDF_TYPE]?.[0];
}
function mapEvent(s: UnionSubject): FpEventData {
return {
id: s.subject,
title: one(s, 'title'),
description: one(s, 'description'),
date: one(s, 'date'),
location: one(s, 'location'),
distance: s.props[`${FP}distance`] ? num(s, 'distance') : undefined,
participantCount: num(s, 'participantCount'),
coverImage: one(s, 'coverImage') || undefined,
hostName: one(s, 'hostName') || undefined,
hostInitials: one(s, 'hostInitials') || undefined,
};
}
function mapUser(s: UnionSubject): FpUserData {
return {
id: s.subject,
name: one(s, 'name'),
initials: one(s, 'initials'),
username: one(s, 'username'),
role: one(s, 'role') || undefined,
isPublic: s.props[`${FP}isPublic`] ? boolOf(s, 'isPublic') : undefined,
};
}
function mapParticipation(s: UnionSubject): FpParticipationData {
return {
id: s.subject,
eventId: one(s, 'event'),
userId: one(s, 'user'),
isConfirmed: boolOf(s, 'isConfirmed'),
};
}
/** All entities read from `docs` (union), split by RDF `@type`. */
export interface ReadEntities {
events: FpEventData[];
users: FpUserData[];
participations: FpParticipationData[];
}
/**
* Open/sync `docs` and run ONE union query (SDK `readModel.readUnion`), then map
* each subject to its Fp* type by RDF `@type`. `docs` is the by-need set of
* document NURIs to read (the app resolves it: index-discovered event docs +
* my own scope docs). A subject whose participation carries no `fp:user` is
* dropped (the SHEX `fp:user` is mandatory — matches the ORM read).
*/
export async function readEntities(docs: string[]): Promise<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;
}
+12 -7
View File
@@ -19,7 +19,7 @@
import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client';
import { sessionPromise } from '../utils/ngSession';
import { resolveInboxAnchor, listEntityDocs } from '../utils/storeRegistry';
import { resolveInboxAnchor, listMyEntityDocs } from '../utils/storeRegistry';
import type { FpNotificationData } from './types';
/** Notification IRI/type constants (mirror the SHEX Notification shape). */
@@ -152,18 +152,23 @@ export async function readRegistrationNotifications(
}
/**
* AUTHORITATIVE count of a user's Participations to an event across ALL protected
* per-entity documents (the broker, not the reactive set). Used to make join
* IDEMPOTENT reliably: the reactive participation set can lag behind a just-written
* participation, so a second join checking only the reactive set would write a
* duplicate. Querying the broker sees the real state regardless of read lag.
* AUTHORITATIVE count of a user's Participations to an event across the user's OWN
* protected per-entity documents (the broker, not the reactive set). Used to make
* join IDEMPOTENT reliably: the reactive participation set can lag behind a
* just-written participation, so a second join checking only the reactive set would
* write a duplicate. Querying the broker sees the real state regardless of read lag.
*
* Scoped to the CURRENT account (`username`) via `listMyEntityDocs` — a user's own
* participations live in their own account, so there is NO need to fan out over all
* accounts (which would open/sync other accounts' unsynced docs → the ~75s hang).
*/
export async function countUserParticipations(
username: string,
eventId: string,
userId: string,
): Promise<number> {
const sid = (await sessionPromise).session_id;
const docs_ = await listEntityDocs('protected');
const docs_ = await listMyEntityDocs(username, 'protected');
let total = 0;
for (const g of docs_) {
total += await countParticipations(sid, g, eventId, userId).catch(() => 0);
-42
View File
@@ -1,42 +0,0 @@
/**
* useShapeWithDefaults — wrapper around the SDK ORM's useShape.
*
* Subscribes to a SCOPE-resolved graph NURI (obtained from the SDK by logical
* scope — the app holds no store id), which opens the repo in the verifier
* (required for writes). Maps results to app types. If the NG set is empty,
* returns defaults.
*
* Must only be called when NG is connected (inside NgDataProvider).
*/
import { useShape } from '@ng-eventually/client';
import type { ShapeType, BaseType, DeepSignalSet } from '@ng-eventually/client';
export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
/** Mapped items from NG store */
items: AppT[];
/** Raw NG signal set for mutations */
ngSet: DeepSignalSet<NgT>;
}
/**
* `scope` is either a single scope-resolved graph NURI (from the SDK) or a
* `{ graphs }` set of document NURIs (a read fan-out). `useShape` accepts both
* natively. Either way the value is opaque to the app — it never builds it.
*/
export type ShapeScope = string | { graphs: string[] } | undefined;
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
shapeType: ShapeType<NgT>,
storeNuri: ShapeScope,
defaults: AppT[],
mapFromNg: (item: NgT) => AppT,
shapesReady: boolean,
): ShapeWithDefaults<NgT, AppT> {
// A single scope-resolved graph NURI opens the repo in the verifier (enables
// writes); a { graphs } scope subscribes to several docs (read fan-out).
const ngSet = useShape(shapeType, storeNuri as any) as DeepSignalSet<NgT>;
const usingDefaults = !shapesReady;
const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT));
return { items, ngSet };
}
+49 -8
View File
@@ -126,14 +126,14 @@ function ConnectedHarness() {
// to the raw ORM set only if the app hasn't hydrated a user yet.
const currentUserId = appData.currentUserId || [...users][0]?.['@id'] || '';
// T03.i round-trip fix. The app now writes ONE DOCUMENT PER ENTITY (events →
// public per-entity docs, participations/users → protected per-entity docs)
// via `createEntityDoc`, and reads a scope by subscribing to the SET of its
// per-entity documents (`listEntityDocs` + registerDoc). The old bridge read
// the STORE-ROOT NURI directly (`useShape(protectedNuri)`), which never sees
// the per-entity docs — so seed/creation didn't round-trip. The step-facing
// `events/users/participations` + mutations/queries now delegate to the APP
// data context (`appData`), i.e. the exact per-entity path the screens use.
// The app writes ONE DOCUMENT PER ENTITY (events → public per-entity docs,
// participations/users → protected per-entity docs) via `createEntityDoc`,
// and READS by the union model (T03.k): resolve the by-need doc NURIs (my own
// scope docs + the discovery index) then run ONE anchorless union
// `sparql_query` (`readEntities` → `readModel.readUnion`), re-querying on a
// change signal — never the reactive per-entity ORM fan-out (that HANGS). The
// step-facing `events/users/participations` + mutations/queries delegate to the
// APP data context (`appData`), i.e. the exact union-read path the screens use.
// The step contract (`[...td.events]` with `@id`/`title`/`participantCount`,
// `.size`, `p.user`/`p.event`) is preserved by mapping the app types to that
// shape in a Set-like adapter.
@@ -510,6 +510,47 @@ function ConnectedHarness() {
return nuri;
},
/**
* T03.k PROBE — pins down the read-model union premise against the REAL
* broker (docs/read-model.md § Minimal broker probe). Creates two graph
* docs A and B, writes a DISTINCT triple into each (anchored per-doc),
* then queries GRAPH ?g { ?s ?p ?o } twice: once with NO anchor (expect
* BOTH A and B — the LOCAL UNION) and once anchored to A (expect ONLY A).
* Returns the graphs seen in each mode so the step can assert the model.
*/
async runUnionProbe() {
const sid = session.session_id;
const docA = await docs.docCreate(sid, 'Graph', 'data:graph', 'store', undefined);
const docB = await docs.docCreate(sid, 'Graph', 'data:graph', 'store', undefined);
const sA = `urn:probe:s:${Date.now().toString(36)}:a`;
const sB = `urn:probe:s:${Date.now().toString(36)}:b`;
await docs.sparqlUpdate(sid, `INSERT DATA { GRAPH <${docA}> { <${sA}> <urn:probe:p> "A" } }`, docA);
await docs.sparqlUpdate(sid, `INSERT DATA { GRAPH <${docB}> { <${sB}> <urn:probe:p> "B" } }`, docB);
// Query our OWN probe subjects (sA/sB) so the assertion is by triple,
// not by the repo_graph_name (which carries an overlay suffix and won't
// string-equal the doc NURI). ?g is still selected for observability.
const q = `SELECT ?g ?s ?o WHERE { GRAPH ?g { ?s <urn:probe:p> ?o . FILTER(?s IN (<${sA}>, <${sB}>)) } }`;
const readObjs = (res: any): string[] => {
const rows = Array.isArray(res) ? res : res?.results?.bindings ?? [];
return rows.map((r: any) => r?.o?.value).filter(Boolean);
};
// NO anchor → local union across all opened graphs.
const unionRes = await docs.sparqlQuery(sid, q, undefined, undefined);
const unionObjs = readObjs(unionRes);
// Anchor = A → one repo only.
const anchorRes = await docs.sparqlQuery(sid, q, undefined, docA);
const anchorObjs = readObjs(anchorRes);
return {
docA, docB,
unionObjs,
anchorObjs,
unionHasA: unionObjs.includes('A'),
unionHasB: unionObjs.includes('B'),
anchorHasA: anchorObjs.includes('A'),
anchorHasB: anchorObjs.includes('B'),
};
},
/**
* Round-trip the sharedWalletShim through the wallet: create an account
* (3 docs + SPARQL INSERT), drop the cache, reload from the wallet via
+10 -14
View File
@@ -11,8 +11,6 @@
* them to the live subscription set (reactivity).
*/
import type { DeepSignalSet } from '@ng-eventually/client';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { normalizeUsername } from '../context/AccountContext';
import {
seedEvents,
@@ -34,24 +32,22 @@ export interface BootstrapResult {
/**
* Seed default data — ONE DOCUMENT PER ENTITY (rule_document-per-entity), written
* DIRECTLY into each entity's own document (see `entityWrites.writeEntity`) rather
* than via the reactive `ngSet.add`. The ngSets are read ONLY to detect an
* already-seeded wallet (their `@graph`-scoped write path can't add into a
* not-yet-subscribed per-entity document — that's the round-trip bug this fixes).
* The created document NURIs are returned so the caller registers them into the
* scope's `useShape({ graphs })` for the reactive READ.
* DIRECTLY into each entity's own document (see `entityWrites.writeEntity`). The
* created document NURIs are returned so the caller registers them into the read
* model's doc set for the union READ.
*
* `walletHasData` tells the seed whether the wallet already carries entities (a
* returning user → skip). The caller computes it from the union read (no ORM set
* needed — the read side is now the one-shot union query, not a reactive fan-out).
*/
export async function bootstrapWallet(
ngEvents: DeepSignalSet<FpEvent>,
ngUsers: DeepSignalSet<FpUserProfile>,
ngParticipations: DeepSignalSet<FpParticipation>,
walletHasData: boolean,
createEntityDoc: CreateEntityDoc,
): Promise<BootstrapResult> {
const createdDocs = { public: [] as string[], protected: [] as string[] };
// Already has data → returning user, nothing to seed
if (ngEvents.size > 0 || ngUsers.size > 0) {
console.log('[Bootstrap] Wallet already has data — events:', ngEvents.size,
'users:', ngUsers.size, 'participations:', ngParticipations.size);
if (walletHasData) {
console.log('[Bootstrap] Wallet already has data — skipping seed');
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs };
}
+1
View File
@@ -62,6 +62,7 @@ export const {
ensureAccount,
resolveWriteGraph,
listEntityDocs,
listMyEntityDocs,
allAccounts,
resolveReadGraphs,
resetRegistryCache,