feat(data): one document per entity + delegate isolation fully to the SDK

Festipod now follows the correct SDK logic: each entity (event, participation,
profile, notification) is created as its OWN document in its scope
(rule_document-per-entity), via the SDK create call — the store-root write path
and the FESTIPOD_MULTISTORE flag are gone. Reads subscribe the per-entity docs
with instant visibility on create; seed/bootstrap rewritten per-entity.

Removed all app-side access logic: utils/isolation.ts (applyIsolation) deleted.
The app only declares its identity (login) and its own bilateral connections
(sharing act), reads via the SDK, and trusts it — no access filtering in the app.
This makes the SDK's per-document ReadCap the sole, real isolation.

Unit-proven in the lib (89 tests). @data/@e2e validation deferred: the NextGraph
broker is unreachable — to be re-run in T03.d. Follow-up: unify app connection
principals (user IRI) onto the username key used by the SDK's cap owner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-04 10:40:44 +02:00
parent 82c2cb5f27
commit 3ad06dfaec
6 changed files with 160 additions and 171 deletions
+113 -67
View File
@@ -25,10 +25,8 @@ import {
} from '../data/seedData';
import { useNextGraph } from './NextGraphContext';
import { useAccount, normalizeUsername } from './AccountContext';
import { applyIsolation } from '../utils/isolation';
import { isolation } from '@ng-eventually/client';
import { declareConnections } from '@ng-eventually/client/polyfill';
import { resolveScopeGraph } from '../utils/storeRegistry';
import { listEntityDocs, createEntityDoc } from '../utils/storeRegistry';
import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
import {
@@ -229,7 +227,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
}, []);
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (local, no-op)');
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } };
}, []);
return {
@@ -252,31 +250,47 @@ function useNgData(): FestipodDataContextValue {
const { session } = useNextGraph();
const { username } = useAccount();
// The app speaks ONLY in logical scopes — it holds no store id and builds no
// `did:ng:${…}` NURI. It asks the SDK (`resolveScopeGraph(scope)`) for the
// opaque graph NURI of each scope; the SDK owns the physical placement (today
// it resolves the shareable domain scopes to the shared wallet's native
// stores — its internal detail). `ready` gates the effects on the session.
// `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).
// `ready` gates the effects on the session.
const ready = !!session;
// Scope-resolved graphs (from the SDK). Domain entities: events → public,
// profiles + participations → protected. Populated by the effect below.
const [scopeGraphs, setScopeGraphs] = useState<{ public?: string; protected?: string }>({});
// 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.
const [publicDocs, setPublicDocs] = useState<string[]>([]);
const [protectedDocs, setProtectedDocs] = useState<string[]>([]);
/** Add a freshly-created entity document to its scope's live subscription set
* (reactivity: the new doc joins the useShape graphs immediately). */
const registerDoc = useCallback((scope: 'public' | 'protected', nuri: string) => {
const setter = scope === 'public' ? setPublicDocs : setProtectedDocs;
setter(prev => (prev.includes(nuri) ? prev : [...prev, nuri]));
}, []);
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
try {
const [pub, prot] = await Promise.all([
resolveScopeGraph('public'),
resolveScopeGraph('protected'),
listEntityDocs('public'),
listEntityDocs('protected'),
]);
if (!cancelled) setScopeGraphs({ public: pub, protected: prot });
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])]);
} catch (err) {
console.error('[FestipodData] scope resolution failed:', err);
console.error('[FestipodData] entity-doc listing failed:', err);
}
})();
return () => { cancelled = true; };
}, [ready]);
}, [ready, username]);
// --- Public discovery (T03.c): read the GLOBAL INDEX ----------------------
// Discovery is "read the global index" (the SDK read). The app asks the SDK
@@ -309,11 +323,12 @@ function useNgData(): FestipodDataContextValue {
}, [ready, username]);
const discoveryScope: ShapeScope = discoveryGraphs.length ? { graphs: discoveryGraphs } : undefined;
// Scope per entity: events read/write the PUBLIC scope, profiles +
// participations the PROTECTED scope. Both are opaque SDK-resolved graph NURIs
// (the SDK owns placement) — the app never sees a store id.
const publicScope: ShapeScope = scopeGraphs.public;
const protectedScope: ShapeScope = scopeGraphs.protected;
// 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[] = [];
@@ -376,7 +391,12 @@ function useNgData(): FestipodDataContextValue {
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
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));
} else {
console.log('[FestipodData] Dev auto-seed: wallet already has data — skip');
}
@@ -433,27 +453,24 @@ function useNgData(): FestipodDataContextValue {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, hostedEventIds.join('|')]);
// Protected-sharing act: hand the SDK the current CONNECTIONS graph so it lets
// an owner's direct connections read that owner's PROTECTED entities (public =
// all; private = owner only). The app knows its connections (friendships — a
// domain fact) and declares them to the SDK; the SDK owns the enforcement. No
// store id, no document NURI crosses here — a pure domain graph.
// Protected-sharing act: declare the CURRENT identity's own connections to the
// SDK so an owner's connections may read that owner's PROTECTED entities (public
// = all; private = owner only). The declaration is AUTHENTICATED — it names only
// the current user's own peers and is bound to the current identity by the SDK;
// a protected read is granted only where BOTH sides connected (bilateral). The
// app carries NO access logic (see knowledge_trust-model) — it only declares its
// domain fact (friendships) and trusts the SDK's enforcement. No store id, no
// document NURI crosses here.
useEffect(() => {
if (!ready) return;
declareConnections(
isolation.connectionsFromLinks(friendships.map(f => ({ a: f.userId, b: f.friendId }))),
);
}, [ready, friendships]);
// Isolation (staging realism): the app honors the matrix in connected mode —
// participations/connections narrowed to self + connections. See isolation.ts.
const isolated = applyIsolation(
{ events, users, participations, meetingPoints, friendships },
currentUserId,
);
if (!ready || !currentUserId) return;
const myPeers = friendships
.filter(f => f.userId === currentUserId || f.friendId === currentUserId)
.map(f => (f.userId === currentUserId ? f.friendId : f.userId));
declareConnections(myPeers, currentUserId);
}, [ready, friendships, currentUserId]);
const queries = buildQueries(
events, users, isolated.participations, meetingPoints, isolated.friendships, currentUserId,
events, users, participations, meetingPoints, friendships, currentUserId,
);
console.log('[FestipodData] Render — NG | events:', events.length,
@@ -461,16 +478,23 @@ function useNgData(): FestipodDataContextValue {
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// --- Mutations (NG) ---
// Writes target the SCOPE-resolved graphs (opaque SDK NURIs — no store id).
// Participations + profiles → protected scope; events → public scope. The
// read scopes subscribe the same graphs, so writes round-trip.
const protectedGraph = protectedScope || '';
const publicGraph = publicScope || '';
// 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 /
// private) — the app carries no access logic.
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (NG):', event.title);
// Events live in the PUBLIC scope (SDK-resolved graph — no store id).
const eventGraph = publicGraph;
// Owner principal = the account username (what setCurrentUser declares). The
// SDK create returns THIS entity's OWN public document and declares its
// ReadCap policy (public → world-readable). Fall back to a generic account
// label when no login is present (dev/demo).
const owner = username || currentUserId || 'anon';
// Create the event's OWN document in the PUBLIC scope (one doc per entity).
const eventGraph = await createEntityDoc(owner, 'public');
registerDoc('public', eventGraph);
eventsShape.ngSet.add({
"@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
@@ -480,24 +504,32 @@ function useNgData(): FestipodDataContextValue {
} as FpEvent);
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
// The host's participation is its OWN document in the PROTECTED scope.
const partGraph = await createEntityDoc(owner, 'protected');
registerDoc('protected', partGraph);
participationsShape.ngSet.add({
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
"@graph": partGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
}
// Make the PUBLIC event discoverable: submit its reference to the SDK global
// discovery index (an SDK act — the app holds no index/store id). `submitter`
// = the declaring user when known, anonymous otherwise. Best-effort: a failed
// submission must not roll back a successful event creation.
// 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
// non-public doc. Best-effort: a failed submission must not roll back a
// successful event creation.
if (addedEvent) {
// Submitter is bound to the current identity by the SDK (it is not
// caller-supplied) — pass `null` for an anonymous submission (discovery
// needs no author). Avoids handing a principal the SDK would reject as a
// spoof (the app's user IRI differs from the declared identity key).
submitEventToIndex(
{ doc: eventGraph, id: addedEvent["@id"], title: event.title },
currentUserId || null,
null,
).catch(err => console.error('[FestipodData] submit event to index failed:', err));
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [protectedGraph, publicGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, username]);
}, [eventsShape.ngSet, participationsShape.ngSet, currentUserId, username, registerDoc]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
@@ -520,9 +552,14 @@ function useNgData(): FestipodDataContextValue {
console.log('[FestipodData] Already participating, skipping');
return;
}
// 1) Persist the Participation (reactive ORM set — protected scope graph).
// 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).
const owner = username || uid || 'anon';
const partGraph = await createEntityDoc(owner, 'protected');
registerDoc('protected', partGraph);
participationsShape.ngSet.add({
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
"@graph": partGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
@@ -542,7 +579,12 @@ function useNgData(): FestipodDataContextValue {
const targetInbox = await hostInboxNuri(eventId);
const { ts, uid: depositUid } = await depositRegistration(targetInbox, eventId, registrantId);
const notif = buildNotification(recipientId, eventId, registrantId, ts);
await insertNotification(protectedGraph, notif).catch(() => { /* data-level best-effort */ });
// The host FpNotification is its OWN document in the PROTECTED scope (one
// 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
// notification id from the inbox and same-ms/anon deposits never collide.
@@ -550,7 +592,7 @@ function useNgData(): FestipodDataContextValue {
} catch (err) {
console.error('[FestipodData] joinEvent inbox/notify failed:', err);
}
}, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId, username, registerDoc]);
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
@@ -562,11 +604,11 @@ function useNgData(): FestipodDataContextValue {
// 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) — falling back to the protected SCOPE graph (SDK-resolved, no store id)
// — 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"] || protectedGraph;
// 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"];
let result;
try {
@@ -599,7 +641,7 @@ function useNgData(): FestipodDataContextValue {
if (ngEvent) {
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId, protectedGraph]);
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
@@ -630,19 +672,23 @@ function useNgData(): FestipodDataContextValue {
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (NG)');
return bootstrapWallet(
const result = await bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
createEntityDoc,
);
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
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]);
return {
currentUserId, currentUser,
events, users,
participations: isolated.participations,
participations,
meetingPoints,
friendships: isolated.friendships,
friendships,
notifications,
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
+3 -1
View File
@@ -50,7 +50,9 @@ export async function submitEventToIndex(
submitter: string | null = null,
): Promise<void> {
const payload: EventIndexRef = { kind: 'event', ...ref };
await discovery.submitToIndex(payload, { from: submitter });
// Pass the event's document NURI so the SDK enforces PUBLIC-ONLY at the index:
// a non-public document is refused (it must never leak past its scope).
await discovery.submitToIndex(payload, { from: submitter, doc: ref.doc });
}
/**
+7 -2
View File
@@ -100,7 +100,12 @@ export function buildNotification(
/**
* Deposit a registration into the host's inbox (generic lib `inbox.post`) +
* return the deposit ts so the caller can mint a matching notification.
* `from` = the registrant id when connected, or `null` for an anonymous deposit.
*
* The registrant identity travels in the PAYLOAD (`userId`), which the host
* materializer reads. The transport-level `from` is left ANONYMOUS (`null`): the
* SDK binds `from` to the depositor's own identity and rejects a mismatched one
* as a spoof, and the app's user IRI is not the declared identity key — so the
* domain identity belongs in the payload, not in the transport `from`.
*/
export async function depositRegistration(
targetInbox: string,
@@ -115,7 +120,7 @@ export async function depositRegistration(
userId: registrantId,
uid,
};
await inbox.post(targetInbox, { from: registrantId ?? null, payload, ts });
await inbox.post(targetInbox, { from: null, payload, ts });
return { ts, uid };
}
+5 -4
View File
@@ -14,7 +14,6 @@ import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataCo
// useShape routed through the lib (SDK-identical surface); caps from /polyfill.
import { useShape, docs, inbox as docsInbox } from '@ng-eventually/client';
import { getCaps, getCurrentUser, setCurrentUser, resetCaps, declareConnections } from '@ng-eventually/client/polyfill';
import { isolation as ngIsolation } from '@ng-eventually/client';
import { hostInboxNuri as regInboxNuri } from '../data/registration';
import type { DeepSignalSet } from '@ng-eventually/client';
// doc_create goes through the lib's `docs` primitive (T01.a): it calls the REAL
@@ -302,10 +301,12 @@ function ConnectedHarness() {
setCurrentUser(reader);
setFilterActive(true);
},
/** Declare the owner↔reader connection to the SDK (domain sharing act).
* The SDK then issues the protected doc's read cap to the connection. */
/** Declare a BILATERAL owner↔reader connection to the SDK (domain sharing
* act). Each side asserts the other (bound to that identity); only then
* does the SDK issue the protected doc's read cap to the connection. */
connect(a: string, b: string) {
declareConnections(ngIsolation.connectionsFromLinks([{ a, b }]));
declareConnections([b], a); // a asserts b
declareConnections([a], b); // b asserts a → bilateral link materializes
},
/** Does the CURRENT user read the public entity document — through the
* SDK's own cap check — regardless of the protected caps? */
-85
View File
@@ -1,85 +0,0 @@
/**
* isolation — app-side visibility filter for the authorization matrix.
*
* STOPGAP: the app HONORS the matrix by filtering reads by owner + connections:
*
* - public (events, meeting points) → visible to everyone
* - protected (participations, connections) → owner + connections
* - private (settings) → owner only
*
* This is a deliberate, removable scaffold. Applied in CONNECTED mode only;
* demo/@ui mode keeps full seed data.
*
* Pure functions — no NextGraph, no React. Trivially testable.
*/
import type {
FpEventData,
FpUserData,
FpParticipationData,
FpMeetingPointData,
FpFriendshipData,
} from '../data/types';
// The generic visibility matrix now lives in the lib (`isolation`, ported in
// T01.c): pure `applyIsolation(items, current, connections, accessors)` +
// `connectionsFromLinks`. This wrapper maps the Festipod shapes onto that
// generic surface (friendships → connection graph; participations/friendships
// → items with a Festipod owner+scope). See decision_2026-06-17_eventually-library.
import { isolation } from '@ng-eventually/client';
export interface IsolatableData {
events: FpEventData[];
users: FpUserData[];
participations: FpParticipationData[];
meetingPoints: FpMeetingPointData[];
friendships: FpFriendshipData[];
}
/** The set the current user may see protected data for: self + direct connections. */
export function connectionIds(currentUserId: string, friendships: FpFriendshipData[]): Set<string> {
const connections = isolation.connectionsFromLinks(
friendships.map(f => ({ a: f.userId, b: f.friendId })),
);
return isolation.visibleSet(currentUserId, connections);
}
/**
* Narrow data to what `currentUserId` is allowed to see.
*
* - events / meeting points: untouched (public).
* - users: untouched — names/avatars are referenced (denormalized) by public
* events and by visible participations; full profile-level isolation is a
* later refinement (matrix open question on host identity).
* - participations: only the user's own and their connections' (protected).
* - friendships: only links involving the user or one of their connections.
*
* Delegates the visibility matrix to the lib's pure `applyIsolation`, mapping
* each Festipod item to (owner, scope). A friendship is owned by *either*
* endpoint, so we model it as protected-owned-by-both via a synthetic owner
* check: keep the original link-based predicate for friendships, use the lib
* for the per-owner participation filter.
*/
export function applyIsolation<T extends IsolatableData>(data: T, currentUserId: string): T {
// No identity yet → don't hide everything (e.g. during hydration).
if (!currentUserId) return data;
const connections = isolation.connectionsFromLinks(
data.friendships.map(f => ({ a: f.userId, b: f.friendId })),
);
// Participations: owner = the participating user, scope = protected.
const participations = isolation.applyIsolation(
data.participations,
currentUserId,
connections,
{ ownerOf: p => p.userId, scopeOf: () => 'protected' },
);
// Friendships are two-ended links: keep a link if EITHER endpoint is visible.
const visible = isolation.visibleSet(currentUserId, connections);
const friendships = data.friendships.filter(
f => visible.has(f.userId) || visible.has(f.friendId),
);
return { ...data, participations, friendships };
}
+32 -12
View File
@@ -3,21 +3,33 @@
*
* Called once after NG connection + shapes ready. If the wallet already
* has events/users, it's a returning user — skip seeding.
*
* ONE DOCUMENT PER ENTITY (rule_document-per-entity): every seeded entity is
* created as its OWN document in its scope via the SDK create (`createEntityDoc`,
* injected). Events live in the PUBLIC scope; profiles + participations in the
* PROTECTED scope. The created document NURIs are returned so the caller can add
* 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 { ensureGraphNuri } from './ngGraph';
import { normalizeUsername } from '../context/AccountContext';
import {
seedEvents,
seedUsers,
seedParticipations,
} from '../data/seedData';
/** Scope of a seed entity + how to create its own document (SDK create). */
export type Scope = 'public' | 'protected' | 'private';
export type CreateEntityDoc = (owner: string, scope: Scope) => Promise<string>;
export interface BootstrapResult {
seeded: boolean;
userIdMap: Map<string, string>;
eventIdMap: Map<string, string>;
/** Every per-entity document created, by scope — register these to subscribe. */
createdDocs: { public: string[]; protected: string[] };
}
/**
@@ -36,24 +48,24 @@ export async function bootstrapWallet(
ngEvents: DeepSignalSet<FpEvent>,
ngUsers: DeepSignalSet<FpUserProfile>,
ngParticipations: DeepSignalSet<FpParticipation>,
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);
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs };
}
console.log('[Bootstrap] First time for this wallet — seeding default data...');
console.log('[Bootstrap] First time for this wallet — seeding per-entity docs...');
// Create (or get) a document in the private store for our data.
// The ORM requires a real document NURI as @graph, not the store ID.
const graph = await ensureGraphNuri(ngEvents, ngUsers, ngParticipations);
console.log('[Bootstrap] Using graph NURI:', graph);
// Seed users — one at a time with ORM flush between each
// Seed users — one PROTECTED document each, owned by that user's account.
const userIdMap = new Map<string, string>();
for (const u of seedUsers) {
const owner = normalizeUsername(u.username);
const graph = await createEntityDoc(owner, 'protected');
createdDocs.protected.push(graph);
ngUsers.add({
"@graph": graph,
"@type": "http://festipod.org/UserProfile",
@@ -70,9 +82,13 @@ export async function bootstrapWallet(
}
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
// Seed events — one at a time with ORM flush between each
// Seed events — one PUBLIC document each. The seed carries no host username, so
// the seed events are owned by the first seed user (a fixture-level choice).
const seedOwner = seedUsers[0] ? normalizeUsername(seedUsers[0].username) : 'seed';
const eventIdMap = new Map<string, string>();
for (const e of seedEvents) {
const graph = await createEntityDoc(seedOwner, 'public');
createdDocs.public.push(graph);
ngEvents.add({
"@graph": graph,
"@type": "http://festipod.org/Event",
@@ -93,11 +109,15 @@ export async function bootstrapWallet(
}
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events');
// Seed participations with mapped IDs — one at a time
// Seed participations — one PROTECTED document each, owned by the participant.
let partCount = 0;
for (const p of seedParticipations) {
const eventIri = eventIdMap.get(p.eventId) || p.eventId;
const userIri = userIdMap.get(p.userId) || p.userId;
const seedUser = seedUsers.find(u => u.id === p.userId);
const owner = seedUser ? normalizeUsername(seedUser.username) : seedOwner;
const graph = await createEntityDoc(owner, 'protected');
createdDocs.protected.push(graph);
ngParticipations.add({
"@graph": graph,
"@type": "http://festipod.org/Participation",
@@ -111,5 +131,5 @@ export async function bootstrapWallet(
}
console.log('[Bootstrap] Seeded', partCount, 'participations');
return { seeded: true, userIdMap, eventIdMap };
return { seeded: true, userIdMap, eventIdMap, createdDocs };
}