refactor(data): route entities by scope via the SDK — no store ids in the app

Festipod now treats @ng-eventually/client as a finished NextGraph SDK: the app
decides only each entity's logical scope (events/PdR public, profiles/
participations protected, settings private) and calls the lib by scope. The old
mono-store default and the FESTIPOD_MULTISTORE path collapse into ONE scope path.

Removed every physical-store leak from the app data-plane (ngGraph, registration,
FestipodDataContext, NextGraphContext, useShapeWithDefaults): no more
did🆖${store_id} construction. The session is handed to the lib only at the
sanctioned injection point (ngSession/configureStoreRegistry). Product behavior
unchanged. @data 20/20; build + tsc clean.

(_debt.md included; the T03.e doctrine pass settles accumulated doc-debt.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 23:42:03 +02:00
parent db9eb1cf47
commit 619b94ac0e
10 changed files with 117 additions and 117 deletions
+41 -76
View File
@@ -26,17 +26,8 @@ import {
import { useNextGraph } from './NextGraphContext';
import { useAccount, normalizeUsername } from './AccountContext';
import { applyIsolation } from '../utils/isolation';
import { ensureAccount, resolveReadGraphs, resolveWriteGraph, createEntityDoc, listEntityDocs } from '../utils/storeRegistry';
import { resolveScopeGraph, listEntityDocs } from '../utils/storeRegistry';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
// Multi-document mode (storeRegistry): one document per (account × scope),
// mirroring the target per-user stores. Default OFF — the validated mono-store
// path stays the default until the multi-document path is broker-validated.
// Flip via FESTIPOD_MULTISTORE=1 at build time. See brief_2026-06-15_shared-wallet-shim.
// Browser-safe env read: the bundler inlines process.env.NODE_ENV but NOT
// custom vars, so a bare `process.env.FESTIPOD_MULTISTORE` throws
// "process is not defined" in the browser harness. Guard it.
const MULTISTORE = typeof process !== 'undefined' && process?.env?.FESTIPOD_MULTISTORE === '1';
import {
FpEventShapeType,
FpUserProfileShapeType,
@@ -257,45 +248,32 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
function useNgData(): FestipodDataContextValue {
const { session } = useNextGraph();
const { username } = useAccount();
// Mono-store scopes (MULTISTORE off). Two native stores of the shared wallet:
// - privateNuri: kept as the anchor for the inbox shim (host inbox deposits)
// and for private settings. Opens the repo in the verifier.
// - protectedNuri: T02.h (axe A) — the SHAREABLE domain entities (events,
// profiles, participations) now READ from and WRITE to the real protected
// native store, representative of the target per-user wallet. Verified to
// open for ORM reads+writes exactly like private (round-trip, no
// RepoNotFound — see protected-store.feature). Subscribing useShape with
// this NURI opens its repo in the verifier (required for writes).
const privateNuri = session ? `did:ng:${session.private_store_id}` : undefined;
const protectedNuri = session ? `did:ng:${session.protected_store_id}` : undefined;
// Multi-document state (storeRegistry): read fan-out (all accounts' docs per
// scope) and the current account's write docs. Populated by the effect below.
const [readGraphs, setReadGraphs] = useState<{ public: string[]; protected: string[] }>({ public: [], protected: [] });
const [writeGraphs, setWriteGraphs] = useState<{ protected?: string }>({});
// 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.
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 }>({});
useEffect(() => {
if (!MULTISTORE || !privateNuri || !username) return;
if (!ready) return;
let cancelled = false;
(async () => {
try {
await ensureAccount(username); // create this account's index docs on first sight
// Public = per-entity documents (events/PdR) listed by the public index.
// Protected = grouped in each account's protected index document.
const [pub, prot, wProt] = await Promise.all([
listEntityDocs('public'),
resolveReadGraphs('protected'),
resolveWriteGraph(username, 'protected'),
const [pub, prot] = await Promise.all([
resolveScopeGraph('public'),
resolveScopeGraph('protected'),
]);
if (cancelled) return;
setReadGraphs({ public: pub, protected: prot });
setWriteGraphs({ protected: wProt });
if (!cancelled) setScopeGraphs({ public: pub, protected: prot });
} catch (err) {
console.error('[FestipodData] storeRegistry init failed:', err);
console.error('[FestipodData] scope resolution failed:', err);
}
})();
return () => { cancelled = true; };
}, [privateNuri, username]);
}, [ready]);
// --- Public discovery (T02.e): cross-account fan-out, ALWAYS on ------------
// Materialize the cross-account source of PUBLIC entities so a user discovers
@@ -313,7 +291,7 @@ function useNgData(): FestipodDataContextValue {
// shim IS populated (multi-account staging), discovery unions those events in.
const [discoveryGraphs, setDiscoveryGraphs] = useState<string[]>([]);
useEffect(() => {
if (!privateNuri) return;
if (!ready) return;
let cancelled = false;
(async () => {
try {
@@ -324,18 +302,14 @@ function useNgData(): FestipodDataContextValue {
}
})();
return () => { cancelled = true; };
}, [privateNuri, username]);
}, [ready, username]);
const discoveryScope: ShapeScope = discoveryGraphs.length ? { graphs: discoveryGraphs } : undefined;
// Scope per entity: events live in the PUBLIC docs, profiles + participations
// in the PROTECTED docs. Mono-store mode collapses all to the PROTECTED native
// store (T02.h, axe A) — the shareable domain entities read from there.
const publicScope: ShapeScope = MULTISTORE
? (readGraphs.public.length ? { graphs: readGraphs.public } : undefined)
: protectedNuri;
const protectedScope: ShapeScope = MULTISTORE
? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined)
: protectedNuri;
// 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;
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyEvents: FpEventData[] = [];
@@ -388,9 +362,8 @@ function useNgData(): FestipodDataContextValue {
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
if (MULTISTORE) return; // seed targets the private store; multi-doc seeding is a separate concern
if (hasTriedAutoSeed.current) return;
if (!privateNuri) return;
if (!ready) return;
const t = setTimeout(() => {
hasTriedAutoSeed.current = true;
if (eventsShape.ngSet.size === 0 && usersShape.ngSet.size === 0) {
@@ -405,7 +378,7 @@ function useNgData(): FestipodDataContextValue {
}
}, 3000);
return () => clearTimeout(t);
}, [privateNuri, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
}, [ready, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
// --- Derived ---
// Resolve current user from the chosen account username (the perceived login);
@@ -428,12 +401,12 @@ function useNgData(): FestipodDataContextValue {
[events, currentUserId],
);
useEffect(() => {
if (!privateNuri || hostedEventIds.length === 0) return;
if (!ready || hostedEventIds.length === 0) return;
let cancelled = false;
(async () => {
try {
// In the polyfill every host inbox resolves to the shared private store,
// so read it ONCE and let the curator filter deposits per hosted event.
// The SDK resolves the inbox anchor for the current session; read it ONCE
// and let the curator filter deposits per hosted event.
const targetInbox = await hostInboxNuri('');
const all: FpNotificationData[] = [];
for (const evId of hostedEventIds) {
@@ -454,7 +427,7 @@ function useNgData(): FestipodDataContextValue {
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [privateNuri, hostedEventIds.join('|')]);
}, [ready, hostedEventIds.join('|')]);
// Isolation (staging realism): the app honors the matrix in connected mode —
// participations/connections narrowed to self + connections. See isolation.ts.
@@ -472,24 +445,16 @@ function useNgData(): FestipodDataContextValue {
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// --- Mutations (NG) ---
// Participations stay GROUPED in the account's protected index document.
// Mono-store mode writes to the PROTECTED native store (T02.h, axe A) — the
// same store the domain read scopes subscribe, so writes round-trip.
const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || protectedNuri || '';
// 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 || '';
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (NG):', event.title);
// Per-entity: in multistore each event is its OWN document. Mono-store: the
// private store. (Multistore create reactivity is best-effort — the new doc
// is appended to the read fan-out so it shows after re-subscribe.)
const eventGraph = MULTISTORE
? await createEntityDoc(username || '', 'public')
: (protectedNuri || '');
if (MULTISTORE && eventGraph) {
setReadGraphs(prev =>
prev.public.includes(eventGraph) ? prev : { ...prev, public: [...prev.public, eventGraph] },
);
}
// Events live in the PUBLIC scope (SDK-resolved graph — no store id).
const eventGraph = publicGraph;
eventsShape.ngSet.add({
"@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
@@ -506,7 +471,7 @@ function useNgData(): FestipodDataContextValue {
setSelectedEventId(addedEvent["@id"]);
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, protectedNuri, username]);
}, [protectedGraph, publicGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, username]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
@@ -529,7 +494,7 @@ function useNgData(): FestipodDataContextValue {
console.log('[FestipodData] Already participating, skipping');
return;
}
// 1) Persist the Participation (reactive ORM set — mono-store default path).
// 1) Persist the Participation (reactive ORM set — protected scope graph).
participationsShape.ngSet.add({
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
@@ -571,8 +536,8 @@ 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) — mono-store: the private store; multistore: the protected write doc —
// and is identified by the participation's OWN subject IRI (ngPart["@id"]),
// 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;
+5 -5
View File
@@ -40,11 +40,11 @@ export function NextGraphProvider({ children }: { children: ReactNode }) {
sessionPromise
.then((s) => {
console.log('[NG] Session obtained, stores:', {
private: s.private_store_id,
protected: s.protected_store_id,
public: s.public_store_id,
});
// The session (incl. native store ids) is handed to the SDK at the
// sanctioned injection point (ngSession/storeRegistry). The app context
// itself does not surface or manipulate store ids — it only tracks the
// connection status and the opaque session handle.
console.log('[NG] Session obtained — connected');
setNgSession(s);
setStatus('connected');
})
+9 -10
View File
@@ -19,6 +19,7 @@
import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client';
import { sessionPromise } from '../utils/ngSession';
import { resolveInboxAnchor } from '../utils/storeRegistry';
import type { FpNotificationData } from './types';
/** Notification IRI/type constants (mirror the SHEX Notification shape). */
@@ -64,20 +65,18 @@ function mintDepositUid(): string {
* Resolve the inbox document NURI for a meeting point / host.
*
* Preference order: the explicit MeetingPoint `inbox` NURI (SHEX field, T02.a)
* when known → else the shared-wallet PRIVATE STORE NURI. The lib's `inbox`
* uses `targetInbox` as BOTH the RDF graph AND the SPARQL anchor, and the anchor
* must be a REAL repo NURI (a `urn:` is rejected as `InvalidNuri` by the broker)
* — so in the polyfill every host inbox physically resolves to the shared wallet
* private store; deposits are DISCRIMINATED by their `eventId` payload (the
* curator filters per event). At migration this becomes the host's native inbox
* NURI and the deposits move to per-host docs. Async because the session (hence
* the private_store_id) is resolved lazily.
* when known → else the SDK-resolved inbox anchor for the current session
* (`resolveInboxAnchor()`). The app asks the SDK for the anchor by intent and
* holds NO store id: the SDK owns where deposits physically land (today: the
* shared wallet's private store — a real repo NURI, required because the broker
* rejects a `urn:` anchor; deposits are discriminated by their `eventId`
* payload, the curator filters per event). At migration the SDK returns the
* host's native inbox NURI and this call is unchanged.
*/
export async function hostInboxNuri(eventId: string, explicitInbox?: string): Promise<string> {
void eventId; // reserved: per-event inbox docs at migration
if (explicitInbox) return explicitInbox;
const { private_store_id } = await sessionPromise;
return `did:ng:${private_store_id}`;
return resolveInboxAnchor();
}
/**
+10 -9
View File
@@ -1,9 +1,10 @@
/**
* useShapeWithDefaults — wrapper around NextGraph ORM's useShape.
* useShapeWithDefaults — wrapper around the SDK ORM's useShape.
*
* Subscribes to the private store via did:ng:<private_store_id> scope,
* which opens the store repo in the verifier (required for writes).
* Maps results to app types. If the NG set is empty, returns defaults.
* 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).
*/
@@ -18,9 +19,9 @@ export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
}
/**
* `scope` is either a single store/document NURI (mono-store mode) or a
* `{ graphs }` set of document NURIs (multi-document mode — storeRegistry).
* `useShape` accepts both natively.
* `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;
@@ -31,8 +32,8 @@ export function useShapeWithDefaults<NgT extends BaseType, AppT>(
mapFromNg: (item: NgT) => AppT,
shapesReady: boolean,
): ShapeWithDefaults<NgT, AppT> {
// Mono-store: a single store NURI opens the repo in the verifier (enables
// writes). Multi-document: a { graphs } scope subscribes to several docs.
// 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));
+10 -15
View File
@@ -1,24 +1,20 @@
/**
* NextGraph graph NURI management.
*
* Returns the PROTECTED store NURI as @graph for ORM entity creation of the
* SHAREABLE domain entities (events, profiles, participations). T02.h (axe A)
* switched the default domain scope from the private store to the real
* protected native store (`did:ng:${protected_store_id}`) — the store
* representative of the target per-user wallet. Verified empirically: the
* protected store opens for ORM reads AND writes the same way private does
* (round-trip probe, no RepoNotFound). Like private, subscribing useShape with
* the protected store NURI as scope opens its repo in the verifier, and writes
* target the same NURI. (Private stays the anchor for the inbox shim + settings.)
* Returns the graph NURI where the SHAREABLE domain entities (events, profiles,
* participations) are created via the ORM. These entities live in the PROTECTED
* scope, so the NURI is resolved by SCOPE through the SDK
* (`resolveScopeGraph('protected')`) — the app holds NO physical store id and
* builds NO `did:ng:${store_id}` NURI. The SDK owns the placement/resolution.
*/
import { sessionPromise } from './ngSession';
import { resolveScopeGraph } from './storeRegistry';
let cachedGraphNuri: string | undefined;
/**
* Get the graph NURI for adding shareable ORM entities.
* Uses the protected store NURI (T02.h; was the private store before).
* Resolves the PROTECTED scope via the SDK (was a raw store NURI before T03.g).
*/
export async function ensureGraphNuri(
...sets: Iterable<{ readonly "@graph": string }>[]
@@ -36,9 +32,8 @@ export async function ensureGraphNuri(
}
}
// Use PROTECTED store NURI (the repo is opened by useShape with this scope).
const session = await sessionPromise;
cachedGraphNuri = `did:ng:${session.protected_store_id}`;
console.log('[ngGraph] Using protected store as graph:', cachedGraphNuri);
// Resolve the PROTECTED scope's graph via the SDK (opaque NURI — no store id).
cachedGraphNuri = await resolveScopeGraph('protected');
console.log('[ngGraph] Using protected scope as graph:', cachedGraphNuri);
return cachedGraphNuri;
}
+1 -1
View File
@@ -39,7 +39,7 @@ export function init(): Promise<void> {
async (event: any) => {
session = event.session;
session!.ng ??= realNg;
console.log('[NG session] Connected — private_store:', session!.private_store_id);
console.log('[NG session] Connected');
resolveSessionPromise(session!);
initNgSignals(realNg, session!);
},
+14 -1
View File
@@ -48,7 +48,16 @@ export function entityScope(kind: EntityKind): Scope {
configureStoreRegistry({
getSession: async () => {
const session = await sessionPromise;
return { sessionId: session.session_id, privateStoreId: session.private_store_id };
// Sanctioned injection point: the session (incl. the three native store ids)
// is handed to the lib HERE and nowhere else. The lib owns physical placement
// and resolves scope → store internally; the rest of the app speaks only in
// logical scopes and never touches a store id / builds a `did:ng:${…}` NURI.
return {
sessionId: session.session_id,
privateStoreId: session.private_store_id,
protectedStoreId: session.protected_store_id,
publicStoreId: session.public_store_id,
};
},
normalizeUser: normalizeUsername,
});
@@ -64,6 +73,10 @@ export const {
allAccounts,
resolveReadGraphs,
resetRegistryCache,
// SDK-shaped scope resolvers — the app asks by scope, the lib resolves
// placement (no store-id ever crosses the boundary).
resolveScopeGraph,
resolveInboxAnchor,
} = libStoreRegistry;
/**