Ng eventually #1
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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? */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user