diff --git a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md
index 47d3bd3..7226060 100644
--- a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md
+++ b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md
@@ -8,7 +8,7 @@ against: 66616484b96fb2227cd8cdf02321aacfb4811365
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
-Nothing is wired yet, so the list below is **declared, not measured**: it is what Festipod will call, derived from the seam's requirements and from the observation of the package pinned beside this declaration. `against:` names that observation's commit. The package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
+The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, per the platform flow recorded in the 2026-10-09 brief. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
## Consumed surface
diff --git a/src/app/App.tsx b/src/app/App.tsx
index f7000c4..f8e48a6 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,4 +1,5 @@
-import { RouterProvider, useRouter } from './router';
+import { useEffect } from 'react';
+import { RouterProvider, useNavigate, useRouter } from './router';
import { ThemeProvider } from '../shared/context/ThemeContext';
import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
@@ -38,7 +39,7 @@ function AppContent() {
case 'home': return ;
case 'events': return ;
case 'create-event': return ;
- case 'login': return null;
+ case 'login': return ;
case 'event-detail': return ;
case 'update-event': return ;
case 'invite': return ;
@@ -54,26 +55,44 @@ function AppContent() {
}
}
+/**
+ * What `/login` shows once the gate has let the user through: home. The route
+ * exists for the Solid-OIDC callback (`AuthGate` intercepts `?code` and
+ * `?register_app` before children render); a plain `/login` with nothing left
+ * to do is not a destination — send the user to the app.
+ */
+function AuthenticatedLoginLanding() {
+ const navigate = useNavigate();
+ useEffect(() => {
+ navigate('/home');
+ }, [navigate]);
+ return null;
+}
+
export function App() {
const backendName = initialBackendName();
const isNg = backendName === 'nextgraph';
+ // THE AUTH GATE ABOVE THE DATA PROVIDER — signing in must have settled before
+ // any data path mounts. The gate renders children only once there is a
+ // session to read through (or an explicit offline-demo choice), so the data
+ // provider's dispatch decision never races a sign-in that has not finished.
const appTree = (
-
-
+
+
-
-
+
+
);
return (
- {isNg ? {appTree} : appTree}
+
+ {isNg ? {appTree} : appTree}
+
);
}
diff --git a/src/app/AuthGate.tsx b/src/app/AuthGate.tsx
index d707827..268a0de 100644
--- a/src/app/AuthGate.tsx
+++ b/src/app/AuthGate.tsx
@@ -7,7 +7,9 @@
* a Pod-provider picker when no session exists, and the `/login` route handling
* the OAuth callback plus the ActivityPods app-registration stage. The auth
* provider is built with the app description document served by our backend as
- * `clientId` and `${origin}/login` as `redirectUri`.
+ * `clientId` and `${origin}/login` as `redirectUri`. Every session the flow
+ * establishes is published to `utils/currentSession` — the module store the
+ * data provider's dispatch reads, independent of where each mounts in the tree.
*
* 2. NEXTGRAPH (suspended at the root, kept for the URL variant). The legacy
* `ensureIdentity()` flow: the SDK mounts its own full-screen barrier and owns
@@ -26,6 +28,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { ensureIdentity } from '@ng-eventually/polyfill';
import { startNgSession } from '../shared/utils/ngSession';
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
+import { setCurrentSession } from '../shared/utils/currentSession';
import { useRouter, useNavigate } from './router';
import { initialBackendName } from '../shared/refine/providers';
import { getBackendUrl } from '../shared/utils/runtimeConfig';
@@ -89,7 +92,9 @@ export function AuthGate({ children }: { children: ReactNode }) {
if (stage.stage === 'callback') {
const result = await provider.handleCallback();
if (cancelled) return;
+ const session = provider.getSession();
setCurrentPrincipal(result.webId);
+ setCurrentSession(session ? { webId: session.webId, token: session.token } : undefined);
navigate(`/login?register_app=1&redirect=${encodeURIComponent(stage.redirectPath)}`);
return;
}
@@ -99,7 +104,10 @@ export function AuthGate({ children }: { children: ReactNode }) {
const registration = session ? await provider.registerApp(session.webId) : undefined;
if (cancelled) return;
if (registration) {
- if (session) setCurrentPrincipal(session.webId);
+ if (session) {
+ setCurrentPrincipal(session.webId);
+ setCurrentSession({ webId: session.webId, token: session.token });
+ }
navigate(stage.redirectPath);
}
// If `registerApp` returned `undefined`, the browser left for the Pod's
@@ -111,8 +119,10 @@ export function AuthGate({ children }: { children: ReactNode }) {
const session = provider.getSession();
if (session) {
setCurrentPrincipal(session.webId);
+ setCurrentSession({ webId: session.webId, token: session.token });
if (!cancelled) setApState('authenticated');
} else {
+ setCurrentSession(undefined);
if (!cancelled) setApState('unauthenticated');
}
} catch (err) {
diff --git a/src/app/auth/activityPodsAuth.ts b/src/app/auth/activityPodsAuth.ts
index 23d679d..3525e14 100644
--- a/src/app/auth/activityPodsAuth.ts
+++ b/src/app/auth/activityPodsAuth.ts
@@ -1,39 +1,13 @@
import { authProvider as createActivityPodsAuthProvider } from '@activitypods/refine-providers';
import type { AuthProvider } from '@activitypods/refine-providers';
-import { getRuntimeConfig } from '../../shared/utils/runtimeConfig';
+import { resolveActivityPodsAuthConfig } from '../../shared/utils/runtimeConfig';
-/**
- * Resolved ActivityPods auth configuration.
- *
- * `clientId` names the app description document the backend serves, as required
- * by the Solid-OIDC flow. `redirectUri` is fixed to `${origin}/login` because the
- * app renders its own callback route there.
- */
-export interface ResolvedAuthConfig {
- clientId: string;
- redirectUri: string;
- podProviderUrl: string | undefined;
-}
-
-/**
- * Resolve the auth configuration from runtime values.
- *
- * - `backendClientId` wins when explicitly set.
- * - Otherwise, `backendUrl` is used to derive `${backendUrl}/app`.
- * - Otherwise, fall back to `${origin}/app` so local dev still typechecks and
- * can be exercised without a backend.
- */
-export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig {
- const cfg = getRuntimeConfig();
- const backendClientId = cfg.backendClientId?.trim();
- const backendUrl = cfg.backendUrl?.trim();
- const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`);
- return {
- clientId,
- redirectUri: `${origin}/login`,
- podProviderUrl: cfg.podProviderUrl?.trim() || undefined,
- };
-}
+// Re-exported so existing callers (AuthGate, the flow-stage tests) keep their
+// import surface; the implementation lives in the runtime-config module, the
+// shared source of truth both the auth gate and the data provider resolve
+// against — one clientId/redirect for the whole app.
+export { resolveActivityPodsAuthConfig };
+export type { ResolvedAuthConfig } from '../../shared/utils/runtimeConfig';
/** Build an ActivityPods auth provider with the Festipod-specific configuration. */
export function buildActivityPodsAuthProvider(origin: string): AuthProvider {
diff --git a/src/modules/event/screens/EventDetailScreen.tsx b/src/modules/event/screens/EventDetailScreen.tsx
index 9cd9ea9..d145f5e 100644
--- a/src/modules/event/screens/EventDetailScreen.tsx
+++ b/src/modules/event/screens/EventDetailScreen.tsx
@@ -10,15 +10,14 @@ export function EventDetailScreen() {
getEvent,
currentUser,
isParticipating,
- joinEvent,
- leaveEvent,
+ joinMeetingPoint,
+ leaveMeetingPoint,
getEventParticipants,
getEventMeetingPoints,
getEventOwnership,
} = useFestipodData();
const event = eventId ? getEvent(eventId) : undefined;
- const joined = eventId ? isParticipating(eventId) : false;
const participants = eventId ? getEventParticipants(eventId) : [];
const meetingPointsRaw = eventId ? getEventMeetingPoints(eventId) : [];
@@ -30,6 +29,30 @@ export function EventDetailScreen() {
lieu: mp.location,
}));
+ // SIGNING UP IS PER MEETING POINT — the product model (on s'inscrit à un
+ // point de rencontre, pas à un événement). There is no event-level join on
+ // this screen anymore: "joined" means a member of at least one of this
+ // event's meeting points, and it gates the Invite affordance the way the
+ // old event-level membership did.
+ const joinedMeetingPointIds = new Set(
+ meetingPointsRaw.filter(mp => isParticipating(mp.id)).map(mp => String(mp.id)),
+ );
+ const joined = joinedMeetingPointIds.size > 0;
+
+ const handleToggleMeetingPoint = (id: string, _title: string, willJoin: boolean) => {
+ const failed = (message: string) => (err: unknown) => {
+ console.error('[EventDetail] meeting point participation failed:', err);
+ showToast(message, 'error');
+ };
+ if (willJoin) {
+ void Promise.resolve(joinMeetingPoint(id))
+ .catch(failed("L'inscription au point de rencontre n'a pas pu être enregistrée"));
+ } else {
+ void Promise.resolve(leaveMeetingPoint(id))
+ .catch(failed("La désinscription du point de rencontre n'a pas pu être enregistrée"));
+ }
+ };
+
// EDITING IS OWNING. Only a document's owner writes to it, and nothing delegates
// a write, so "may I edit this event" is answered entirely by whether this
// session owns the event's document — no permission call, no extra read.
@@ -51,28 +74,6 @@ export function EventDetailScreen() {
// `p.id !== currentUserId` failed to drop self, leaking it in as "1 unknown").
const knownParticipants = participants.filter(p => p.id !== currentUser?.id);
- const handleToggleJoin = () => {
- if (!eventId) return;
- // THE CONFIRMATION FOLLOWS THE WRITE. It used to be shown on the spot, before
- // the call had settled, so a sign-up that wrote nothing still read as
- // « Tu participes ». The screen's own list flips immediately anyway (the data
- // layer's optimistic overlay), so nothing is lost by waiting for the truth.
- const confirmed = (message: string, tone: 'success' | 'info') => () => showToast(message, tone);
- const failed = (message: string) => (err: unknown) => {
- console.error('[EventDetail] participation write failed:', err);
- showToast(message, 'error');
- };
- if (joined) {
- void Promise.resolve(leaveEvent(eventId))
- .then(confirmed('Participation annulée', 'info'))
- .catch(failed("La désinscription n'a pas pu être enregistrée"));
- } else {
- void Promise.resolve(joinEvent(eventId))
- .then(confirmed('Tu participes à cet événement', 'success'))
- .catch(failed("L'inscription n'a pas pu être enregistrée"));
- }
- };
-
if (!event) {
return (
@@ -150,30 +151,26 @@ export function EventDetailScreen() {
)}
-
-
- {joined ? '✓ Je participe' : "J'y serai"}
-
- {joined && (
+ {/* SIGNING UP IS PER MEETING POINT: no event-level join button here. The
+ join affordances live on the meeting points below; Invite follows
+ membership, the way the old event-level "Je participe" did. */}
+ {joined && (
+
navigate(`/events/${eventId}/invite`)}
style={{ flex: 1, padding: '12px 0' }}
>
Inviter
- )}
-
+
+ )}
{meetingPoints.length > 0 && (
{}}
+ joinedIds={joinedMeetingPointIds}
+ onToggle={handleToggleMeetingPoint}
expanded
/>
diff --git a/src/modules/event/steps/ui/event.steps.ts b/src/modules/event/steps/ui/event.steps.ts
index feadf32..4095749 100644
--- a/src/modules/event/steps/ui/event.steps.ts
+++ b/src/modules/event/steps/ui/event.steps.ts
@@ -77,13 +77,16 @@ Then('les événements affichent leur lieu', async function (this: FestipodWorld
'Event cards should display a location (text or 📍 icon)').to.be.true;
});
+// The product model signs up to a MEETING POINT, not to the event (arbitration
+// of the event-join removal, T04.e): the signup affordance on this screen is the
+// per-meeting-point "S'inscrire" toggle, not an event-level join button.
Then('je peux m\'inscrire à l\'événement', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('event-detail');
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');
- const hasToggle = buttons.some(t => t.includes("J'y serai") || t.includes('Je participe'));
- expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;
+ const hasSignup = buttons.some(t => t.includes("S'inscrire") || t.includes('Inscrit'));
+ expect(hasSignup, 'Event detail should expose a meeting-point signup button').to.be.true;
});
Then('je peux me désinscrire de l\'événement', async function (this: FestipodWorld) {
@@ -91,8 +94,8 @@ Then('je peux me désinscrire de l\'événement', async function (this: Festipod
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');
- const hasToggle = buttons.some(t => t.includes("J'y serai") || t.includes('Je participe'));
- expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;
+ const hasSignup = buttons.some(t => t.includes("S'inscrire") || t.includes('Inscrit'));
+ expect(hasSignup, 'Event detail should expose a meeting-point signup toggle').to.be.true;
});
// --- Create-event form assertions ---
diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx
index a9f17d8..e909d60 100644
--- a/src/shared/context/FestipodDataContext.tsx
+++ b/src/shared/context/FestipodDataContext.tsx
@@ -1,23 +1,35 @@
-import React, { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
+/**
+ * FestipodDataContext.tsx — the provider implementations and the dispatcher.
+ *
+ * THE DISPATCHER (`FestipodDataProvider`) routes by SESSION REALITY, not by
+ * which module imported whom:
+ *
+ * - A LIVE NEXTGRAPH SESSION (`useNextGraph().status`) → `NgDataProvider`
+ * (`ngData.tsx`) — the suspended polyfill path, kept for the `nextgraph`
+ * URL variant and the `@data` broker harness. Never mounted at the root.
+ * - AN ACTIVITYPODS SESSION (`utils/currentSession`, published by AuthGate)
+ * → `RefineDataProvider` (`refineData.tsx`) — the default backend behind
+ * the Refine seam.
+ * - OTHERWISE → `LocalDataProvider` — the disconnected demo over the seed
+ * fixtures. What the `@ui` layer renders against, and what an offline
+ * choice ("Continuer hors ligne", unconfigured backend) serves.
+ *
+ * THE AUTH GATE SITS ABOVE (see `app/App.tsx`): children reach the dispatcher
+ * only once signing in has settled or been deliberately skipped, so the data
+ * paths never fire against a session that does not exist yet. The transitional
+ * local-empty states below cover the NG harness's own pre-identity renders.
+ *
+ * The screen-facing contract, the shared queries and the hook live in
+ * `festipodDataBase.ts`; the NextGraph implementation in `ngData.tsx`; the
+ * ActivityPods implementation in `refineData.tsx`.
+ */
+
+import { useState, useCallback, type ReactNode } from 'react';
import type {
FpEventData,
FpUserData,
- FpParticipationData,
FpMeetingPointData,
- FpFriendshipData,
- FpNotificationData,
} from '../data/types';
-import {
- depositRegistration,
- depositLeave,
- readRegistrationNotifications,
- materializeAttendance,
- deleteParticipation,
- countUserParticipations,
- canonicalEventId,
-} from '../data/registration';
-import { inbox } from '@ng-eventually/polyfill';
-import type { Nuri } from '@ng-eventually/polyfill';
import {
CURRENT_USER_ID,
seedEvents,
@@ -27,228 +39,26 @@ import {
seedFriendships,
} from '../data/seedData';
import { useNextGraph } from './NextGraphContext';
-import { normalizeIdentifier } from '../utils/identifier';
-// Relationship is a Festipod concept: the app keeps its own bilateral registry
-// and shares its own documents' keys with its neighbours (see shared/utils/connections).
-import { declareConnections } from '../utils/connections';
-import {
- listMyEntityDocs,
- createEntityDoc,
- openDocumentInbox,
-} from '../utils/storeRegistry';
import { useCurrentPrincipal } from '../utils/currentPrincipal';
-import { createSerialTask } from '../utils/serialTask';
-import { useShapeQuery } from '../data/useShapeQuery';
-import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters';
-// The ORM generator emits BARE shape names (`EventShapeType`, `Event`, …), taken
-// from each shape's IRI local name. They are aliased back to the `Fp*` spelling
-// HERE, at the import, so nothing downstream depends on the generator's naming:
-// `Event` and `Notification` are DOM globals, and the bare names collide with the
-// app's own vocabulary (`FpEventData` & co. in `data/types.ts`). The prefix cannot
-// be restored at the generator — the emitted name follows the shape IRI, and those
-// IRIs are the persisted RDF classes (`ENTITY_TYPE`), not ours to rename.
+import { useCurrentSession } from '../utils/currentSession';
+import { initialBackendName } from '../refine/providers';
+import { NgDataProvider } from './ngData';
+import { RefineDataProvider } from './refineData';
+import { resolveDataProviderMode } from './dataProviderMode';
import {
- EventShapeType as FpEventShapeType,
- UserProfileShapeType as FpUserProfileShapeType,
- ParticipationShapeType as FpParticipationShapeType,
-} from '../shapes/orm/festipodShapes.shapeTypes';
-import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
-import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
-import { autoSeedEnabled, fixtureSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
+ FestipodDataContext,
+ buildQueries,
+ nextId,
+ type FestipodDataContextValue,
+ type EventOwnership,
+} from './festipodDataBase';
-// ============================================================================
-// Context interface
-// ============================================================================
-
-/**
- * Whether this session may WRITE an event's document — which, by the SDK
- * contract, is exactly whether it OWNS that document: *"Only a document's owner
- * writes to it. Holding its read key never grants a write"*, and *"No delegated
- * writing. A received key never grants a write, and no call adds a writer to a
- * document"*. So there is nothing extra to ask: owning it IS being able to write
- * it, and the owned-document listing already answers that.
- *
- * `'unknown'` is a REAL third answer, never a polite `'not-mine'`. The listing
- * may not have landed yet, or may have failed — and a rejection means UNKNOWN,
- * never "this session owns nothing". Collapsing it into `'not-mine'` is how you
- * end up telling an owner, silently and wrongly, that the thing is not theirs.
- */
-export type EventOwnership = 'mine' | 'not-mine' | 'unknown';
-
-interface FestipodDataContextValue {
- currentUserId: string;
- currentUser: FpUserData | undefined;
- /**
- * WHO THE SESSION SIGNED IN AS — the identity `ensureIdentity()` returned,
- * known before any document is read. For DISPLAY (and log attribution) only:
- * it is a different id space from `currentUserId` (a profile document NURI),
- * it is never written into an entity, and no data call takes it.
- */
- currentPrincipal: string;
-
- events: FpEventData[];
- users: FpUserData[];
- participations: FpParticipationData[];
- meetingPoints: FpMeetingPointData[];
- friendships: FpFriendshipData[];
- /** Host-facing notifications, surfaced from the inbox read (T02.c). */
- notifications: FpNotificationData[];
-
- getEvent(id: string): FpEventData | undefined;
- getUser(id: string): FpUserData | undefined;
- getEventParticipants(eventId: string): FpUserData[];
- getUserEvents(userId: string): FpEventData[];
- isParticipating(eventId: string, userId?: string): boolean;
- getFriends(userId?: string): FpUserData[];
- getEventMeetingPoints(eventId: string): FpMeetingPointData[];
- /**
- * May this session WRITE this event's document? Callers MUST treat `'unknown'`
- * as its own case — see {@link EventOwnership}.
- */
- getEventOwnership(eventId: string): EventOwnership;
-
- selectedEventId: string;
- setSelectedEventId(id: string): void;
- selectedEvent: FpEventData | undefined;
- selectedUserId: string;
- setSelectedUserId(id: string): void;
- selectedUser: FpUserData | undefined;
-
- createEvent(event: Omit): Promise;
- updateEvent(id: string, updates: Partial): void | Promise;
- joinEvent(eventId: string, userId?: string): Promise | void;
- leaveEvent(eventId: string, userId?: string): Promise | void;
- addMeetingPoint(mp: Omit): void;
- addFriend(friendId: string): void;
- updateProfile(updates: Partial): void | Promise;
- loadTestData(): Promise;
-}
-
-const FestipodDataContext = createContext(null);
-
-// ============================================================================
-// Helpers
-// ============================================================================
-
-let idCounter = 100;
-function nextId(prefix: string): string {
- return `${prefix}-${++idCounter}`;
-}
-
-// LEGACY, READ ONLY. Participations written by an older version key their user
-// (`fp:user`) as `urn:festipod:user:` instead of the UserProfile's `did:ng:`
-// document NURI. Nothing MINTS this any more — today's writes carry the profile
-// NURI (`currentUserId`) — but `resolveParticipantUser` must still recognize the
-// old form, or those participations render as "participant inconnu".
-const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:';
-
-/** Waits between attempts at resolving an owned-document set (see the effects). */
-const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000];
-
-/**
- * What a BRAND-NEW profile is created with.
- *
- * A profile is entirely Festipod's own object — the data layer knows nothing of
- * pseudos or display names — but the UserProfile SHEX shape makes `fp:name`,
- * `fp:initials` and `fp:username` MANDATORY, so a profile cannot be written
- * empty. And nothing about the person is known at sign-in: the identity the
- * session signed in as is OPAQUE (no display name; it is never parsed, never
- * rendered, never written into an entity). So the three required fields carry a
- * PLACEHOLDER that reads on screen as "not filled in yet": none of them is a
- * person's name or handle, and none is derived from the identity. The user
- * replaces them through `updateProfile` (UpdateProfileScreen).
- */
-const UNSET_PROFILE = {
- name: 'Profil à compléter',
- initials: '?',
- username: '(pseudo non défini)',
-} as const;
-
-/**
- * Resolve a Participation's `fp:user` to its UserProfile.
- *
- * TODAY'S WRITES need no resolving: `fp:user` carries the profile's own document
- * NURI, so the DIRECT match `u.id === userId` answers — and it is tried first.
- * The demo/local fixtures coincide there too (both sides are the bare `user-1`).
- *
- * LEGACY ONLY: participations written under the earlier scheme carry
- * `urn:festipod:user:`, which matches no profile id. For those
- * — and ONLY those — the handle is stripped off and compared to
- * `normalizeIdentifier(profile.username)`. This username bridge is a READ-side
- * survival for old data; it plays NO part in deciding who the current user is
- * (my profile is the profile document I own — see the "WHO AM I" block below).
- *
- * A per-deposit materializer uid (`mint...`, e.g. `mrktnoke-rzd699dk`) is a third,
- * unrelated space: it identifies an inbox deposit for the count, never a user.
- */
-function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined {
- // 1) Direct id match — demo/local seed space (`user-1`), or any coincident space.
- const direct = users.find(u => u.id === userId);
- if (direct) return direct;
- // 2) NG principal space: `urn:festipod:user:` → match on normalized username.
- const key = userId.startsWith(USER_PRINCIPAL_PREFIX)
- ? userId.slice(USER_PRINCIPAL_PREFIX.length)
- : userId;
- return users.find(u => u.username && normalizeIdentifier(u.username) === key);
-}
-
-// NG shape → app type mapping lives in `../data/shapeAdapters` (domain adapters
-// over the SDK's `watchShape` subjects).
-
-// ============================================================================
-// Shared queries builder — same logic for both local and NG modes
-// ============================================================================
-
-function buildQueries(
- events: FpEventData[],
- users: FpUserData[],
- participations: FpParticipationData[],
- meetingPoints: FpMeetingPointData[],
- friendships: FpFriendshipData[],
- currentUserId: string,
-) {
- const getEvent = (id: string) => events.find(e => e.id === id);
- const getUser = (id: string) => users.find(u => u.id === id);
-
- const getEventParticipants = (eventId: string) => {
- // Resolve each of the event's participations to its UserProfile across the two
- // id spaces (participation principal vs profile NURI) — see
- // `resolveParticipantUser`. A raw `partUserIds.includes(u.id)` join never
- // matched in connected mode (principal ≠ NURI) → every participant rendered as
- // "unknown". Deduped, returned in `users` order to match the prior contract.
- const eventParts = participations.filter(p => p.eventId === eventId);
- const resolvedIds = new Set();
- for (const p of eventParts) {
- const u = resolveParticipantUser(p.userId, users);
- if (u) resolvedIds.add(u.id);
- }
- return users.filter(u => resolvedIds.has(u.id));
- };
-
- const getUserEvents = (userId: string) => {
- const partEventIds = participations.filter(p => p.userId === userId).map(p => p.eventId);
- return events.filter(e => partEventIds.includes(e.id));
- };
-
- const isParticipating = (eventId: string, userId?: string) => {
- const uid = userId || currentUserId;
- return participations.some(p => p.eventId === eventId && p.userId === uid);
- };
-
- const getFriends = (userId?: string) => {
- const uid = userId || currentUserId;
- const friendIds = friendships
- .filter(f => f.userId === uid || f.friendId === uid)
- .map(f => f.userId === uid ? f.friendId : f.userId);
- return users.filter(u => friendIds.includes(u.id));
- };
-
- const getEventMeetingPoints = (eventId: string) => {
- return meetingPoints.filter(mp => mp.eventId === eventId);
- };
-
- return { getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints };
-}
+// Re-exported so existing importers of this module keep their surface: the
+// hook, the context object, the demo provider, and the shared types.
+export { useFestipodData, FestipodDataContext } from './festipodDataBase';
+export type { FestipodDataContextValue, EventOwnership } from './festipodDataBase';
+export { resolveDataProviderMode } from './dataProviderMode';
+export type { DataProviderMode } from './dataProviderMode';
// ============================================================================
// Local (disconnected) provider — uses seed data directly, read-only
@@ -292,11 +102,11 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
const updateEvent = useCallback((_id: string, _updates: Partial) => {
console.log(`${logPrefix} updateEvent (local, no-op)`);
}, [logPrefix]);
- const joinEvent = useCallback((_eventId: string) => {
- console.log(`${logPrefix} joinEvent (local, no-op)`);
+ const joinMeetingPoint = useCallback((_meetingPointId: string) => {
+ console.log(`${logPrefix} joinMeetingPoint (local, no-op)`);
}, [logPrefix]);
- const leaveEvent = useCallback((_eventId: string) => {
- console.log(`${logPrefix} leaveEvent (local, no-op)`);
+ const leaveMeetingPoint = useCallback((_meetingPointId: string) => {
+ console.log(`${logPrefix} leaveMeetingPoint (local, no-op)`);
}, [logPrefix]);
const addMeetingPoint = useCallback((_mp: Omit) => {
console.log(`${logPrefix} addMeetingPoint (local, no-op)`);
@@ -307,7 +117,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
const updateProfile = useCallback((_updates: Partial) => {
console.log(`${logPrefix} updateProfile (local, no-op)`);
}, [logPrefix]);
- const loadTestData = useCallback(async (): Promise => {
+ const loadTestData = useCallback(async () => {
console.log(`${logPrefix} loadTestData (local, no-op)`);
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } };
}, []);
@@ -327,1256 +137,42 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
- createEvent, updateEvent, joinEvent, leaveEvent,
+ createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
addMeetingPoint, addFriend, updateProfile, loadTestData,
};
}
-// ============================================================================
-// NextGraph-connected provider — reads directly from NG wallet
-// ============================================================================
-
-function useNgData(): FestipodDataContextValue {
- const { session } = useNextGraph();
- // 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 via the SDK's reactive,
- // `useQuery`-shaped surface `watchShape(shape, scope)` (bound to React by
- // `useShapeQuery`). The observable resolves the scope to the current identity's
- // wallet (its own scope docs + discovery for public), awaits the sync barrier,
- // and pushes on every change — no bespoke re-query, no manual doc listing, no
- // per-doc subscription in the app. See rule_app-uses-sdk-surface-only.
- const ready = !!session;
-
- // --- REACTIVE READS via the SDK surface (`watchShape` bound with useShapeQuery) --
- // Three scoped shape reads, mapped to the app's domain types. Each is reactive
- // (broker push, no polling): a locally-created entity, a seeded doc, or a remote
- // peer's public event all re-render through the observable's own subscriptions.
- // • events = public (my own public event docs + discovery index)
- // • profiles/users = protected (my own)
- // • participations = protected (my own; cap-filtered by the SDK)
- const eventQuery = useShapeQuery(FpEventShapeType, 'public');
- const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected');
- const partQuery = useShapeQuery(FpParticipationShapeType, 'protected');
- // The RAW reactive sets straight from `watchShape` (before the optimistic
- // overlay). The exposed `events`/`users`/`participations` merge these with the
- // pending overlay below — see the "OPTIMISTIC OVERLAY" block.
- const reactiveUsers = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
- const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]);
- const reactiveParticipations = React.useMemo(
- () => adaptParticipations(partQuery.data),
- [partQuery.data],
- );
-
- // --- OPTIMISTIC OVERLAY (standard useQuery-mutation pattern; NO polling) ------
- // `watchShape` only surfaces a doc AFTER the broker push (a real latency), so a
- // mutation's effect is invisible in the reactive set for a beat. To keep the
- // screens' local-first feel, each mutation reflects its effect IMMEDIATELY in a
- // pure app-side React overlay laid OVER the SDK surface (never touching the SDK
- // internals — see rule_app-uses-sdk-surface-only), then RECONCILED by the push:
- // • `pendingAddEvents` / `pendingAddParticipations` — entities created
- // optimistically, keyed by their `@id` (= the doc NURI `writeEntity` returns,
- // the SAME id `watchShape` surfaces as `s.subject` → they match exactly).
- // • `pendingRemoveIds` — participation ids removed optimistically (leave).
- // The exposed set = merge(reactive, pendingAdds) minus pendingRemoves, DEDUPED by
- // id. Reconciliation (effects below) auto-cleans the overlay the moment the
- // reactive read catches up: an add whose id now appears in the reactive set is
- // dropped from pendingAdds; a remove whose id is no longer in the reactive set is
- // dropped from pendingRemoves. No re-query, no interval — the overlay only reacts
- // to `watchShape`'s own pushes.
- const [pendingAddEvents, setPendingAddEvents] = useState([]);
- const [pendingAddUsers, setPendingAddUsers] = useState([]);
- const [pendingAddParticipations, setPendingAddParticipations] = useState([]);
- const [pendingRemoveIds, setPendingRemoveIds] = useState>(() => new Set());
-
- const users = React.useMemo(() => {
- if (pendingAddUsers.length === 0) return reactiveUsers;
- const seen = new Set(reactiveUsers.map(u => u.id));
- const extra = pendingAddUsers.filter(u => !seen.has(u.id));
- return extra.length ? [...reactiveUsers, ...extra] : reactiveUsers;
- }, [reactiveUsers, pendingAddUsers]);
-
- const events = React.useMemo(() => {
- if (pendingAddEvents.length === 0) return reactiveEvents;
- const seen = new Set(reactiveEvents.map(e => e.id));
- const extra = pendingAddEvents.filter(e => !seen.has(e.id));
- return extra.length ? [...reactiveEvents, ...extra] : reactiveEvents;
- }, [reactiveEvents, pendingAddEvents]);
-
- const participations = React.useMemo(() => {
- let base = reactiveParticipations;
- if (pendingAddParticipations.length) {
- const seen = new Set(base.map(p => p.id));
- const extra = pendingAddParticipations.filter(p => !seen.has(p.id));
- if (extra.length) base = [...base, ...extra];
- }
- if (pendingRemoveIds.size) base = base.filter(p => !pendingRemoveIds.has(p.id));
- return base;
- }, [reactiveParticipations, pendingAddParticipations, pendingRemoveIds]);
-
- // RECONCILIATION — drop each optimistic add once the reactive read carries its id
- // (the push arrived), and each optimistic remove once the reactive read no longer
- // carries its id (the SPARQL delete propagated). Guarded to no-op when there is
- // nothing to reconcile, so a stable reactive set does not churn state.
- useEffect(() => {
- if (pendingAddEvents.length === 0) return;
- const live = new Set(reactiveEvents.map(e => e.id));
- setPendingAddEvents(prev => {
- const next = prev.filter(e => !live.has(e.id));
- return next.length === prev.length ? prev : next;
- });
- }, [reactiveEvents, pendingAddEvents]);
-
- useEffect(() => {
- if (pendingAddUsers.length === 0) return;
- const live = new Set(reactiveUsers.map(u => u.id));
- setPendingAddUsers(prev => {
- const next = prev.filter(u => !live.has(u.id));
- return next.length === prev.length ? prev : next;
- });
- }, [reactiveUsers, pendingAddUsers]);
-
- useEffect(() => {
- if (pendingAddParticipations.length === 0) return;
- const live = new Set(reactiveParticipations.map(p => p.id));
- setPendingAddParticipations(prev => {
- const next = prev.filter(p => !live.has(p.id));
- return next.length === prev.length ? prev : next;
- });
- }, [reactiveParticipations, pendingAddParticipations]);
-
- useEffect(() => {
- if (pendingRemoveIds.size === 0) return;
- const live = new Set(reactiveParticipations.map(p => p.id));
- setPendingRemoveIds(prev => {
- let changed = false;
- const next = new Set(prev);
- for (const id of prev) if (!live.has(id)) { next.delete(id); changed = true; }
- return changed ? next : prev;
- });
- }, [reactiveParticipations, pendingRemoveIds]);
- // The read is "settled" once every scope has reached its sync barrier
- // (`isSuccess`). A synced-but-empty scope reads `isSuccess` with `data: []` — the
- // distinction the auto-seed relies on to tell "still syncing" from "truly empty".
- const readReady =
- eventQuery.isSuccess && userQuery.isSuccess && partQuery.isSuccess;
-
- // Session-local map `${eventId}|${userId}` → the join deposit's uid, so a leave
- // in the SAME session can carry `regUid` for a precise cancellation. Absent it
- // (cross-session leave), the owner's materializer falls back to (event, user)
- // matching — so this is an optimization, not a correctness dependency.
- //
- // Nothing ever clears this map, and nothing needs to: a browser context is one
- // user for its whole life (`ensureIdentity()` answers once, before anything
- // renders), so there is never anyone else's leftovers in it.
- const joinUidsRef = useRef>(new Map());
-
- // OPTION B — the set of event docs the CURRENT identity OWNS (its own public
- // event docs). Each such NURI IS the event `@id` (writeEntity uses the doc NURI
- // as the subject). The owner-materializer subscribes to each owned event's inbox
- // and writes `participantCount` on THAT (owned) doc — never on someone else's.
- const [ownedEventIds, setOwnedEventIds] = useState([]);
- // Events the listing has RULED OUT — canonical ids that were already visible in
- // the reactive read when a listing RESOLVED, and were absent from its result.
- // ONLY these may be shown as "not yours".
- //
- // Why a set and not a "the listing answered" boolean: a listing is a SNAPSHOT,
- // and it can only ever UNDER-report ownership, because a document created after
- // it was taken cannot possibly be in it. So a miss is authoritative for what
- // existed at the time and says NOTHING about anything that arrived since. With a
- // boolean, a second tab on the same wallet that creates an event has it pushed
- // here, misses the stale snapshot, and its owner is told the event is not theirs
- // — permanently. That is the exact failure this whole answer exists to avoid.
- const [ruledOutEventIds, setRuledOutEventIds] = useState>(new Set());
- // The owned set in canonical id-form — the form every event-id comparison here
- // uses, so an event reached under a different overlay is still the same event.
- const ownedCanonicalIds = React.useMemo(
- () => new Set(ownedEventIds.map(canonicalEventId)),
- [ownedEventIds],
- );
-
- // WHICH PROTECTED DOCUMENTS ARE MINE — the ground on which "my profile" rests.
- // `null` means NOT ANSWERED YET (the listing has not landed, or it failed):
- // "unknown" and "I own nothing" are indistinguishable as an empty array, and
- // only one of them may lead to creating a profile. Nothing downstream may read
- // `null` as an empty set.
- const [myProtectedDocs, setMyProtectedDocs] = useState(null);
- // The profile document THIS session created for the signed-in person. Set once,
- // by the creation effect; it settles "which of my profile documents is mine"
- // without looking at any field of any profile.
- const [myProfileDocId, setMyProfileDocId] = useState('');
-
- /**
- * Fold documents THIS SESSION just created into the owned set.
- *
- * The backfill below runs once, on connection, so it only ever knows what
- * existed at mount. Everything created afterwards has to announce itself, or it
- * is owned in fact and unowned as far as this tree is concerned — and the
- * owner-materializer, which only ever looks at this set, never runs on it.
- */
- const claimOwnedEventDocs = useCallback((created: Nuri[]) => {
- if (created.length === 0) return;
- setOwnedEventIds(prev => [...new Set([...prev, ...created])]);
- }, []);
-
- // Resolve the CURRENT identity's owned public event docs for the materializer
- // ONLY (decoupled from the read — `watchShape` resolves reads itself). Bounded to
- // this session's own documents (`listMyEntityDocs('public')` — "mine" needs no
- // identity, the session is one user's). Runs on connection to backfill events
- // owned before this mount; `createEvent` and the seed (`loadTestData` /
- // auto-seed) claim what they create through `claimOwnedEventDocs`. This is NOT a
- // read path (it feeds no `events`/`users`/`participations`), only the
- // owner-count derivation.
- //
- // A REJECTION HERE MEANS "UNKNOWN", NEVER "THIS SESSION OWNS NOTHING". The two
- // are indistinguishable downstream — both leave the set empty — but only one of
- // them is true, and taking the wrong one silently disables the owner
- // materializer, so every event this session hosts stops converging. So the
- // failure is RETRIED, and if it still will not answer, it is said loudly instead
- // of leaving a plausible-looking empty set behind.
- // Read through refs inside the async listing below, so classification uses what
- // is on screen when the listing RESOLVES rather than what was there when it
- // started — the two differ by exactly the push that prompted a re-listing.
- const eventsRef = useRef(events);
- eventsRef.current = events;
- const ownedCanonicalIdsRef = useRef(ownedCanonicalIds);
- ownedCanonicalIdsRef.current = ownedCanonicalIds;
- // Events the snapshot cannot speak about: neither owned nor ruled out. While
- // this is non-empty the listing is re-taken — ONCE per new arrival, never on a
- // timer ([[rule_no-broker-polling]]): a listing classifies everything visible at
- // the moment it resolves, so this empties and the effect falls silent until a
- // genuinely new event turns up.
- const unclassifiedEventKey = React.useMemo(() => {
- const pending = events
- .map(e => canonicalEventId(e.id))
- .filter(c => !ownedCanonicalIds.has(c) && !ruledOutEventIds.has(c));
- return [...new Set(pending)].sort().join('|');
- }, [events, ownedCanonicalIds, ruledOutEventIds]);
- const listingTaken = useRef(false);
- useEffect(() => {
- if (!ready) return;
- // Nothing left to learn: a listing has been taken and every visible event is
- // classified. Without this the effect would re-list on its own state change.
- if (listingTaken.current && unclassifiedEventKey === '') return;
- let cancelled = false;
- (async () => {
- for (let attempt = 0; !cancelled; attempt++) {
- try {
- const myPublic = await listMyEntityDocs('public');
- if (cancelled) return;
- setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]);
- listingTaken.current = true;
- // The listing ANSWERED — so every event visible RIGHT NOW that it did
- // not return is genuinely not this session's, and may be shown as such.
- // Anything arriving after this point stays unclassified and triggers a
- // fresh listing rather than inheriting this one's silence. Events already
- // claimed as mine (created here, possibly racing this listing) are never
- // ruled out.
- // REBUILT, never accumulated: every listing RE-ADJUDICATES everything
- // visible, so a later listing can overturn an earlier ruling instead of
- // being outvoted by it.
- //
- // KNOWN RESIDUAL, and it needs the provider — not more code here. This
- // infers "not yours" from ABSENCE, and absence is not authoritative: the
- // reactive read and this listing are two different mechanisms, so an
- // event can be on screen a moment before the listing can see it. Ruled
- // out in that window, it is only re-examined if some OTHER unclassified
- // event later triggers a listing. Self-healing would need either a timer
- // (polling — forbidden) or the "may I write this?" call the surface does
- // not publish. Per `rule_app-uses-sdk-surface-only` that is a GAP to
- // raise, not one to paper over here, so it is left visible and stated.
- const ownedNow = new Set(myPublic.map(canonicalEventId));
- setRuledOutEventIds(() => {
- const next = new Set();
- for (const e of eventsRef.current) {
- const c = canonicalEventId(e.id);
- if (!ownedNow.has(c) && !ownedCanonicalIdsRef.current.has(c)) next.add(c);
- }
- return next;
- });
- return;
- } catch (err) {
- const wait = OWNED_RETRY_BACKOFF_MS[attempt];
- if (wait === undefined) {
- console.error(
- `${logPrefix} owned-events resolution FAILED after ${OWNED_RETRY_BACKOFF_MS.length + 1} ` +
- `attempts — the owned set is UNKNOWN, not empty, and events hosted by this session ` +
- `will not converge until it is known:`,
- err,
- );
- return;
- }
- console.warn(
- `${logPrefix} owned-events resolution failed (attempt ${attempt + 1}) — retrying in ${wait}ms:`,
- err,
- );
- await new Promise(r => setTimeout(r, wait));
- }
- }
- })();
- return () => { cancelled = true; };
- }, [ready, unclassifiedEventKey]);
-
- // Not in SHEX shapes yet
- const [meetingPoints, setMeetingPoints] = useState([]);
- const [friendships, setFriendships] = useState([]);
- // Host-facing notifications, materialized from the current user's inboxes
- // (the inbox read, T02.b/c). Data-level surfacing of "new participants".
- const [notifications, setNotifications] = useState([]);
-
- const [selectedEventId, setSelectedEventId] = useState('');
- const [selectedUserId, setSelectedUserId] = useState('');
-
- // Auto-select first event when data appears from NG
- useEffect(() => {
- const first = events[0];
- if (!selectedEventId && first) {
- setSelectedEventId(first.id);
- }
- }, [events.length, selectedEventId]);
-
- // Auto-seed: bootstrap seed data into a genuinely EMPTY wallet. Gated on the
- // SDK's `isSuccess` (readReady) — the sync barrier is reached for every scope —
- // so an empty set means "synced and truly empty", NOT "still syncing". This
- // replaces the old 3s chronometer heuristic (which guessed a sync delay and mis-
- // fired a re-seed on the 3rd connect). `isPending` → wait; `isSuccess` + empty
- // data → seed. Gated behind the `FESTIPOD_AUTO_SEED` env var (see utils/autoSeed):
- // OFF BY DEFAULT so a fresh/persistent wallet is never silently bloated with demo
- // data — the seed only runs when explicitly opted in. Explicit `loadTestData`
- // (the test-data action + every @data test) is UNAFFECTED (it seeds directly).
- // `hasTriedAutoSeed` keeps it single-shot (also suppressed by an explicit
- // `loadTestData`).
- //
- // ALL OF THAT IS CURRENTLY MOOT: the fixture seed is switched OFF outright
- // (`fixtureSeedEnabled`), so this effect returns on its first line and no
- // fixture reaches the wallet. `bootstrapWallet` enforces the same switch, so
- // the check here buys only silence — no misleading "bootstrapping…" log, and
- // no work started that would write nothing.
- const hasTriedAutoSeed = useRef(false);
- useEffect(() => {
- if (!fixtureSeedEnabled()) return;
- if (!autoSeedEnabled()) return;
- if (hasTriedAutoSeed.current) return;
- if (!ready) return;
- if (!readReady) return; // still syncing — do NOT mistake pending for empty
- // MY OWN PROFILE IS NOT "DATA IN THE WALLET". It is created at sign-in on a
- // brand-new wallet, so counting it here would permanently disable the seed on
- // exactly the wallets it exists for. The question the gate asks is "does this
- // wallet already hold something worth preserving", and my own empty profile
- // does not.
- const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
- if (!shouldAutoSeed(walletHasData)) {
- console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`);
- return;
- }
- // Enabled AND synced-empty → a real empty wallet. Seed once.
- hasTriedAutoSeed.current = true;
- console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…`);
- bootstrapWallet(false, createEntityDoc)
- // The seed's public event docs were created BY THIS SESSION — they are mine.
- .then(result => claimOwnedEventDocs(result.createdDocs.public))
- .catch(err => console.error(`${logPrefix} Auto-seed failed:`, err));
- // The reactive `watchShape` reads pick the seeded per-entity docs up on their
- // own (each createEntityDoc appends to the scope index → the container-index
- // subscription re-resolves → the new docs enter the read). No registerDoc/relist.
- }, [ready, readReady, events.length, users, myProfileDocId, claimOwnedEventDocs]);
-
- // --- Derived ---
- // WHO AM I — answered in TWO id spaces that must not be confused.
- //
- // (1) `currentPrincipal` — what signing in returned. Known as soon as the one
- // identity await settles, i.e. before any document has been read. It names
- // a PERSON, OPAQUELY: it is never parsed, never rendered as a name, never
- // written into an entity and never handed to a data call. It exists here
- // for display of "am I signed in" and for log attribution.
- // (2) `currentUserId` — the app's own entity space: the `@id` of MY PROFILE
- // DOCUMENT (a doc NURI). This is what a Participation's `fp:user` carries
- // and what `resolveParticipantUser` matches directly, so it is the only
- // value a mutation may write.
- //
- // THERE IS NO JOIN BETWEEN THE TWO, and there must not be one. A profile is
- // Festipod's own object; the identity says nothing about it. **My profile is
- // the profile document I OWN** — `listMyEntityDocs('protected')` answers "which
- // documents are mine", and the UserProfile among them is mine. No field of any
- // profile takes part in the answer: no username comparison, no normalization,
- // no positional pick.
- //
- // THREE OUTCOMES, and "somebody else's profile" is not one of them:
- // • exactly one owned profile → that is me;
- // • none → I have no profile yet, and the creation effect below makes one
- // (until it lands, `currentUserId` is '' and the mutations that need it
- // REJECT — they never write an entity keyed on nobody);
- // • several, none of them created by this session → genuinely AMBIGUOUS (the
- // opt-in fixture seed writes its profiles into my own protected scope), so
- // the answer is NO PROFILE, said loudly. Picking one would be picking a
- // person at random and calling them "you".
- const currentPrincipal = useCurrentPrincipal();
- /** The profiles I own: the reactive profiles whose document is one of mine. */
- const myOwnedProfiles = React.useMemo(() => {
- if (myProtectedDocs === null) return null; // UNKNOWN — never "none"
- const mine = new Set(myProtectedDocs);
- return users.filter(u => mine.has(u.id));
- }, [users, myProtectedDocs]);
- const currentUser = React.useMemo(() => {
- // This session created it → no ambiguity possible, whatever else is owned.
- if (myProfileDocId) return users.find(u => u.id === myProfileDocId);
- if (myOwnedProfiles === null) return undefined; // not answered yet
- if (myOwnedProfiles.length === 1) return myOwnedProfiles[0];
- if (myOwnedProfiles.length === 0) return undefined; // none — one gets created
- // SEVERAL profile documents are mine, and none was created by this session:
- // the fixture seed writes its profiles into my own protected scope, so a
- // reloaded demo wallet lands here. Pick the first by document reference —
- // stable across reloads, and arbitrary, which is honest: while the profile
- // is not a built feature, "which of my fixtures am I" has no true answer.
- //
- // This is NOT the impersonation that was removed. That one reached for a
- // profile by NAME and could land on a document belonging to somebody else.
- // Every candidate here is a document I own, so the invariant that holds is
- // the one that matters: the app never presents another person's profile as
- // mine. Delete this branch the day a profile is really created and known.
- return [...myOwnedProfiles].sort((a, b) => a.id.localeCompare(b.id))[0];
- }, [users, myProfileDocId, myOwnedProfiles]);
- const currentUserId = currentUser?.id || '';
- // Identity-first log prefix, reused by every DATA log below (including the
- // closures defined earlier in this function body — they only execute after
- // this render has finished, by which point `logPrefix` is initialized). Several
- // browser contexts can be tailed at once, so a line must say WHOSE it is: the
- // profile NURI once it is read, and the signed-in principal before that — which
- // is now from the very first render, instead of the anonymous `[app][data]`.
- const logPrefix = currentUserId || currentPrincipal
- ? `[${currentUserId || currentPrincipal}][app][data]`
- : '[app][data]';
- const selectedEvent = events.find(e => e.id === selectedEventId);
- // DISPLAY READ — log participantCount exactly as currently exposed for
- // rendering. Compared against the owner-materializer's WRITE logs below, this
- // pinpoints whether a stuck counter is a DATA problem (never incremented) or a
- // DISPLAY/read problem (incremented but not re-read until the next session).
- if (selectedEvent) {
- console.log(
- `${logPrefix} participation read for display — event=${canonicalEventId(selectedEvent.id)} ` +
- `"${selectedEvent.title}": participantCount lu = ${selectedEvent.participantCount}`,
- );
- }
- const selectedUser = users.find(u => u.id === selectedUserId);
-
- // --- MY PROFILE: which documents are mine ----------------------------------
- // Resolve the PROTECTED documents this identity owns. That set is the whole
- // basis of "which profile is mine", and it is also what decides whether a
- // profile has to be CREATED — so a failure here must never look like an answer:
- // a rejection means UNKNOWN, and reading it as "I own nothing" would create a
- // second profile for someone who already has one. Retried; if it still will not
- // answer, the set stays `null` (no profile resolved, no profile created) and it
- // is said loudly.
- useEffect(() => {
- if (!ready) return;
- let cancelled = false;
- (async () => {
- for (let attempt = 0; !cancelled; attempt++) {
- try {
- const mine = await listMyEntityDocs('protected');
- if (cancelled) return;
- setMyProtectedDocs(prev => [...new Set([...(prev ?? []), ...mine])]);
- return;
- } catch (err) {
- const wait = OWNED_RETRY_BACKOFF_MS[attempt];
- if (wait === undefined) {
- console.error(
- `${logPrefix} my-protected-documents resolution FAILED after ` +
- `${OWNED_RETRY_BACKOFF_MS.length + 1} attempts — which profile is mine is UNKNOWN, ` +
- `not absent: no profile will be resolved and none will be created until it is known:`,
- err,
- );
- return;
- }
- console.warn(
- `${logPrefix} my-protected-documents resolution failed (attempt ${attempt + 1}) — ` +
- `retrying in ${wait}ms:`,
- err,
- );
- await new Promise(r => setTimeout(r, wait));
- }
- }
- })();
- return () => { cancelled = true; };
- }, [ready]);
-
- // --- MY PROFILE: create one when I have none -------------------------------
- // A profile is Festipod's own object, and signing in produces none — so the
- // first time a person signs in, the app makes theirs. Gated on BOTH the
- // protected read having settled (`userQuery.isSuccess`: synced-and-empty, not
- // still-syncing) and the owned-document set being KNOWN, because "I have no
- // profile" is only true when both have answered. Single-shot per session; on
- // failure the guard is released, so a later change to the owned set retries.
- const hasTriedProfileCreate = useRef(false);
- useEffect(() => {
- if (!ready) return;
- if (hasTriedProfileCreate.current) return;
- if (!userQuery.isSuccess) return;
- if (myOwnedProfiles === null) return; // UNKNOWN — never read as "none"
- if (myOwnedProfiles.length > 0) return; // I already have one (or more)
- hasTriedProfileCreate.current = true;
- (async () => {
- console.log(`${logPrefix} no profile of mine — creating one (fields left visibly unset)`);
- const graph = await createEntityDoc('protected');
- // The three fields the UserProfile shape makes mandatory, written with the
- // "not filled in yet" placeholders — nothing here comes from the identity.
- await writeEntity(graph, ENTITY_TYPE.user, {
- name: str(UNSET_PROFILE.name),
- initials: str(UNSET_PROFILE.initials),
- username: str(UNSET_PROFILE.username),
- });
- // This document is MINE — claimed explicitly, so it is recognized as my
- // profile whatever else the protected scope holds (fixtures included).
- setMyProfileDocId(graph);
- setMyProtectedDocs(prev => [...new Set([...(prev ?? []), graph])]);
- // OPTIMISTIC OVERLAY, same pattern as events/participations: surface the
- // profile immediately so `currentUserId` resolves without waiting for the
- // broker push; the reconciliation effect drops it once the read carries it.
- const optimisticProfile: FpUserData = { id: graph, ...UNSET_PROFILE };
- setPendingAddUsers(prev => (prev.some(u => u.id === graph) ? prev : [...prev, optimisticProfile]));
- })().catch(err => {
- hasTriedProfileCreate.current = false;
- console.error(`${logPrefix} creating my profile FAILED — this session has no profile:`, err);
- });
- }, [ready, userQuery.isSuccess, myOwnedProfiles]);
-
- // --- MY PROFILE: say it when the answer is arbitrary ------------------------
- // Several profile documents are mine and none was created by this session (the
- // opt-in fixture seed writes its profiles into my own protected scope). One is
- // picked deterministically so the app stays usable on a demo wallet, but the
- // pick carries no meaning — say so once, or a fixture person silently becomes
- // "you" and nobody wonders why.
- const warnedAmbiguousProfile = useRef(false);
- useEffect(() => {
- if (myProfileDocId) return;
- if (myOwnedProfiles === null || myOwnedProfiles.length <= 1) return;
- if (warnedAmbiguousProfile.current) return;
- warnedAmbiguousProfile.current = true;
- console.warn(
- `${logPrefix} ${myOwnedProfiles.length} profile documents are mine and none was created by ` +
- `this session (a fixture seed run on this wallet is the usual cause). The first by document ` +
- `reference is used as mine — a stable but ARBITRARY pick, so the name shown as yours is ` +
- `demo data, not you.`,
- );
- }, [myOwnedProfiles, myProfileDocId]);
-
- // --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) -------
- // The event OWNER's session materializes its OWN events' inbox deposits into
- // (1) the correct `participantCount` on its OWN event doc, and
- // (2) host-facing "new participant" FpNotifications.
- // This is what makes the count CORRECT and reactive WITHOUT any non-owner ever
- // writing the event doc: the joiner only deposits; the owner counts.
- //
- // Reactive, no polling: subscribe the inbox document via `inbox.watch` (a
- // `doc_subscribe` push). One watch PER owned event: each event has its OWN
- // inbox, whose address its owner obtains with `openDocumentInbox`.
- //
- // IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct
- // active registrations (`materializeAttendance`: distinct join uids MINUS
- // cancelled ones), never an unbounded ±1. A broker re-sync replays the same
- // deposits → same set → same count. `participantCount = activeRegistrations.size`
- // (no host baseline — the creator does not auto-participate). The write is GUARDED
- // (write only when the value actually changes) so re-materializing an unchanged
- // inbox does not thrash the doc / loop the reactive read.
- //
- // OWNER-OFFLINE = EVENTUAL (brief §E.2): only the owner's session runs this, so
- // while the owner is disconnected the count doesn't advance for others (the
- // deposits persist in the inbox — nothing is lost; a future service will
- // materialize on the owner's behalf).
- // Dedup owned events by their CANONICAL id-form (base repo id, stripping any
- // `:v:` suffix): the SAME event can enter `ownedEventIds` under two
- // overlays (create-time vs a later `listMyEntityDocs` backfill), and iterating
- // both would materialize + count-write the same event twice. Keep ONE real NURI
- // per canonical id as the write/anchor target (the count is written on a live
- // doc NURI — never a stripped id). See `canonicalEventId` in registration.ts.
- const ownedEvents = React.useMemo(() => {
- const byCanon = new Map();
- for (const nuri of ownedEventIds) {
- const c = canonicalEventId(nuri);
- if (!byCanon.has(c)) byCanon.set(c, nuri);
- }
- return [...byCanon.values()].sort();
- }, [ownedEventIds]);
- // Primitive identity of the set above — the effect's dependency (an array is a
- // new reference on every render).
- const ownedKey = ownedEvents.join('|');
-
- // WHICH EVENTS MAY THIS SESSION WRITE — the same owned set, asked the other way
- // round. Nothing new is called: the contract makes writing a document and owning
- // it the same thing, so `listMyEntityDocs('public')` (already resolved above for
- // the materializer) is the whole answer. Matched on the CANONICAL id-form, like
- // every other event-id comparison here, so an event reached under a different
- // overlay is still recognized as the same event.
- const getEventOwnership = useCallback((eventId: string): EventOwnership => {
- const canon = canonicalEventId(eventId);
- // A hit is authoritative EVEN BEFORE any listing has answered: an event this
- // session just created was claimed directly, and it is mine whatever a
- // listing later says. Checked FIRST, so ownership always beats a stale miss.
- if (ownedCanonicalIds.has(canon)) return 'mine';
- // A miss counts only for events a resolved listing actually looked past.
- if (ruledOutEventIds.has(canon)) return 'not-mine';
- // Otherwise the answer is genuinely still coming — a listing is pending, has
- // failed, or this event arrived after the last one. Never 'not-mine' here.
- return 'unknown';
- }, [ownedCanonicalIds, ruledOutEventIds]);
- // Last count WRITTEN per owned event (keyed by canonical id), stamped with the
- // cycle that wrote it. The stamp is what makes a STALE cycle harmless — see the
- // guard below.
- const materializedCountRef = useRef>(new Map());
- // Monotonic cycle number, shared by every materialize cycle of this session. It
- // has to outlive the effect: when the owned set changes the effect re-runs, and
- // a cycle started by the PREVIOUS run can still be in flight.
- const cycleSeqRef = useRef(0);
- useEffect(() => {
- if (!ready) return;
- const owned = ownedEvents;
- if (owned.length === 0) return;
- let cancelled = false;
-
- // ONE inbox address per owned event, resolved ONCE here and shared by the two
- // things that must not disagree: the cycle that READS the inbox and the watch
- // that SUBSCRIBES to it. Resolving them apart is how the owner ends up watching
- // one address while a deposit lands in another — the sign-up is then never seen
- // in the session that made it. `openDocumentInbox` is itself single-flight per
- // document (utils/storeRegistry), so the app can no longer open a second inbox
- // at all; this map is the narrower promise that the reader and the watcher hold
- // the very same value.
- //
- // Resolved on first need and kept — but a FAILED resolution is released, not
- // kept: it means the address is UNKNOWN, so the next trigger must really ask
- // again instead of inheriting a permanent "no inbox".
- let resolvingInboxes: Promise> | null = null;
- const inboxesForOwnedEvents = (): Promise> => {
- if (resolvingInboxes) return resolvingInboxes;
- const attempt = (async () => {
- const byEvent = new Map();
- for (const evId of owned) byEvent.set(evId, await openDocumentInbox(evId));
- return byEvent;
- })().catch(err => {
- if (resolvingInboxes === attempt) resolvingInboxes = null;
- throw err;
- });
- resolvingInboxes = attempt;
- return attempt;
- };
-
- const runCycle = async (trigger: string) => {
- if (cancelled) return;
- const seq = ++cycleSeqRef.current;
- try {
- const inboxes = await inboxesForOwnedEvents();
- console.log(
- `${logPrefix} owner participation materialize START (trigger=${trigger}, cycle=${seq}) — ` +
- `${owned.length} owned event(s)`,
- );
- const notifs: FpNotificationData[] = [];
- // Each event has its OWN inbox, and only its OWNER can open it. The address
- // comes from the shared map above — the same one the watch subscribed; a
- // depositor never sees it (they name the document instead).
- for (const [evId, targetInbox] of inboxes) {
- const canonId = canonicalEventId(evId);
- // BEFORE — the event's readable detail (short id + title) and the
- // participantCount value as currently READ/exposed (the app-side `events`
- // state), captured before this cycle's derive+write. Comparing this to the
- // AFTER log below tells whether the counter is a DATA problem (never
- // incremented) or a DISPLAY/read problem (incremented but not re-read).
- //
- // Read through the REF, never the closure: this effect only re-runs on
- // [ready, ownedKey], so the `events` it captured is the snapshot from the
- // render that wired the watch — which is why this line printed `(unknown)`
- // for the whole session and told the last investigation nothing. Matched
- // on the canonical id-form, like every other event-id comparison here.
- const knownEvent = eventsRef.current.find(e => canonicalEventId(e.id) === canonId);
- const knownCount = knownEvent?.participantCount;
- console.log(
- `${logPrefix} participation materialize — event=${canonId}` +
- (knownEvent?.title ? ` "${knownEvent.title}"` : '') +
- ` — participantCount before write (as currently read) = ${knownCount ?? '(unknown)'}`,
- );
- // (1) COUNT — derive the distinct active-registration set for this event
- // and write it on MY OWN event doc (only when it changed). `materializeAttendance`
- // reads through the synced-view contract (`inbox.readSynced`), so a
- // registrant's deposit is visible even on a cold session.
- const active = await materializeAttendance(targetInbox, evId);
- const nextCount = active.length; // no host baseline (creator not auto-in)
- const written = materializedCountRef.current.get(canonId);
- // THE VALUE A CYCLE CARRIES IS ONLY AS FRESH AS THE READ IT CAME FROM.
- // Cycles of this effect can no longer interleave (they are serialized
- // below), but the effect re-runs whenever the owned set changes, and a
- // cycle from the previous run can still be in flight — holding a count it
- // derived BEFORE the fresher one's. Writing it would put the stale value
- // back on the document, which is a count that goes backwards for no
- // visible reason. So a cycle may only overwrite what an OLDER cycle wrote.
- if (written && written.seq > seq) {
- console.log(
- `${logPrefix} owner participation materialize — event=${canonId}: cycle ${seq} is STALE ` +
- `(cycle ${written.seq} already wrote ${written.count}) — not writing ${nextCount}`,
- );
- } else if (written?.count === nextCount) {
- // Write ONLY when the derived value actually changes (anti-loop). This
- // memo does NOT lock in a premature 0: the barrier-gated read above makes
- // the first post-connection materialize see the real deposits, so once the
- // set becomes non-empty the value differs and the correct count is
- // written. A transient write failure reverts the memo so the next trigger
- // retries. The guard's sole job is to avoid re-writing an UNCHANGED value.
- console.log(
- `${logPrefix} owner participation materialize — event=${canonId}: ` +
- `participantCount unchanged (${nextCount}) — no write`,
- );
- } else if (!cancelled) {
- materializedCountRef.current.set(canonId, { count: nextCount, seq });
- console.log(
- `${logPrefix} owner participation materialize — event=${canonId}: ` +
- `participantCount ${written?.count ?? '(none)'} → ${nextCount} (writing own doc, cycle=${seq})`,
- );
- // The write lands on the owned event doc, which `watchShape('public')`
- // already subscribes → the reactive read re-renders the new count on
- // the broker push (no manual re-query).
- let writeOk = true;
- await updateEntityField(evId, evId, 'participantCount', int(nextCount))
- .catch(err => {
- // Revert the memo so a transient write failure retries next trigger
- // — but only if it is still OURS. A fresher cycle's value stands.
- writeOk = false;
- const current = materializedCountRef.current.get(canonId);
- if (current && current.seq === seq) materializedCountRef.current.delete(canonId);
- console.error(`${logPrefix} owner participation materialize count WRITE FAILED:`, err);
- });
- if (writeOk) {
- // AFTER — the write has landed on the owner's own doc. N → M reuses the
- // SAME "before" reference point logged above, so it is directly
- // comparable: a DISPLAY-read log (see `selectedEvent`, above in this
- // file) still showing the old N after this fires means the counter data
- // is fine and it is the read side that lags.
- console.log(
- `${logPrefix} participation materialize — event=${canonId}: ` +
- `participantCount AFTER write = ${knownCount ?? '(unknown)'} → ${nextCount}`,
- );
- }
- }
- // (2) NOTIFICATIONS — surface "new participant" deposits (unchanged T02.c).
- const evNotifs = await readRegistrationNotifications(targetInbox, evId);
- notifs.push(...evNotifs);
- }
- if (!cancelled && notifs.length) {
- setNotifications(prev => {
- const seen = new Set(prev.map(n => n.id));
- const merged = [...prev];
- for (const n of notifs) if (!seen.has(n.id)) { seen.add(n.id); merged.push(n); }
- return merged;
- });
- }
- } catch (err) {
- console.error(`${logPrefix} owner participation materialization failed:`, err);
- }
- };
-
- // A CYCLE IS A READ-DERIVE-WRITE, AND TWO OF THEM MUST NOT OVERLAP. The two
- // triggers below fire within the same instant on the connection that creates an
- // event, and run concurrently they both read the inbox before either writes —
- // so the one that finishes last puts its own, older reading back on the
- // document. Serialized, a request arriving mid-cycle is served by ONE follow-up
- // cycle once the current one has finished (a cycle re-derives everything from
- // the inbox, so one follow-up covers however many requests it coalesces).
- const materialize = createSerialTask(runCycle);
-
- // (A) RELIABLE-AT-CONNECTION: run one materialization directly on this trigger
- // ([ready, ownedKey]). This is the spec's core — the owner, at its NEXT
- // CONNECTION, deterministically processes its owned events' inbox, reading
- // through the synced-view contract. It does NOT depend on a cross-session
- // inbox push arriving.
- void materialize('connection').catch(err =>
- console.error(`${logPrefix} owner participation materialize cycle rejected:`, err));
-
- // (B) SAME-SESSION LIVE: `inbox.watch` fires on the initial state push and on
- // every later deposit visible to THIS verifier (a local deposit, or a remote one
- // that pushes) — no polling. Re-materialize on each so the owner's own session
- // stays live when a deposit does push. Cross-session convergence does NOT rely on
- // this (it relies on (A) at the owner's next connection); this only sharpens the
- // same-session/live case. One watch PER owned event — each event has its OWN
- // inbox document — and the address watched is the one taken from the SHARED map
- // above, so what is watched is exactly what the cycle reads.
- const unsubscribes: Array<() => void> = [];
- void (async () => {
- const inboxes = await inboxesForOwnedEvents();
- if (cancelled) return;
- for (const targetInbox of inboxes.values()) {
- unsubscribes.push(inbox.watch(targetInbox, () =>
- void materialize('inbox-push').catch(err =>
- console.error(`${logPrefix} owner participation materialize cycle rejected:`, err))));
- }
- })().catch(err => console.error(`${logPrefix} owner inbox watch wiring failed:`, err));
- return () => { cancelled = true; for (const stop of unsubscribes) stop(); };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [ready, ownedKey]);
-
- // Protected-sharing act: the app owns the relationship concept — it declares the
- // current identity's own connections (a Festipod domain fact) and, for each
- // bilateral link, hands the SDK directed read grants so an owner's connections
- // may read that owner's PROTECTED entities (public = all; private = owner only).
- // The declaration names only the current user's own peers, asserted as the
- // current identity. The app carries no access CHECK (see knowledge_trust-model)
- // — it only declares its own relationship graph, then trusts the SDK to enforce
- // the resulting per-document grants. No store id, no document NURI crosses here.
- useEffect(() => {
- if (!ready || !currentUserId) return;
- // `inbox.share(doc, toUser)` names a PERSON, so the app models both sides of
- // a relationship in ONE key space: the normalized profile username. It maps
- // each peer IRI → that key before declaring, and asserts AS its own key.
- // Peers with no known profile are skipped (they cannot be named).
- const idKeyOf = (userIri: string): string | undefined => {
- const u = users.find(x => x.id === userIri);
- return u?.username ? normalizeIdentifier(u.username) : undefined;
- };
- const selfKey = idKeyOf(currentUserId);
- if (!selfKey) return;
- const myPeers = friendships
- .filter(f => f.userId === currentUserId || f.friendId === currentUserId)
- .map(f => (f.userId === currentUserId ? f.friendId : f.userId))
- .map(idKeyOf)
- .filter((k): k is string => !!k);
- // Sharing is I/O now (a key handed to each neighbour's inbox), so it is
- // fire-and-forget from this effect — nothing downstream waits on it.
- void declareConnections(myPeers, selfKey)
- .catch(err => console.error(`${logPrefix} declareConnections failed:`, err));
- }, [ready, friendships, currentUserId, users]);
-
- const queries = buildQueries(
- events, users, participations, meetingPoints, friendships, currentUserId,
- );
-
- console.log(`${logPrefix} Render — NG | events:`, events.length,
- '| users:', users.length, '| participations:', participations.length,
- '| selectedEvent:', selectedEvent?.title ?? '(none)');
-
- // --- Mutations (NG) ---
- // Each entity is written as its OWN document, created via the SDK in its scope
- // (`createEntityDoc(scope)`) — never a store-level document. Creating a doc
- // appends its NURI to the scope index, which `watchShape` subscribes; the new
- // entity enters the reactive read on the resulting push (no manual registration).
- // The SDK declares the per-document ReadCap policy on create (public / protected /
- // private) — the app carries no access logic.
-
- const createEvent = useCallback(async (event: Omit): Promise => {
- console.log(`${logPrefix} createEvent (NG):`, event.title);
- // The SDK create returns THIS entity's OWN public document and declares its
- // access policy (public → world-readable). Placement is named by SCOPE
- // alone: the session belongs to one user, so there is no owner to name.
- // A creation that cannot be recorded THROWS — it never hands back a
- // reference that would read empty forever — so this rejects the whole
- // `createEvent` rather than returning a half-made event.
- // Create the event's OWN document in the PUBLIC scope (one doc per entity),
- // then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via
- // the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed
- // per-entity doc against the real broker. The doc's NURI is appended to the
- // public scope index, which `watchShape('public')` subscribes → the event
- // enters the reactive read on the push. The written subject IRI is the `@id`.
- const eventGraph = await createEntityDoc('public');
- // An event is a document people SIGN UP TO, so its owner opens its inbox
- // here, at creation. A document only HAS one if its owner opened it; without
- // this, a registrant's deposit would have nothing to reach.
- await openDocumentInbox(eventGraph);
- const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, {
- title: str(event.title), description: str(event.description), date: str(event.date),
- // `date` is the human LABEL the form composed; these four are the machine
- // values it collected, written UNALTERED as the form's own ISO forms
- // (`YYYY-MM-DD`, `HH:MM`). They are stored alongside the label, never
- // derived from it — the label is display text and cannot be parsed back.
- startDate: str(event.startDate), endDate: str(event.endDate),
- startTime: str(event.startTime), endTime: str(event.endTime),
- location: str(event.location), distance: flt(event.distance),
- // No host notion: the creator merely SIGNALS a public event and is NOT
- // obliged to participate, so the count starts at 0 (the owner-materializer
- // derives it from the active-registration set — |active|, no host baseline).
- participantCount: int(event.participantCount || 0),
- coverImage: str(event.coverImage),
- });
- // OPTION B: this event's doc is MINE (I just created it), so track it as owned
- // → the owner-materializer subscribes to its inbox and maintains its count.
- setOwnedEventIds(prev => (prev.includes(eventGraph) ? prev : [...prev, eventGraph]));
- // OPTIMISTIC OVERLAY: surface the created event IMMEDIATELY (its id = eventId =
- // the doc NURI, the SAME id `watchShape('public')` will surface as `s.subject`,
- // so the reconciliation effect drops this add once the push arrives). Without
- // it, `getEvent(eventId)` right after create returns undefined until the push.
- // participantCount starts at 0 (creator does not auto-participate).
- const optimisticEvent: FpEventData = { ...event, id: eventId, participantCount: event.participantCount || 0 };
- setPendingAddEvents(prev => (prev.some(e => e.id === eventId) ? prev : [...prev, optimisticEvent]));
- // The creator does NOT auto-participate (no host notion — settled product
- // decision): NO participation is written on create. The creator sees "J'y
- // serai" and may join/leave their own event like anyone else.
- if (currentUserId) setSelectedEventId(eventId);
- 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
- // non-public doc. Best-effort: a failed submission must not roll back a
- // successful event creation.
- if (addedEvent) {
- // NO index submission any more. There is no discovery: a reader reaches a
- // document only by FOLLOWING A LINK it was given. Announcing a public event
- // beyond its creator is therefore a Festipod-level concern (a directory
- // document whose link the app knows), not an SDK call — see the directory
- // work. Until it lands, a created event is reachable by its creator only.
- }
- return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
- }, [currentUserId]);
-
- const updateEvent = useCallback(async (id: string, updates: Partial) => {
- console.log(`${logPrefix} updateEvent (NG):`, id, updates);
- // The event's `@id` IS its own document NURI (one entity = one document), so
- // it is both the write graph and the subject. Persist each provided mutable
- // field DIRECTLY via SPARQL (the durable write); `watchShape` re-reads on the
- // resulting broker push (the doc is already subscribed) — no manual re-query.
- // No pre-flight NURI guard: every SDK entry takes the reference as it stands
- // and validates at its own door.
- const graph = id;
- const persists: Promise[] = [];
- if (updates.participantCount !== undefined) {
- persists.push(updateEntityField(graph, id, 'participantCount', int(updates.participantCount)));
- }
- 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)));
- // The machine dates/times travel with the label, not instead of it. A field
- // arriving as `''` (the form's empty date input) CLEARS the triple rather than
- // leaving a stale value behind — that is `updateEntityField`'s empty handling,
- // and it is the right reading here: the user emptied the input.
- if (updates.startDate !== undefined) persists.push(updateEntityField(graph, id, 'startDate', str(updates.startDate)));
- if (updates.endDate !== undefined) persists.push(updateEntityField(graph, id, 'endDate', str(updates.endDate)));
- if (updates.startTime !== undefined) persists.push(updateEntityField(graph, id, 'startTime', str(updates.startTime)));
- if (updates.endTime !== undefined) persists.push(updateEntityField(graph, id, 'endTime', str(updates.endTime)));
- 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(`${logPrefix} persist event update failed:`, err));
- }, []);
-
- const joinEvent = useCallback(async (eventId: string, userId?: string) => {
- const uid = userId || currentUserId;
- console.log(`${logPrefix} joinEvent (NG):`, eventId, 'user:', uid);
- // A Participation MUST carry a user reference (SHEX `fp:user` is mandatory) —
- // writing one without it produces an entity the read drops (the participation
- // silently never round-trips). REJECT rather than return: returning here made
- // the sign-up a no-op that wrote nothing, threw nothing and let the screen
- // congratulate the user. The cause is named, because it is actionable: no
- // profile of mine is resolved yet.
- if (!uid) {
- const msg =
- `joinEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
- `so a Participation would carry no fp:user and would never round-trip. ` +
- `Wait for the profile to be created/read, or resolve the ambiguity reported above.`;
- console.error(`${logPrefix} ${msg}`);
- throw new Error(msg);
- }
- // IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set.
- // 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.
- //
- // A FAILED count is UNKNOWN, not zero, so it is NOT caught here: reading it as
- // "not participating yet" is precisely how a duplicate gets written. The
- // rejection propagates out of `joinEvent`, and the screen that called it says
- // the sign-up could not be recorded — which is the truth.
- const already = await countUserParticipations(eventId, uid);
- if (already > 0) {
- console.log(`${logPrefix} Already participating (broker-confirmed), skipping`);
- return;
- }
- // 1) Persist the Participation as its OWN document in the PROTECTED scope
- // (one doc per entity). Its NURI is appended to the protected scope index,
- // which `watchShape('protected')` subscribes → the participation enters
- // the reactive read on the push.
- // A creation that cannot be recorded THROWS, so a failure here rejects
- // `joinEvent` instead of depositing a registration for a participation
- // document that would read empty forever.
- const partGraph = await createEntityDoc('protected');
- // WRITE the participation RDF DIRECTLY into its own document (writeEntity) —
- // not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed
- // per-entity doc against the real broker). The written subject is the
- // participation's `@id` (its own graph is partGraph, used later by the
- // authoritative delete).
- await writeEntity(partGraph, ENTITY_TYPE.participation, {
- event: iri(eventId), user: iri(uid), isConfirmed: bool(true),
- });
- // OPTIMISTIC OVERLAY: surface the participation IMMEDIATELY so the joiner shows
- // up in the list and `isParticipating` is true before the broker push. Its id =
- // partGraph = the doc NURI, the SAME id `watchShape('protected')` will surface
- // as `s.subject` (adaptParticipations sets `id: s.subject`) → the reconciliation
- // effect drops this add once the push arrives. If a stale pendingRemove targeted
- // this exact id (re-join same doc — never happens, ids are fresh), clear it too.
- const optimisticPart: FpParticipationData = { id: partGraph, eventId, userId: uid, isConfirmed: true };
- setPendingAddParticipations(prev => (prev.some(p => p.id === partGraph) ? prev : [...prev, optimisticPart]));
- // OPTION B (brief §B): the joiner does NOT write `participantCount` on the
- // EVENT doc — that doc belongs to the OWNER, and a non-owner write there is an
- // isolation violation (NextGraph write is membership-bound; there is no append
- // — see brief §1). The count now moves only via the OWNER materializing this
- // deposit onto its OWN event doc (the `hostedEventIds` materializer below).
- // The joiner's sole writes are: their OWN participation doc (above) + the
- // inbox DEPOSIT (below). While the owner is offline the count doesn't advance
- // for others — accepted eventual behaviour (brief §E.2); nothing is lost.
- // 2) Tell the host. THE DEPOSIT IS THE DELIVERY: `inbox.postToDocument(doc, …)`
- // is what the surface publishes for reaching a document's owner — anyone
- // may deposit, only the owner reads. The host's notification is then built
- // on the OWNER's side, out of the deposits it reads from its own event's
- // inbox (`readRegistrationNotifications`, in the materializer above).
- //
- // Nothing else is written here. A host FpNotification used to be minted at
- // this point into the JOINER's own protected scope with `recipient` set to
- // the event — a document the host cannot read, and never will: the joiner
- // owns it and the surface has no way to hand it over. It also pushed that
- // notification into THIS session's own list, so the joiner saw a
- // "new participant" notice addressed to someone else. Both are gone; the
- // deposit alone carries the news, and it reaches its reader.
- //
- // Best-effort: a failed deposit must not roll back a successful
- // registration (the participation document is already written).
- try {
- const registrantId = uid || null; // no current user → anonymous deposit
- // The event's `@id` IS its document NURI, and a deposit NAMES that document
- // — the joiner resolves no inbox and holds no address.
- // Carry the joiner's participation-doc NURI so the owner (if a connection)
- // could read it in clear; the count itself does not depend on reading it.
- console.log(
- `${logPrefix} joinEvent — depositing participation registration into event inbox: ` +
- `event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` +
- `materializing this deposit on its own doc, at its next connection)`,
- );
- const { uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
- // Remember the join uid so a same-session leave can cancel it precisely.
- joinUidsRef.current.set(`${eventId}|${uid}`, depositUid);
- } catch (err) {
- console.error(`${logPrefix} joinEvent inbox deposit failed:`, err);
- }
- }, [events, currentUserId]);
-
- const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
- const uid = userId || currentUserId;
- console.log(`${logPrefix} leaveEvent (NG):`, eventId, 'user:', uid);
- // 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);
- // REJECT rather than pretend. Withdrawal is AUTHORITATIVE (see
- // caveat_participation-deletion): the caller only reaches here because it
- // believes a participation exists, so finding none is a real disagreement
- // about the state — either no profile of mine is resolved (`uid` empty), or
- // the participation the screen showed is not in the set. Returning silently
- // deleted nothing while the screen announced a withdrawal, and the sign-up
- // came back on the next read.
- if (!part) {
- const msg = uid
- ? `leaveEvent refused for event=${canonicalEventId(eventId)}: no participation of user=${uid} ` +
- `in the participation set — nothing was deleted, so the withdrawal must not be announced.`
- : `leaveEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
- `so the participation to withdraw cannot even be named.`;
- console.error(`${logPrefix} ${msg}`);
- throw new Error(msg);
- }
- // 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(`${logPrefix} SPARQL DELETE participation failed:`, err);
- throw err instanceof Error ? err : new Error(String(err));
- }
- // 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 = `${logPrefix} leaveEvent: SPARQL delete removed nothing ` +
- `(before=${result.before}, remaining=${result.remaining}, bySubject=${result.bySubject}) ` +
- `for event=${eventId} user=${uid} — NOT flipping UI (would resurrect).`;
- console.error(msg);
- throw new Error(msg);
- }
- // OPTIMISTIC OVERLAY: the delete is broker-confirmed (remaining === 0) but the
- // reactive `watchShape('protected')` set only drops the participation on the
- // NEXT push (a beat later). Mark its id removed NOW so the leaver disappears
- // from the list immediately; the reconciliation effect clears this pendingRemove
- // once the reactive set no longer carries the id. Also drop it from
- // pendingAddParticipations in case it was still only-optimistic (join+leave in
- // the same session before the join's push landed).
- setPendingRemoveIds(prev => {
- if (prev.has(part.id)) return prev;
- const next = new Set(prev);
- next.add(part.id);
- return next;
- });
- setPendingAddParticipations(prev => {
- const next = prev.filter(p => p.id !== part.id);
- return next.length === prev.length ? prev : next;
- });
- // OPTION B, symmetric (brief §B "Désinscription"): the leaver does NOT write
- // `participantCount` on the EVENT doc (owner-owned — same isolation violation
- // as the join). Instead it DEPOSITS a `leave-participant` marker into the
- // event inbox; the OWNER materializes it and recomputes the count on its OWN
- // doc (idempotent — a re-synced leave never double-decrements, since the count
- // is derived from the SET of distinct active registrations, not from −1).
- try {
- const registrantId = uid || null;
- // Same as `joinEvent`: the leave marker NAMES the event document; no inbox
- // address is resolved on the depositor's side.
- // Carry the join uid when this session minted it (precise cancellation);
- // otherwise the owner falls back to (eventId, userId) matching.
- const regUid = joinUidsRef.current.get(`${eventId}|${uid}`);
- console.log(
- `${logPrefix} leaveEvent — depositing participation leave marker into event inbox: ` +
- `event=${canonicalEventId(eventId)} user=${uid} regUid=${regUid ?? '(none)'}`,
- );
- await depositLeave(eventId, registrantId, regUid);
- joinUidsRef.current.delete(`${eventId}|${uid}`);
- } catch (err) {
- console.error(`${logPrefix} leaveEvent inbox deposit failed:`, err);
- }
- // The participation doc is subscribed by `watchShape('protected')`; the SPARQL
- // DELETE pushes → the reactive read drops it (`isParticipating` reflects it).
- // The count itself follows the owner's materialization of the leave marker
- // (reactive, cross-session).
- }, [participations, events, currentUserId]);
-
- const addMeetingPoint = useCallback((mp: Omit) => {
- setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
- }, []);
-
- const addFriend = useCallback((friendId: string) => {
- setFriendships(prev => {
- if (prev.some(f =>
- (f.userId === currentUserId && f.friendId === friendId) ||
- (f.userId === friendId && f.friendId === currentUserId)
- )) return prev;
- return [...prev, { id: `ng-fr-${Date.now()}`, userId: currentUserId, friendId }];
- });
- }, [currentUserId]);
-
- const updateProfile = useCallback(async (updates: Partial) => {
- console.log(`${logPrefix} updateProfile (NG):`, updates);
- // MY profile is its own document (subject IRI = doc NURI), and it is the ONLY
- // document these edits may land in. There used to be a `?? users[0]` fallback
- // here: with no profile resolved, a person editing their own pseudo wrote it
- // into a stranger's profile document. Refuse instead — and say which of the
- // two reasons it is, since only one of them clears up on its own.
- const target = currentUser;
- if (!target) {
- const msg =
- `updateProfile refused: no profile of mine is resolved, and these edits must never land in ` +
- `someone else's profile document.`;
- console.error(`${logPrefix} ${msg}`);
- throw new Error(msg);
- }
- const graph = target.id;
- const persists: Promise[] = [];
- 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(`${logPrefix} persist profile update failed:`, err));
- }, [currentUser, users]);
-
- const loadTestData = useCallback(async (): Promise => {
- console.log(`${logPrefix} loadTestData (NG)`);
- // THIS NO LONGER SEEDS. `bootstrapWallet` refuses while the fixture seed is
- // switched off (`fixtureSeedEnabled`), so what comes back is `seeded: false`
- // with no documents, and everything below is a no-op on that empty result.
- // The call is kept, and kept honest, rather than removed: the switch is what
- // decides, in one place, and flipping it back restores this path unchanged.
- // An EXPLICIT load is authoritative — SUPPRESS the dev auto-seed so only ONE
- // seed runs (marking the guard at the START, before the awaited seed, closes
- // the window where the auto-seed effect could also fire on a still-empty read).
- hasTriedAutoSeed.current = true;
- // Same reading as the auto-seed gate: my own profile is not "data in the
- // wallet" — it exists on every wallet from the first sign-in, so counting it
- // would make an explicit load a permanent no-op.
- const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
- const result = await bootstrapWallet(walletHasData, createEntityDoc);
- // The seeded per-entity docs are appended to their scope indices, which
- // `watchShape` subscribes → they enter the reactive reads on the push. No
- // manual registration / re-list.
- // OWNERSHIP is a different question from READING, and it is not answered by
- // the push: these public event docs were created by THIS session, so claim
- // them — otherwise the owner-materializer never opens their inboxes and their
- // `participantCount` is never derived.
- claimOwnedEventDocs(result.createdDocs.public);
- // The seeded PROTECTED docs are deliberately NOT claimed as mine: they are
- // FIXTURES (nobody signed in as them), and folding them into the owned-profile
- // set is exactly what would make "which profile is mine" ambiguous.
- return result;
- }, [events.length, users, myProfileDocId, claimOwnedEventDocs]);
-
- return {
- currentUserId, currentUser, currentPrincipal,
- events, users,
- participations,
- meetingPoints,
- friendships,
- notifications,
- selectedEventId, setSelectedEventId, selectedEvent,
- selectedUserId, setSelectedUserId, selectedUser,
- ...queries,
- getEventOwnership,
- createEvent, updateEvent, joinEvent, leaveEvent,
- addMeetingPoint, addFriend, updateProfile, loadTestData,
- };
-}
-
-// ============================================================================
-// Provider — switches between local and NG
-// ============================================================================
-
export function LocalDataProvider({ children, empty }: { children: ReactNode; empty?: boolean }) {
const data = useLocalData(empty);
return {children} ;
}
-function NgDataProvider({ children }: { children: ReactNode }) {
- const data = useNgData();
- return {children} ;
-}
+// ============================================================================
+// Dispatcher — which data path this page runs
+// ============================================================================
export function FestipodDataProvider({ children }: { children: ReactNode }) {
const { status } = useNextGraph();
- // SIGNING IN MUST HAVE SETTLED BEFORE THE DATA PROVIDER STARTS. Every call the
- // NG provider makes addresses "MY documents" — placement by scope
- // (`createEntityDoc` / `listMyEntityDocs`) and the reactive scope reads alike —
- // and there is no "my" until `ensureIdentity()` has answered. Being CONNECTED is
- // not being SIGNED IN: the session opens first, the identity settles after. A
- // provider mounted in between fires its reads and its owned-documents
- // resolution against no identity — they fail once, at mount, and the memoized
- // observables never retry, so the screens stay empty for the whole session
- // while the writes that come later succeed.
- //
- // The order is carried HERE, by which provider is mounted, rather than by a
- // guard repeated in every effect — the invariant cannot then be forgotten by
- // the next call site.
const signedIn = useCurrentPrincipal() !== '';
- console.log('[app][data] Provider — NG status:', status, '| signed in:', signedIn);
+ const hasRefineSession = useCurrentSession() !== undefined;
+ const backendName = initialBackendName();
- if (status === 'connected' && signedIn) {
- return {children} ;
+ const mode = resolveDataProviderMode({
+ ngStatus: status,
+ signedIn,
+ hasRefineSession,
+ backendName,
+ });
+ console.log('[app][data] Provider — mode:', mode, '| NG status:', status, '| backend:', backendName);
+
+ switch (mode) {
+ case 'ng':
+ return {children} ;
+ case 'refine':
+ return {children} ;
+ case 'local-empty':
+ return {children} ;
+ case 'local':
+ return {children} ;
}
- if (status === 'connecting' || status === 'connected') {
- // NG initializing, or connected but not signed in yet: show empty state (no
- // misleading seed data flash, and no read issued before there is a "my").
- return {children} ;
- }
- // Disconnected or error: demo mode with seed data
- return {children} ;
-}
-
-// ============================================================================
-// Hook
-// ============================================================================
-
-export function useFestipodData() {
- const context = useContext(FestipodDataContext);
- if (!context) {
- throw new Error('useFestipodData must be used within a FestipodDataProvider');
- }
- return context;
}
diff --git a/src/shared/context/dataProviderMode.test.ts b/src/shared/context/dataProviderMode.test.ts
new file mode 100644
index 0000000..670f7d5
--- /dev/null
+++ b/src/shared/context/dataProviderMode.test.ts
@@ -0,0 +1,36 @@
+import { expect, test } from 'bun:test';
+import { resolveDataProviderMode } from './dataProviderMode';
+
+const base = {
+ ngStatus: 'disconnected' as const,
+ signedIn: false,
+ hasRefineSession: false,
+ backendName: 'activitypods',
+};
+
+// A live NextGraph session wins over everything — this is how the @data broker
+// harness (which mounts NextGraphProvider at the root URL) keeps its path even
+// though the URL names the default ActivityPods backend.
+test('a connecting or connected-but-unsigned NG session shows an empty local state', () => {
+ expect(resolveDataProviderMode({ ...base, ngStatus: 'connecting' })).toBe('local-empty');
+ expect(resolveDataProviderMode({ ...base, ngStatus: 'connected', signedIn: false })).toBe('local-empty');
+});
+
+test('a connected and signed-in NG session routes to the polyfill path', () => {
+ expect(resolveDataProviderMode({ ...base, ngStatus: 'connected', signedIn: true })).toBe('ng');
+ expect(resolveDataProviderMode({ ...base, ngStatus: 'connected', signedIn: true, hasRefineSession: true })).toBe('ng');
+});
+
+test('an ActivityPods session with a registered backend routes to refine', () => {
+ expect(resolveDataProviderMode({ ...base, hasRefineSession: true })).toBe('refine');
+ expect(resolveDataProviderMode({ ...base, hasRefineSession: true, backendName: undefined })).toBe('local');
+});
+
+test('no session at all serves the seeded demo (offline, unconfigured backend)', () => {
+ expect(resolveDataProviderMode(base)).toBe('local');
+ expect(resolveDataProviderMode({ ...base, backendName: undefined })).toBe('local');
+});
+
+test('an NG error without a refine session falls back to the demo, like disconnected', () => {
+ expect(resolveDataProviderMode({ ...base, ngStatus: 'error' })).toBe('local');
+});
diff --git a/src/shared/context/dataProviderMode.ts b/src/shared/context/dataProviderMode.ts
new file mode 100644
index 0000000..d47cb53
--- /dev/null
+++ b/src/shared/context/dataProviderMode.ts
@@ -0,0 +1,45 @@
+/**
+ * Which data path a page runs — the dispatcher's decision, pure.
+ *
+ * The rule is SESSION REALITY FIRST, backend name second:
+ *
+ * - A live NextGraph session wins — this covers the `nextgraph` URL variant
+ * AND the `@data` broker harness, which mounts `NextGraphProvider` directly
+ * at the root URL. While it connects, or once connected but before the one
+ * identity await settles, the app shows an EMPTY local state: no reads may
+ * fire before "who am I" is answered.
+ * - Otherwise, an ActivityPods session (published by AuthGate) routes to the
+ * registered Refine backend — the default. Only the session distinguishes
+ * "authenticated" from "offline demo": with a session the screens read the
+ * Pod; without one (unconfigured backend, "Continuer hors ligne") they get
+ * the seeded demo.
+ */
+
+export type DataProviderMode = 'ng' | 'refine' | 'local-empty' | 'local';
+
+export interface DataProviderModeInput {
+ /** The NextGraph connection status ('disconnected' when no provider is mounted). */
+ ngStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
+ /** Whether the one identity await has settled (NextGraph path). */
+ signedIn: boolean;
+ /** Whether an ActivityPods session exists. */
+ hasRefineSession: boolean;
+ /** The backend the URL selected (`undefined` when none is registered). */
+ backendName: string | undefined;
+}
+
+export function resolveDataProviderMode(input: DataProviderModeInput): DataProviderMode {
+ const { ngStatus, signedIn, hasRefineSession, backendName } = input;
+
+ if (ngStatus === 'connecting') return 'local-empty';
+ if (ngStatus === 'connected') {
+ // The NG harness and the nextgraph variant both gate on the identity
+ // settling before exposing anything; the data path follows the same rule.
+ return signedIn ? 'ng' : 'local-empty';
+ }
+
+ // No live NextGraph session: the polyfill path is not mounted (its status
+ // stays 'disconnected' without a provider, including at the root variant).
+ if (hasRefineSession && backendName) return 'refine';
+ return 'local';
+}
diff --git a/src/shared/context/festipodDataBase.ts b/src/shared/context/festipodDataBase.ts
new file mode 100644
index 0000000..545835e
--- /dev/null
+++ b/src/shared/context/festipodDataBase.ts
@@ -0,0 +1,222 @@
+/**
+ * FestipodDataContext — the screen-facing data contract, and the shared pieces
+ * every provider implementation builds on.
+ *
+ * The app signs up to a MEETING POINT, not to an event (product model): an
+ * event is only the public anchor meeting points graft onto. So the
+ * participation surface is `joinMeetingPoint` / `leaveMeetingPoint`; there is
+ * no event-level join. Provider implementations:
+ *
+ * - `LocalDataProvider` (`FestipodDataContext.tsx`) — the disconnected demo,
+ * seed fixtures, mutations as no-ops. What the `@ui` layer renders against.
+ * - `RefineDataProvider` (`refineData.tsx`) — the ActivityPods backend behind
+ * the Refine seam: CRUD through the data provider, joins/leaves through the
+ * outbox service beside it.
+ * - `NgDataProvider` (`ngData.tsx`) — the suspended NextGraph+polyfill path,
+ * preserved for the `nextgraph` URL variant; NOT mounted at the root.
+ */
+
+import { createContext, useContext } from 'react';
+import type {
+ FpEventData,
+ FpUserData,
+ FpParticipationData,
+ FpMeetingPointData,
+ FpFriendshipData,
+ FpNotificationData,
+} from '../data/types';
+import type { BootstrapResult } from '../utils/ngBootstrap';
+import { normalizeIdentifier } from '../utils/identifier';
+
+// ============================================================================
+// Context interface
+// ============================================================================
+
+/**
+ * Whether this session may WRITE an event's document — asked the same way on
+ * every backend: only a record's owner writes it. `'unknown'` is a REAL third
+ * answer (the read that settles it may not have landed yet), never a polite
+ * `'not-mine'`: collapsing it is how an owner gets told, silently and wrongly,
+ * that the thing is not theirs.
+ */
+export type EventOwnership = 'mine' | 'not-mine' | 'unknown';
+
+export interface FestipodDataContextValue {
+ currentUserId: string;
+ currentUser: FpUserData | undefined;
+ /**
+ * WHO THE SESSION SIGNED IN AS — the identity signing in returned, known
+ * before any document is read. For display (and log attribution) only: it is
+ * a different id space from `currentUserId` (a profile document URI), it is
+ * never written into an entity, and no data call takes it.
+ */
+ currentPrincipal: string;
+
+ events: FpEventData[];
+ users: FpUserData[];
+ participations: FpParticipationData[];
+ meetingPoints: FpMeetingPointData[];
+ friendships: FpFriendshipData[];
+ /** Host-facing notifications. Empty on backends without an inbox read. */
+ notifications: FpNotificationData[];
+
+ getEvent(id: string): FpEventData | undefined;
+ getUser(id: string): FpUserData | undefined;
+ getEventParticipants(eventId: string): FpUserData[];
+ getUserEvents(userId: string): FpEventData[];
+ /** Membership test against a MEETING POINT id (defaults to the current user). */
+ isParticipating(meetingPointId: string, userId?: string): boolean;
+ getFriends(userId?: string): FpUserData[];
+ getEventMeetingPoints(eventId: string): FpMeetingPointData[];
+ /**
+ * May this session WRITE this event? Callers MUST treat `'unknown'` as its
+ * own case — see {@link EventOwnership}.
+ */
+ getEventOwnership(eventId: string): EventOwnership;
+
+ selectedEventId: string;
+ setSelectedEventId(id: string): void;
+ selectedEvent: FpEventData | undefined;
+ selectedUserId: string;
+ setSelectedUserId(id: string): void;
+ selectedUser: FpUserData | undefined;
+
+ createEvent(event: Omit): Promise;
+ updateEvent(id: string, updates: Partial): void | Promise;
+ /**
+ * Sign up to a meeting point. Backend-specific delivery (outbox `as:Join` on
+ * ActivityPods; a participation record on the suspended NextGraph path), one
+ * product fact everywhere: afterwards this user counts among the meeting
+ * point's participants.
+ */
+ joinMeetingPoint(meetingPointId: string, userId?: string): Promise | void;
+ /** Withdraw from a meeting point — the symmetric fact to `joinMeetingPoint`. */
+ leaveMeetingPoint(meetingPointId: string, userId?: string): Promise | void;
+ addMeetingPoint(mp: Omit): void;
+ addFriend(friendId: string): void;
+ updateProfile(updates: Partial): void | Promise;
+ loadTestData(): Promise;
+}
+
+export const FestipodDataContext = createContext(null);
+
+// ============================================================================
+// Helpers shared by every provider
+// ============================================================================
+
+let idCounter = 100;
+export function nextId(prefix: string): string {
+ return `${prefix}-${++idCounter}`;
+}
+
+// LEGACY, READ ONLY. Participations written by an older version key their user
+// (`fp:user`) as `urn:festipod:user:` instead of the UserProfile's
+// `did:ng:` document NURI. Nothing MINTS this any more — today's writes carry
+// the profile NURI (`currentUserId`) — but `resolveParticipantUser` must still
+// recognize the old form, or those participations render as "participant
+// inconnu".
+export const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:';
+
+/**
+ * Resolve a Participation's `fp:user` to its UserProfile. TODAY'S WRITES need
+ * no resolving (`fp:user` carries the profile's own document id, the direct
+ * match — tried first). LEGACY ONLY: `urn:festipod:user:` matches no
+ * profile id, so the handle is stripped and compared to the normalized
+ * username — a READ-side survival for old data, playing no part in deciding
+ * who the current user is.
+ */
+export function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined {
+ const direct = users.find(u => u.id === userId);
+ if (direct) return direct;
+ const key = userId.startsWith(USER_PRINCIPAL_PREFIX)
+ ? userId.slice(USER_PRINCIPAL_PREFIX.length)
+ : userId;
+ return users.find(u => u.username && normalizeIdentifier(u.username) === key);
+}
+
+/**
+ * What a BRAND-NEW profile is created with — "not filled in yet" placeholders,
+ * replaced by the user through `updateProfile`. A profile is the app's own
+ * object and signing in produces none of it; nothing here is derived from the
+ * signed-in identity.
+ */
+export const UNSET_PROFILE = {
+ name: 'Profil à compléter',
+ initials: '?',
+ username: '(pseudo non défini)',
+} as const;
+
+/**
+ * The shared in-memory queries — the same logic for every provider, fed with
+ * that provider's arrays. Cross-entity derivations only: nothing here touches
+ * a backend.
+ *
+ * THE `participations` ARRAY'S `eventId` IS THE JOINED TARGET'S ID. On the
+ * local/NextGraph providers that is an event; on the ActivityPods provider it
+ * is a meeting point. `isParticipating` and `getUserEvents` filter on it
+ * as-is, so a provider whose participations are keyed differently (the
+ * ActivityPods reader derives event-level participants from the meeting
+ * points) overrides those two queries and reuses the rest.
+ */
+export function buildQueries(
+ events: FpEventData[],
+ users: FpUserData[],
+ participations: FpParticipationData[],
+ meetingPoints: FpMeetingPointData[],
+ friendships: FpFriendshipData[],
+ currentUserId: string,
+) {
+ const getEvent = (id: string) => events.find(e => e.id === id);
+ const getUser = (id: string) => users.find(u => u.id === id);
+
+ const getEventParticipants = (eventId: string) => {
+ // Resolve each participation's `user` to its UserProfile across the id
+ // spaces — see `resolveParticipantUser`. Deduped, returned in `users`
+ // order. Whoever stays unresolved (a profile the reader may not read) is
+ // NOT here — the count on the event is the total, the screens render the
+ // remainder as generic participants.
+ const eventParts = participations.filter(p => p.eventId === eventId);
+ const resolvedIds = new Set();
+ for (const p of eventParts) {
+ const u = resolveParticipantUser(p.userId, users);
+ if (u) resolvedIds.add(u.id);
+ }
+ return users.filter(u => resolvedIds.has(u.id));
+ };
+
+ const getUserEvents = (userId: string) => {
+ const partEventIds = participations.filter(p => p.userId === userId).map(p => p.eventId);
+ return events.filter(e => partEventIds.includes(e.id));
+ };
+
+ const isParticipating = (meetingPointId: string, userId?: string) => {
+ const uid = userId || currentUserId;
+ return participations.some(p => p.eventId === meetingPointId && p.userId === uid);
+ };
+
+ const getFriends = (userId?: string) => {
+ const uid = userId || currentUserId;
+ const friendIds = friendships
+ .filter(f => f.userId === uid || f.friendId === uid)
+ .map(f => f.userId === uid ? f.friendId : f.userId);
+ return users.filter(u => friendIds.includes(u.id));
+ };
+
+ const getEventMeetingPoints = (eventId: string) => {
+ return meetingPoints.filter(mp => mp.eventId === eventId);
+ };
+
+ return { getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints };
+}
+
+// ============================================================================
+// Hook
+// ============================================================================
+
+export function useFestipodData() {
+ const context = useContext(FestipodDataContext);
+ if (!context) {
+ throw new Error('useFestipodData must be used within a FestipodDataProvider');
+ }
+ return context;
+}
diff --git a/src/shared/context/ngData.tsx b/src/shared/context/ngData.tsx
new file mode 100644
index 0000000..75b8c5d
--- /dev/null
+++ b/src/shared/context/ngData.tsx
@@ -0,0 +1,1281 @@
+/**
+ * NgDataProvider — the suspended NextGraph+polyfill data path.
+ *
+ * PRESERVED, NOT MOUNTED AT THE ROOT. Per the 2026-10-09 directive (concept
+ * `data-layer`, `brief_2026-10-09_activitypods-first-backend`) ActivityPods is
+ * the sole priority backend; this path stays in the repo for the `nextgraph`
+ * URL variant and the `@data` broker harness, and the dispatcher
+ * (`FestipodDataContext.tsx`) routes to it only when a live NextGraph session
+ * is present. Everything below is the pre-Refine implementation: reactive
+ * `watchShape` reads, per-entity documents in scopes, inbox deposits and the
+ * owner materializer.
+ *
+ * THE MEETING-POINT-CENTRIC SURFACE, ON THIS PATH. The product model signs up
+ * to a meeting point; NextGraph persists no meeting point document (the local
+ * list below is session state only), so `joinMeetingPoint`/`leaveMeetingPoint`
+ * carry the id they are given — the event id in every flow that exists today —
+ * and write the participation record exactly as the event-join they replace
+ * did. The participation's target IS the joined target, whatever id that is.
+ */
+
+import React, { useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
+import type {
+ FpEventData,
+ FpUserData,
+ FpParticipationData,
+ FpMeetingPointData,
+ FpFriendshipData,
+ FpNotificationData,
+} from '../data/types';
+import {
+ depositRegistration,
+ depositLeave,
+ readRegistrationNotifications,
+ materializeAttendance,
+ deleteParticipation,
+ countUserParticipations,
+ canonicalEventId,
+} from '../data/registration';
+import { inbox } from '@ng-eventually/polyfill';
+import type { Nuri } from '@ng-eventually/polyfill';
+import { useNextGraph } from './NextGraphContext';
+import { normalizeIdentifier } from '../utils/identifier';
+// Relationship is a Festipod concept: the app keeps its own bilateral registry
+// and shares its own documents' keys with its neighbours (see shared/utils/connections).
+import { declareConnections } from '../utils/connections';
+import {
+ listMyEntityDocs,
+ createEntityDoc,
+ openDocumentInbox,
+} from '../utils/storeRegistry';
+import { useCurrentPrincipal } from '../utils/currentPrincipal';
+import { createSerialTask } from '../utils/serialTask';
+import { useShapeQuery } from '../data/useShapeQuery';
+import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters';
+// The ORM generator emits BARE shape names (`EventShapeType`, `Event`, …), taken
+// from each shape's IRI local name. They are aliased back to the `Fp*` spelling
+// HERE, at the import, so nothing downstream depends on the generator's naming:
+// `Event` and `Notification` are DOM globals, and the bare names collide with
+// the app's own vocabulary (`FpEventData` & co. in `data/types.ts`). The prefix cannot
+// be restored at the generator — the emitted name follows the shape IRI, and those
+// IRIs are the persisted RDF classes (`ENTITY_TYPE`), not ours to rename.
+import {
+ EventShapeType as FpEventShapeType,
+ UserProfileShapeType as FpUserProfileShapeType,
+ ParticipationShapeType as FpParticipationShapeType,
+} from '../shapes/orm/festipodShapes.shapeTypes';
+import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
+import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
+import { autoSeedEnabled, fixtureSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
+import {
+ FestipodDataContext,
+ buildQueries,
+ UNSET_PROFILE,
+ type FestipodDataContextValue,
+ type EventOwnership,
+} from './festipodDataBase';
+
+/** Waits between attempts at resolving an owned-document set (see the effects). */
+const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000];
+
+// ============================================================================
+// NextGraph-connected provider — reads directly from NG wallet
+// ============================================================================
+
+function useNgData(): FestipodDataContextValue {
+ const { session } = useNextGraph();
+ // 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 via the SDK's reactive,
+ // `useQuery`-shaped surface `watchShape(shape, scope)` (bound to React by
+ // `useShapeQuery`). The observable resolves the scope to the current identity's
+ // wallet (its own scope docs + discovery for public), awaits the sync barrier,
+ // and pushes on every change — no bespoke re-query, no manual doc listing, no
+ // per-doc subscription in the app. See rule_app-uses-sdk-surface-only.
+ const ready = !!session;
+
+ // --- REACTIVE READS via the SDK surface (`watchShape` bound with useShapeQuery) --
+ // Three scoped shape reads, mapped to the app's domain types. Each is reactive
+ // (broker push, no polling): a locally-created entity, a seeded doc, or a remote
+ // peer's public event all re-render through the observable's own subscriptions.
+ // • events = public (my own public event docs + discovery index)
+ // • profiles/users = protected (my own)
+ // • participations = protected (my own; cap-filtered by the SDK)
+ const eventQuery = useShapeQuery(FpEventShapeType, 'public');
+ const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected');
+ const partQuery = useShapeQuery(FpParticipationShapeType, 'protected');
+ // The RAW reactive sets straight from `watchShape` (before the optimistic
+ // overlay). The exposed `events`/`users`/`participations` merge these with the
+ // pending overlay below — see the "OPTIMISTIC OVERLAY" block.
+ const reactiveUsers = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
+ const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]);
+ const reactiveParticipations = React.useMemo(
+ () => adaptParticipations(partQuery.data),
+ [partQuery.data],
+ );
+
+ // --- OPTIMISTIC OVERLAY (standard useQuery-mutation pattern; NO polling) ------
+ // `watchShape` only surfaces a doc AFTER the broker push (a real latency), so a
+ // mutation's effect is invisible in the reactive set for a beat. To keep the
+ // screens' local-first feel, each mutation reflects its effect IMMEDIATELY in a
+ // pure app-side React overlay laid OVER the SDK surface (never touching the SDK
+ // internals — see rule_app-uses-sdk-surface-only), then RECONCILED by the push:
+ // • `pendingAddEvents` / `pendingAddParticipations` — entities created
+ // optimistically, keyed by their `@id` (= the doc NURI `writeEntity` returns,
+ // the SAME id `watchShape` surfaces as `s.subject` → they match exactly).
+ // • `pendingRemoveIds` — participation ids removed optimistically (leave).
+ // The exposed set = merge(reactive, pendingAdds) minus pendingRemoves, DEDUPED by
+ // id. Reconciliation (effects below) auto-cleans the overlay the moment the
+ // reactive read catches up: an add whose id now appears in the reactive set is
+ // dropped from pendingAdds; a remove whose id is no longer in the reactive set is
+ // dropped from pendingRemoves. No re-query, no interval — the overlay only reacts
+ // to `watchShape`'s own pushes.
+ const [pendingAddEvents, setPendingAddEvents] = useState([]);
+ const [pendingAddUsers, setPendingAddUsers] = useState([]);
+ const [pendingAddParticipations, setPendingAddParticipations] = useState([]);
+ const [pendingRemoveIds, setPendingRemoveIds] = useState>(() => new Set());
+
+ const users = React.useMemo(() => {
+ if (pendingAddUsers.length === 0) return reactiveUsers;
+ const seen = new Set(reactiveUsers.map(u => u.id));
+ const extra = pendingAddUsers.filter(u => !seen.has(u.id));
+ return extra.length ? [...reactiveUsers, ...extra] : reactiveUsers;
+ }, [reactiveUsers, pendingAddUsers]);
+
+ const events = React.useMemo(() => {
+ if (pendingAddEvents.length === 0) return reactiveEvents;
+ const seen = new Set(reactiveEvents.map(e => e.id));
+ const extra = pendingAddEvents.filter(e => !seen.has(e.id));
+ return extra.length ? [...reactiveEvents, ...extra] : reactiveEvents;
+ }, [reactiveEvents, pendingAddEvents]);
+
+ const participations = React.useMemo(() => {
+ let base = reactiveParticipations;
+ if (pendingAddParticipations.length) {
+ const seen = new Set(base.map(p => p.id));
+ const extra = pendingAddParticipations.filter(p => !seen.has(p.id));
+ if (extra.length) base = [...base, ...extra];
+ }
+ if (pendingRemoveIds.size) base = base.filter(p => !pendingRemoveIds.has(p.id));
+ return base;
+ }, [reactiveParticipations, pendingAddParticipations, pendingRemoveIds]);
+
+ // RECONCILIATION — drop each optimistic add once the reactive read carries its id
+ // (the push arrived), and each optimistic remove once the reactive read no longer
+ // carries its id (the SPARQL delete propagated). Guarded to no-op when there is
+ // nothing to reconcile, so a stable reactive set does not churn state.
+ useEffect(() => {
+ if (pendingAddEvents.length === 0) return;
+ const live = new Set(reactiveEvents.map(e => e.id));
+ setPendingAddEvents(prev => {
+ const next = prev.filter(e => !live.has(e.id));
+ return next.length === prev.length ? prev : next;
+ });
+ }, [reactiveEvents, pendingAddEvents]);
+
+ useEffect(() => {
+ if (pendingAddUsers.length === 0) return;
+ const live = new Set(reactiveUsers.map(u => u.id));
+ setPendingAddUsers(prev => {
+ const next = prev.filter(u => !live.has(u.id));
+ return next.length === prev.length ? prev : next;
+ });
+ }, [reactiveUsers, pendingAddUsers]);
+
+ useEffect(() => {
+ if (pendingAddParticipations.length === 0) return;
+ const live = new Set(reactiveParticipations.map(p => p.id));
+ setPendingAddParticipations(prev => {
+ const next = prev.filter(p => !live.has(p.id));
+ return next.length === prev.length ? prev : next;
+ });
+ }, [reactiveParticipations, pendingAddParticipations]);
+
+ useEffect(() => {
+ if (pendingRemoveIds.size === 0) return;
+ const live = new Set(reactiveParticipations.map(p => p.id));
+ setPendingRemoveIds(prev => {
+ let changed = false;
+ const next = new Set(prev);
+ for (const id of prev) if (!live.has(id)) { next.delete(id); changed = true; }
+ return changed ? next : prev;
+ });
+ }, [reactiveParticipations, pendingRemoveIds]);
+ // The read is "settled" once every scope has reached its sync barrier
+ // (`isSuccess`). A synced-but-empty scope reads `isSuccess` with `data: []` — the
+ // distinction the auto-seed relies on to tell "still syncing" from "truly empty".
+ const readReady =
+ eventQuery.isSuccess && userQuery.isSuccess && partQuery.isSuccess;
+
+ // Session-local map `${eventId}|${userId}` → the join deposit's uid, so a leave
+ // in the SAME session can carry `regUid` for a precise cancellation. Absent it
+ // (cross-session leave), the owner's materializer falls back to (event, user)
+ // matching — so this is an optimization, not a correctness dependency.
+ //
+ // Nothing ever clears this map, and nothing needs to: a browser context is one
+ // user for its whole life (`ensureIdentity()` answers once, before anything
+ // renders), so there is never anyone else's leftovers in it.
+ const joinUidsRef = useRef>(new Map());
+
+ // OPTION B — the set of event docs the CURRENT identity OWNS (its own public
+ // event docs). Each such NURI IS the event `@id` (writeEntity uses the doc NURI
+ // as the subject). The owner-materializer subscribes to each owned event's inbox
+ // and writes `participantCount` on THAT (owned) doc — never on someone else's.
+ const [ownedEventIds, setOwnedEventIds] = useState([]);
+ // Events the listing has RULED OUT — canonical ids that were already visible in
+ // the reactive read when a listing RESOLVED, and were absent from its result.
+ // ONLY these may be shown as "not yours".
+ //
+ // Why a set and not a "the listing answered" boolean: a listing is a SNAPSHOT,
+ // and it can only ever UNDER-report ownership, because a document created after
+ // it was taken cannot possibly be in it. So a miss is authoritative for what
+ // existed at the time and says NOTHING about anything that arrived since. With a
+ // boolean, a second tab on the same wallet that creates an event has it pushed
+ // here, misses the stale snapshot, and its owner is told the event is not theirs
+ // — permanently. That is the exact failure this whole answer exists to avoid.
+ const [ruledOutEventIds, setRuledOutEventIds] = useState>(new Set());
+ // The owned set in canonical id-form — the form every event-id comparison here
+ // uses, so an event reached under a different overlay is still the same event.
+ const ownedCanonicalIds = React.useMemo(
+ () => new Set(ownedEventIds.map(canonicalEventId)),
+ [ownedEventIds],
+ );
+
+ // WHICH PROTECTED DOCUMENTS ARE MINE — the ground on which "my profile" rests.
+ // `null` means NOT ANSWERED YET (the listing has not landed, or it failed):
+ // "unknown" and "I own nothing" are indistinguishable as an empty array, and
+ // only one of them may lead to creating a profile. Nothing downstream may read
+ // `null` as an empty set.
+ const [myProtectedDocs, setMyProtectedDocs] = useState(null);
+ // The profile document THIS session created for the signed-in person. Set once,
+ // by the creation effect; it settles "which of my profile documents is mine"
+ // without looking at any field of any profile.
+ const [myProfileDocId, setMyProfileDocId] = useState('');
+
+ /**
+ * Fold documents THIS SESSION just created into the owned set.
+ *
+ * The backfill below runs once, on connection, so it only ever knows what
+ * existed at mount. Everything created afterwards has to announce itself, or it
+ * is owned in fact and unowned as far as this tree is concerned — and the
+ * owner-materializer, which only ever looks at this set, never runs on it.
+ */
+ const claimOwnedEventDocs = useCallback((created: Nuri[]) => {
+ if (created.length === 0) return;
+ setOwnedEventIds(prev => [...new Set([...prev, ...created])]);
+ }, []);
+
+ // Resolve the CURRENT identity's owned public event docs for the materializer
+ // ONLY (decoupled from the read — `watchShape` resolves reads itself). Bounded to
+ // this session's own documents (`listMyEntityDocs('public')` — "mine" needs no
+ // identity, the session is one user's). Runs on connection to backfill events
+ // owned before this mount; `createEvent` and the seed (`loadTestData` /
+ // auto-seed) claim what they create through `claimOwnedEventDocs`. This is NOT a
+ // read path (it feeds no `events`/`users`/`participations`), only the
+ // owner-count derivation.
+ //
+ // A REJECTION HERE MEANS "UNKNOWN", NEVER "THIS SESSION OWNS NOTHING". The two
+ // are indistinguishable downstream — both leave the set empty — but only one of
+ // them is true, and taking the wrong one silently disables the owner
+ // materializer, so every event this session hosts stops converging. So the
+ // failure is RETRIED, and if it still will not answer, it is said loudly instead
+ // of leaving a plausible-looking empty set behind.
+ // Read through refs inside the async listing below, so classification uses what
+ // is on screen when the listing RESOLVES rather than what was there when it
+ // started — the two differ by exactly the push that prompted a re-listing.
+ const eventsRef = useRef(events);
+ eventsRef.current = events;
+ const ownedCanonicalIdsRef = useRef(ownedCanonicalIds);
+ ownedCanonicalIdsRef.current = ownedCanonicalIds;
+ // Events the snapshot cannot speak about: neither owned nor ruled out. While
+ // this is non-empty the listing is re-taken — ONCE per new arrival, never on a
+ // timer ([[rule_no-broker-polling]]): a listing classifies everything visible at
+ // the moment it resolves, so this empties and the effect falls silent until a
+ // genuinely new event turns up.
+ const unclassifiedEventKey = React.useMemo(() => {
+ const pending = events
+ .map(e => canonicalEventId(e.id))
+ .filter(c => !ownedCanonicalIds.has(c) && !ruledOutEventIds.has(c));
+ return [...new Set(pending)].sort().join('|');
+ }, [events, ownedCanonicalIds, ruledOutEventIds]);
+ const listingTaken = useRef(false);
+ useEffect(() => {
+ if (!ready) return;
+ // Nothing left to learn: a listing has been taken and every visible event is
+ // classified. Without this the effect would re-list on its own state change.
+ if (listingTaken.current && unclassifiedEventKey === '') return;
+ let cancelled = false;
+ (async () => {
+ for (let attempt = 0; !cancelled; attempt++) {
+ try {
+ const myPublic = await listMyEntityDocs('public');
+ if (cancelled) return;
+ setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]);
+ listingTaken.current = true;
+ // The listing ANSWERED — so every event visible RIGHT NOW that it did
+ // not return is genuinely not this session's, and may be shown as such.
+ // Anything arriving after this point stays unclassified and triggers a
+ // fresh listing rather than inheriting this one's silence. Events already
+ // claimed as mine (created here, possibly racing this listing) are never
+ // ruled out.
+ // REBUILT, never accumulated: every listing RE-ADJUDICATES everything
+ // visible, so a later listing can overturn an earlier ruling instead of
+ // being outvoted by it.
+ //
+ // KNOWN RESIDUAL, and it needs the provider — not more code here. This
+ // infers "not yours" from ABSENCE, and absence is not authoritative: the
+ // reactive read and this listing are two different mechanisms, so an
+ // event can be on screen a moment before the listing can see it. Ruled
+ // out in that window, it is only re-examined if some OTHER unclassified
+ // event later triggers a listing. Self-healing would need either a timer
+ // (polling — forbidden) or the "may I write this?" call the surface does
+ // not publish. Per `rule_app-uses-sdk-surface-only` that is a GAP to
+ // raise, not one to paper over here, so it is left visible and stated.
+ const ownedNow = new Set(myPublic.map(canonicalEventId));
+ setRuledOutEventIds(() => {
+ const next = new Set();
+ for (const e of eventsRef.current) {
+ const c = canonicalEventId(e.id);
+ if (!ownedNow.has(c) && !ownedCanonicalIdsRef.current.has(c)) next.add(c);
+ }
+ return next;
+ });
+ return;
+ } catch (err) {
+ const wait = OWNED_RETRY_BACKOFF_MS[attempt];
+ if (wait === undefined) {
+ console.error(
+ `${logPrefix} owned-events resolution FAILED after ${OWNED_RETRY_BACKOFF_MS.length + 1} ` +
+ `attempts — the owned set is UNKNOWN, not empty, and events hosted by this session ` +
+ `will not converge until it is known:`,
+ err,
+ );
+ return;
+ }
+ console.warn(
+ `${logPrefix} owned-events resolution failed (attempt ${attempt + 1}) — retrying in ${wait}ms:`,
+ err,
+ );
+ await new Promise(r => setTimeout(r, wait));
+ }
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [ready, unclassifiedEventKey]);
+
+ // Not in SHEX shapes yet
+ const [meetingPoints, setMeetingPoints] = useState([]);
+ const [friendships, setFriendships] = useState([]);
+ // Host-facing notifications, materialized from the current user's inboxes
+ // (the inbox read, T02.b/c). Data-level surfacing of "new participants".
+ const [notifications, setNotifications] = useState([]);
+
+ const [selectedEventId, setSelectedEventId] = useState('');
+ const [selectedUserId, setSelectedUserId] = useState('');
+
+ // Auto-select first event when data appears from NG
+ useEffect(() => {
+ const first = events[0];
+ if (!selectedEventId && first) {
+ setSelectedEventId(first.id);
+ }
+ }, [events.length, selectedEventId]);
+
+ // Auto-seed: bootstrap seed data into a genuinely EMPTY wallet. Gated on the
+ // SDK's `isSuccess` (readReady) — the sync barrier is reached for every scope —
+ // so an empty set means "synced and truly empty", NOT "still syncing". This
+ // replaces the old 3s chronometer heuristic (which guessed a sync delay and mis-
+ // fired a re-seed on the 3rd connect). `isPending` → wait; `isSuccess` + empty
+ // data → seed. Gated behind the `FESTIPOD_AUTO_SEED` env var (see utils/autoSeed):
+ // OFF BY DEFAULT so a fresh/persistent wallet is never silently bloated with demo
+ // data — the seed only runs when explicitly opted in. Explicit `loadTestData`
+ // (the test-data action + every @data test) is UNAFFECTED (it seeds directly).
+ // `hasTriedAutoSeed` keeps it single-shot (also suppressed by an explicit
+ // `loadTestData`).
+ //
+ // ALL OF THAT IS CURRENTLY MOOT: the fixture seed is switched OFF outright
+ // (`fixtureSeedEnabled`), so this effect returns on its first line and no
+ // fixture reaches the wallet. `bootstrapWallet` enforces the same switch, so
+ // the check here buys only silence — no misleading "bootstrapping…" log, and
+ // no work started that would write nothing.
+ const hasTriedAutoSeed = useRef(false);
+ useEffect(() => {
+ if (!fixtureSeedEnabled()) return;
+ if (!autoSeedEnabled()) return;
+ if (hasTriedAutoSeed.current) return;
+ if (!ready) return;
+ if (!readReady) return; // still syncing — do NOT mistake pending for empty
+ // MY OWN PROFILE IS NOT "DATA IN THE WALLET". It is created at sign-in on a
+ // brand-new wallet, so counting it here would permanently disable the seed on
+ // exactly the wallets it exists for. The question the gate asks is "does this
+ // wallet already hold something worth preserving", and my own empty profile
+ // does not.
+ const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
+ if (!shouldAutoSeed(walletHasData)) {
+ console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`);
+ return;
+ }
+ // Enabled AND synced-empty → a real empty wallet. Seed once.
+ hasTriedAutoSeed.current = true;
+ console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…`);
+ bootstrapWallet(false, createEntityDoc)
+ // The seed's public event docs were created BY THIS SESSION — they are mine.
+ .then(result => claimOwnedEventDocs(result.createdDocs.public))
+ .catch(err => console.error(`${logPrefix} Auto-seed failed:`, err));
+ // The reactive `watchShape` reads pick the seeded per-entity docs up on their
+ // own (each createEntityDoc appends to the scope index → the container-index
+ // subscription re-resolves → the new docs enter the read). No registerDoc/relist.
+ }, [ready, readReady, events.length, users, myProfileDocId, claimOwnedEventDocs]);
+
+ // --- Derived ---
+ // WHO AM I — answered in TWO id spaces that must not be confused.
+ //
+ // (1) `currentPrincipal` — what signing in returned. Known as soon as the one
+ // identity await settles, i.e. before any document has been read. It names
+ // a PERSON, OPAQUELY: it is never parsed, never rendered as a name, never
+ // written into an entity and never handed to a data call. It exists here
+ // for display of "am I signed in" and for log attribution.
+ // (2) `currentUserId` — the app's own entity space: the `@id` of MY PROFILE
+ // DOCUMENT (a doc NURI). This is what a Participation's `fp:user` carries
+ // and what `resolveParticipantUser` matches directly, so it is the only
+ // value a mutation may write.
+ //
+ // THERE IS NO JOIN BETWEEN THE TWO, and there must not be one. A profile is
+ // Festipod's own object; the identity says nothing about it. **My profile is
+ // the profile document I OWN** — `listMyEntityDocs('protected')` answers "which
+ // documents are mine", and the UserProfile among them is mine. No field of any
+ // profile takes part in the answer: no username comparison, no normalization,
+ // no positional pick.
+ //
+ // THREE OUTCOMES, and "somebody else's profile" is not one of them:
+ // • exactly one owned profile → that is me;
+ // • none → I have no profile yet, and the creation effect below makes one
+ // (until it lands, `currentUserId` is '' and the mutations that need it
+ // REJECT — they never write an entity keyed on nobody);
+ // • several, none of them created by this session → genuinely AMBIGUOUS (the
+ // opt-in fixture seed writes its profiles into my own protected scope), so
+ // the answer is NO PROFILE, said loudly. Picking one would be picking a
+ // person at random and calling them "you".
+ const currentPrincipal = useCurrentPrincipal();
+ /** The profiles I own: the reactive profiles whose document is one of mine. */
+ const myOwnedProfiles = React.useMemo(() => {
+ if (myProtectedDocs === null) return null; // UNKNOWN — never "none"
+ const mine = new Set(myProtectedDocs);
+ return users.filter(u => mine.has(u.id));
+ }, [users, myProtectedDocs]);
+ const currentUser = React.useMemo(() => {
+ // This session created it → no ambiguity possible, whatever else is owned.
+ if (myProfileDocId) return users.find(u => u.id === myProfileDocId);
+ if (myOwnedProfiles === null) return undefined; // not answered yet
+ if (myOwnedProfiles.length === 1) return myOwnedProfiles[0];
+ if (myOwnedProfiles.length === 0) return undefined; // none — one gets created
+ // SEVERAL profile documents are mine, and none was created by this session:
+ // the fixture seed writes its profiles into my own protected scope, so a
+ // reloaded demo wallet lands here. Pick the first by document reference —
+ // stable across reloads, and arbitrary, which is honest: while the profile
+ // is not a built feature, "which of my fixtures am I" has no true answer.
+ //
+ // This is NOT the impersonation that was removed. That one reached for a
+ // profile by NAME and could land on a document belonging to somebody else.
+ // Every candidate here is a document I own, so the invariant that holds is
+ // the one that matters: the app never presents another person's profile as
+ // mine. Delete this branch the day a profile is really created and known.
+ return [...myOwnedProfiles].sort((a, b) => a.id.localeCompare(b.id))[0];
+ }, [users, myProfileDocId, myOwnedProfiles]);
+ const currentUserId = currentUser?.id || '';
+ // Identity-first log prefix, reused by every DATA log below (including the
+ // closures defined earlier in this function body — they only execute after
+ // this render has finished, by which point `logPrefix` is initialized). Several
+ // browser contexts can be tailed at once, so a line must say WHOSE it is: the
+ // profile NURI once it is read, and the signed-in principal before that — which
+ // is now from the very first render, instead of the anonymous `[app][data]`.
+ const logPrefix = currentUserId || currentPrincipal
+ ? `[${currentUserId || currentPrincipal}][app][data]`
+ : '[app][data]';
+ const selectedEvent = events.find(e => e.id === selectedEventId);
+ // DISPLAY READ — log participantCount exactly as currently exposed for
+ // rendering. Compared against the owner-materializer's WRITE logs below, this
+ // pinpoints whether a stuck counter is a DATA problem (never incremented) or a
+ // DISPLAY/read problem (incremented but not re-read until the next session).
+ if (selectedEvent) {
+ console.log(
+ `${logPrefix} participation read for display — event=${canonicalEventId(selectedEvent.id)} ` +
+ `"${selectedEvent.title}": participantCount lu = ${selectedEvent.participantCount}`,
+ );
+ }
+ const selectedUser = users.find(u => u.id === selectedUserId);
+
+ // --- MY PROFILE: which documents are mine ----------------------------------
+ // Resolve the PROTECTED documents this identity owns. That set is the whole
+ // basis of "which profile is mine", and it is also what decides whether a
+ // profile has to be CREATED — so a failure here must never look like an answer:
+ // a rejection means UNKNOWN, and reading it as "I own nothing" would create a
+ // second profile for someone who already has one. Retried; if it still will not
+ // answer, the set stays `null` (no profile resolved, no profile created) and it
+ // is said loudly.
+ useEffect(() => {
+ if (!ready) return;
+ let cancelled = false;
+ (async () => {
+ for (let attempt = 0; !cancelled; attempt++) {
+ try {
+ const mine = await listMyEntityDocs('protected');
+ if (cancelled) return;
+ setMyProtectedDocs(prev => [...new Set([...(prev ?? []), ...mine])]);
+ return;
+ } catch (err) {
+ const wait = OWNED_RETRY_BACKOFF_MS[attempt];
+ if (wait === undefined) {
+ console.error(
+ `${logPrefix} my-protected-documents resolution FAILED after ` +
+ `${OWNED_RETRY_BACKOFF_MS.length + 1} attempts — which profile is mine is UNKNOWN, ` +
+ `not absent: no profile will be resolved and none will be created until it is known:`,
+ err,
+ );
+ return;
+ }
+ console.warn(
+ `${logPrefix} my-protected-documents resolution failed (attempt ${attempt + 1}) — ` +
+ `retrying in ${wait}ms:`,
+ err,
+ );
+ await new Promise(r => setTimeout(r, wait));
+ }
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [ready]);
+
+ // --- MY PROFILE: create one when I have none -------------------------------
+ // A profile is Festipod's own object, and signing in produces none — so the
+ // first time a person signs in, the app makes theirs. Gated on BOTH the
+ // protected read having settled (`userQuery.isSuccess`: synced-and-empty, not
+ // still-syncing) and the owned-document set being KNOWN, because "I have no
+ // profile" is only true when both have answered. Single-shot per session; on
+ // failure the guard is released, so a later change to the owned set retries.
+ const hasTriedProfileCreate = useRef(false);
+ useEffect(() => {
+ if (!ready) return;
+ if (hasTriedProfileCreate.current) return;
+ if (!userQuery.isSuccess) return;
+ if (myOwnedProfiles === null) return; // UNKNOWN — never read as "none"
+ if (myOwnedProfiles.length > 0) return; // I already have one (or more)
+ hasTriedProfileCreate.current = true;
+ (async () => {
+ console.log(`${logPrefix} no profile of mine — creating one (fields left visibly unset)`);
+ const graph = await createEntityDoc('protected');
+ // The three fields the UserProfile shape makes mandatory, written with the
+ // "not filled in yet" placeholders — nothing here comes from the identity.
+ await writeEntity(graph, ENTITY_TYPE.user, {
+ name: str(UNSET_PROFILE.name),
+ initials: str(UNSET_PROFILE.initials),
+ username: str(UNSET_PROFILE.username),
+ });
+ // This document is MINE — claimed explicitly, so it is recognized as my
+ // profile whatever else the protected scope holds (fixtures included).
+ setMyProfileDocId(graph);
+ setMyProtectedDocs(prev => [...new Set([...(prev ?? []), graph])]);
+ // OPTIMISTIC OVERLAY, same pattern as events/participations: surface the
+ // profile immediately so `currentUserId` resolves without waiting for the
+ // broker push; the reconciliation effect drops it once the read carries it.
+ const optimisticProfile: FpUserData = { id: graph, ...UNSET_PROFILE };
+ setPendingAddUsers(prev => (prev.some(u => u.id === graph) ? prev : [...prev, optimisticProfile]));
+ })().catch(err => {
+ hasTriedProfileCreate.current = false;
+ console.error(`${logPrefix} creating my profile FAILED — this session has no profile:`, err);
+ });
+ }, [ready, userQuery.isSuccess, myOwnedProfiles]);
+
+ // --- MY PROFILE: say it when the answer is arbitrary ------------------------
+ // Several profile documents are mine and none was created by this session (the
+ // opt-in fixture seed writes its profiles into my own protected scope). One is
+ // picked deterministically so the app stays usable on a demo wallet, but the
+ // pick carries no meaning — say so once, or a fixture person silently becomes
+ // "you" and nobody wonders why.
+ const warnedAmbiguousProfile = useRef(false);
+ useEffect(() => {
+ if (myProfileDocId) return;
+ if (myOwnedProfiles === null || myOwnedProfiles.length <= 1) return;
+ if (warnedAmbiguousProfile.current) return;
+ warnedAmbiguousProfile.current = true;
+ console.warn(
+ `${logPrefix} ${myOwnedProfiles.length} profile documents are mine and none was created by ` +
+ `this session (a fixture seed run on this wallet is the usual cause). The first by document ` +
+ `reference is used as mine — a stable but ARBITRARY pick, so the name shown as yours is ` +
+ `demo data, not you.`,
+ );
+ }, [myOwnedProfiles, myProfileDocId]);
+
+ // --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) -------
+ // The event OWNER's session materializes its OWN events' inbox deposits into
+ // (1) the correct `participantCount` on its OWN event doc, and
+ // (2) host-facing "new participant" FpNotifications.
+ // This is what makes the count CORRECT and reactive WITHOUT any non-owner ever
+ // writing the event doc: the joiner only deposits; the owner counts.
+ //
+ // Reactive, no polling: subscribe the inbox document via `inbox.watch` (a
+ // `doc_subscribe` push). One watch PER owned event: each event has its OWN
+ // inbox, whose address its owner obtains with `openDocumentInbox`.
+ //
+ // IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct
+ // active registrations (`materializeAttendance`: distinct join uids MINUS
+ // cancelled ones), never an unbounded ±1. A broker re-sync replays the same
+ // deposits → same set → same count. `participantCount = activeRegistrations.size`
+ // (no host baseline — the creator does not auto-participate). The write is GUARDED
+ // (write only when the value actually changes) so re-materializing an unchanged
+ // inbox does not thrash the doc / loop the reactive read.
+ //
+ // OWNER-OFFLINE = EVENTUAL (brief §E.2): only the owner's session runs this, so
+ // while the owner is disconnected the count doesn't advance for others (the
+ // deposits persist in the inbox — nothing is lost; a future service will
+ // materialize on the owner's behalf).
+ // Dedup owned events by their CANONICAL id-form (base repo id, stripping any
+ // `:v:` suffix): the SAME event can enter `ownedEventIds` under two
+ // overlays (create-time vs a later `listMyEntityDocs` backfill), and iterating
+ // both would materialize + count-write the same event twice. Keep ONE real NURI
+ // per canonical id as the write/anchor target (the count is written on a live
+ // doc NURI — never a stripped id). See `canonicalEventId` in registration.ts.
+ const ownedEvents = React.useMemo(() => {
+ const byCanon = new Map();
+ for (const nuri of ownedEventIds) {
+ const c = canonicalEventId(nuri);
+ if (!byCanon.has(c)) byCanon.set(c, nuri);
+ }
+ return [...byCanon.values()].sort();
+ }, [ownedEventIds]);
+ // Primitive identity of the set above — the effect's dependency (an array is a
+ // new reference on every render).
+ const ownedKey = ownedEvents.join('|');
+
+ // WHICH EVENTS MAY THIS SESSION WRITE — the same owned set, asked the other way
+ // round. Nothing new is called: the contract makes writing a document and owning
+ // it the same thing, so `listMyEntityDocs('public')` (already resolved above for
+ // the materializer) is the whole answer. Matched on the CANONICAL id-form, like
+ // every other event-id comparison here, so an event reached under a different
+ // overlay is still recognized as the same event.
+ const getEventOwnership = useCallback((eventId: string): EventOwnership => {
+ const canon = canonicalEventId(eventId);
+ // A hit is authoritative EVEN BEFORE any listing has answered: an event this
+ // session just created was claimed directly, and it is mine whatever a
+ // listing later says. Checked FIRST, so ownership always beats a stale miss.
+ if (ownedCanonicalIds.has(canon)) return 'mine';
+ // A miss counts only for events a resolved listing actually looked past.
+ if (ruledOutEventIds.has(canon)) return 'not-mine';
+ // Otherwise the answer is genuinely still coming — a listing is pending, has
+ // failed, or this event arrived after the last one. Never 'not-mine' here.
+ return 'unknown';
+ }, [ownedCanonicalIds, ruledOutEventIds]);
+ // Last count WRITTEN per owned event (keyed by canonical id), stamped with the
+ // cycle that wrote it. The stamp is what makes a STALE cycle harmless — see the
+ // guard below.
+ const materializedCountRef = useRef>(new Map());
+ // Monotonic cycle number, shared by every materialize cycle of this session. It
+ // has to outlive the effect: when the owned set changes the effect re-runs, and
+ // a cycle started by the PREVIOUS run can still be in flight.
+ const cycleSeqRef = useRef(0);
+ useEffect(() => {
+ if (!ready) return;
+ const owned = ownedEvents;
+ if (owned.length === 0) return;
+ let cancelled = false;
+
+ // ONE inbox address per owned event, resolved ONCE here and shared by the two
+ // things that must not disagree: the cycle that READS the inbox and the watch
+ // that SUBSCRIBES to it. Resolving them apart is how the owner ends up watching
+ // one address while a deposit lands in another — the sign-up is then never seen
+ // in the session that made it. `openDocumentInbox` is itself single-flight per
+ // document (utils/storeRegistry), so the app can no longer open a second inbox
+ // at all; this map is the narrower promise that the reader and the watcher hold
+ // the very same value.
+ //
+ // Resolved on first need and kept — but a FAILED resolution is released, not
+ // kept: it means the address is UNKNOWN, so the next trigger must really ask
+ // again instead of inheriting a permanent "no inbox".
+ let resolvingInboxes: Promise> | null = null;
+ const inboxesForOwnedEvents = (): Promise> => {
+ if (resolvingInboxes) return resolvingInboxes;
+ const attempt = (async () => {
+ const byEvent = new Map();
+ for (const evId of owned) byEvent.set(evId, await openDocumentInbox(evId));
+ return byEvent;
+ })().catch(err => {
+ if (resolvingInboxes === attempt) resolvingInboxes = null;
+ throw err;
+ });
+ resolvingInboxes = attempt;
+ return attempt;
+ };
+
+ const runCycle = async (trigger: string) => {
+ if (cancelled) return;
+ const seq = ++cycleSeqRef.current;
+ try {
+ const inboxes = await inboxesForOwnedEvents();
+ console.log(
+ `${logPrefix} owner participation materialize START (trigger=${trigger}, cycle=${seq}) — ` +
+ `${owned.length} owned event(s)`,
+ );
+ const notifs: FpNotificationData[] = [];
+ // Each event has its OWN inbox, and only its OWNER can open it. The address
+ // comes from the shared map above — the same one the watch subscribed; a
+ // depositor never sees it (they name the document instead).
+ for (const [evId, targetInbox] of inboxes) {
+ const canonId = canonicalEventId(evId);
+ // BEFORE — the event's readable detail (short id + title) and the
+ // participantCount value as currently READ/exposed (the app-side `events`
+ // state), captured before this cycle's derive+write. Comparing this to the
+ // AFTER log below tells whether the counter is a DATA problem (never
+ // incremented) or a DISPLAY/read problem (incremented but not re-read).
+ //
+ // Read through the REF, never the closure: this effect only re-runs on
+ // [ready, ownedKey], so the `events` it captured is the snapshot from the
+ // render that wired the watch — which is why this line printed `(unknown)`
+ // for the whole session and told the last investigation nothing. Matched
+ // on the canonical id-form, like every other event-id comparison here.
+ const knownEvent = eventsRef.current.find(e => canonicalEventId(e.id) === canonId);
+ const knownCount = knownEvent?.participantCount;
+ console.log(
+ `${logPrefix} participation materialize — event=${canonId}` +
+ (knownEvent?.title ? ` "${knownEvent.title}"` : '') +
+ ` — participantCount before write (as currently read) = ${knownCount ?? '(unknown)'}`,
+ );
+ // (1) COUNT — derive the distinct active-registration set for this event
+ // and write it on MY OWN event doc (only when it changed). `materializeAttendance`
+ // reads through the synced-view contract (`inbox.readSynced`), so a
+ // registrant's deposit is visible even on a cold session.
+ const active = await materializeAttendance(targetInbox, evId);
+ const nextCount = active.length; // no host baseline (creator not auto-in)
+ const written = materializedCountRef.current.get(canonId);
+ // THE VALUE A CYCLE CARRIES IS ONLY AS FRESH AS THE READ IT CAME FROM.
+ // Cycles of this effect can no longer interleave (they are serialized
+ // below), but the effect re-runs whenever the owned set changes, and a
+ // cycle from the previous run can still be in flight — holding a count it
+ // derived BEFORE the fresher one's. Writing it would put the stale value
+ // back on the document, which is a count that goes backwards for no
+ // visible reason. So a cycle may only overwrite what an OLDER cycle wrote.
+ if (written && written.seq > seq) {
+ console.log(
+ `${logPrefix} owner participation materialize — event=${canonId}: cycle ${seq} is STALE ` +
+ `(cycle ${written.seq} already wrote ${written.count}) — not writing ${nextCount}`,
+ );
+ } else if (written?.count === nextCount) {
+ // Write ONLY when the derived value actually changes (anti-loop). This
+ // memo does NOT lock in a premature 0: the barrier-gated read above makes
+ // the first post-connection materialize see the real deposits, so once the
+ // set becomes non-empty the value differs and the correct count is
+ // written. A transient write failure reverts the memo so the next trigger
+ // retries. The guard's sole job is to avoid re-writing an UNCHANGED value.
+ console.log(
+ `${logPrefix} owner participation materialize — event=${canonId}: ` +
+ `participantCount unchanged (${nextCount}) — no write`,
+ );
+ } else if (!cancelled) {
+ materializedCountRef.current.set(canonId, { count: nextCount, seq });
+ console.log(
+ `${logPrefix} owner participation materialize — event=${canonId}: ` +
+ `participantCount ${written?.count ?? '(none)'} → ${nextCount} (writing own doc, cycle=${seq})`,
+ );
+ // The write lands on the owned event doc, which `watchShape('public')`
+ // already subscribes → the reactive read re-renders the new count on
+ // the broker push (no manual re-query).
+ let writeOk = true;
+ await updateEntityField(evId, evId, 'participantCount', int(nextCount))
+ .catch(err => {
+ // Revert the memo so a transient write failure retries next trigger
+ // — but only if it is still OURS. A fresher cycle's value stands.
+ writeOk = false;
+ const current = materializedCountRef.current.get(canonId);
+ if (current && current.seq === seq) materializedCountRef.current.delete(canonId);
+ console.error(`${logPrefix} owner participation materialize count WRITE FAILED:`, err);
+ });
+ if (writeOk) {
+ // AFTER — the write has landed on the owner's own doc. N → M reuses the
+ // SAME "before" reference point logged above, so it is directly
+ // comparable: a DISPLAY-read log (see `selectedEvent`, above in this
+ // file) still showing the old N after this fires means the counter data
+ // is fine and it is the read side that lags.
+ console.log(
+ `${logPrefix} participation materialize — event=${canonId}: ` +
+ `participantCount AFTER write = ${knownCount ?? '(unknown)'} → ${nextCount}`,
+ );
+ }
+ }
+ // (2) NOTIFICATIONS — surface "new participant" deposits (unchanged T02.c).
+ const evNotifs = await readRegistrationNotifications(targetInbox, evId);
+ notifs.push(...evNotifs);
+ }
+ if (!cancelled && notifs.length) {
+ setNotifications(prev => {
+ const seen = new Set(prev.map(n => n.id));
+ const merged = [...prev];
+ for (const n of notifs) if (!seen.has(n.id)) { seen.add(n.id); merged.push(n); }
+ return merged;
+ });
+ }
+ } catch (err) {
+ console.error(`${logPrefix} owner participation materialization failed:`, err);
+ }
+ };
+
+ // A CYCLE IS A READ-DERIVE-WRITE, AND TWO OF THEM MUST NOT OVERLAP. The two
+ // triggers below fire within the same instant on the connection that creates an
+ // event, and run concurrently they both read the inbox before either writes —
+ // so the one that finishes last puts its own, older reading back on the
+ // document. Serialized, a request arriving mid-cycle is served by ONE follow-up
+ // cycle once the current one has finished (a cycle re-derives everything from
+ // the inbox, so one follow-up covers however many requests it coalesces).
+ const materialize = createSerialTask(runCycle);
+
+ // (A) RELIABLE-AT-CONNECTION: run one materialization directly on this trigger
+ // ([ready, ownedKey]). This is the spec's core — the owner, at its NEXT
+ // CONNECTION, deterministically processes its owned events' inbox, reading
+ // through the synced-view contract. It does NOT depend on a cross-session
+ // inbox push arriving.
+ void materialize('connection').catch(err =>
+ console.error(`${logPrefix} owner participation materialize cycle rejected:`, err));
+
+ // (B) SAME-SESSION LIVE: `inbox.watch` fires on the initial state push and on
+ // every later deposit visible to THIS verifier (a local deposit, or a remote one
+ // that pushes) — no polling. Re-materialize on each so the owner's own session
+ // stays live when a deposit does push. Cross-session convergence does NOT rely on
+ // this (it relies on (A) at the owner's next connection); this only sharpens the
+ // same-session/live case. One watch PER owned event — each event has its OWN
+ // inbox document — and the address watched is the one taken from the SHARED map
+ // above, so what is watched is exactly what the cycle reads.
+ const unsubscribes: Array<() => void> = [];
+ void (async () => {
+ const inboxes = await inboxesForOwnedEvents();
+ if (cancelled) return;
+ for (const targetInbox of inboxes.values()) {
+ unsubscribes.push(inbox.watch(targetInbox, () =>
+ void materialize('inbox-push').catch(err =>
+ console.error(`${logPrefix} owner participation materialize cycle rejected:`, err))));
+ }
+ })().catch(err => console.error(`${logPrefix} owner inbox watch wiring failed:`, err));
+ return () => { cancelled = true; for (const stop of unsubscribes) stop(); };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ready, ownedKey]);
+
+ // Protected-sharing act: the app owns the relationship concept — it declares the
+ // current identity's own connections (a Festipod domain fact) and, for each
+ // bilateral link, hands the SDK directed read grants so an owner's connections
+ // may read that owner's PROTECTED entities (public = all; private = owner only).
+ // The declaration names only the current user's own peers, asserted as the
+ // current identity. The app carries no access CHECK (see knowledge_trust-model)
+ // — it only declares its own relationship graph, then trusts the SDK to enforce
+ // the resulting per-document grants. No store id, no document NURI crosses here.
+ useEffect(() => {
+ if (!ready || !currentUserId) return;
+ // `inbox.share(doc, toUser)` names a PERSON, so the app models both sides of
+ // a relationship in ONE key space: the normalized profile username. It maps
+ // each peer IRI → that key before declaring, and asserts AS its own key.
+ // Peers with no known profile are skipped (they cannot be named).
+ const idKeyOf = (userIri: string): string | undefined => {
+ const u = users.find(x => x.id === userIri);
+ return u?.username ? normalizeIdentifier(u.username) : undefined;
+ };
+ const selfKey = idKeyOf(currentUserId);
+ if (!selfKey) return;
+ const myPeers = friendships
+ .filter(f => f.userId === currentUserId || f.friendId === currentUserId)
+ .map(f => (f.userId === currentUserId ? f.friendId : f.userId))
+ .map(idKeyOf)
+ .filter((k): k is string => !!k);
+ // Sharing is I/O now (a key handed to each neighbour's inbox), so it is
+ // fire-and-forget from this effect — nothing downstream waits on it.
+ void declareConnections(myPeers, selfKey)
+ .catch(err => console.error(`${logPrefix} declareConnections failed:`, err));
+ }, [ready, friendships, currentUserId, users]);
+
+ const queries = buildQueries(
+ events, users, participations, meetingPoints, friendships, currentUserId,
+ );
+
+ console.log(`${logPrefix} Render — NG | events:`, events.length,
+ '| users:', users.length, '| participations:', participations.length,
+ '| selectedEvent:', selectedEvent?.title ?? '(none)');
+
+ // --- Mutations (NG) ---
+ // Each entity is written as its OWN document, created via the SDK in its scope
+ // (`createEntityDoc(scope)`) — never a store-level document. Creating a doc
+ // appends its NURI to the scope index, which `watchShape` subscribes; the new
+ // entity enters the reactive read on the resulting push (no manual registration).
+ // The SDK declares the per-document ReadCap policy on create (public / protected /
+ // private) — the app carries no access logic.
+
+ const createEvent = useCallback(async (event: Omit): Promise => {
+ console.log(`${logPrefix} createEvent (NG):`, event.title);
+ // The SDK create returns THIS entity's OWN public document and declares its
+ // access policy (public → world-readable). Placement is named by SCOPE
+ // alone: the session belongs to one user, so there is no owner to name.
+ // A creation that cannot be recorded THROWS — it never hands back a
+ // reference that would read empty forever — so this rejects the whole
+ // `createEvent` rather than returning a half-made event.
+ // Create the event's OWN document in the PUBLIC scope (one doc per entity),
+ // then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via
+ // the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed
+ // per-entity doc against the real broker. The doc's NURI is appended to the
+ // public scope index, which `watchShape('public')` subscribes → the event
+ // enters the reactive read on the push. The written subject IRI is the `@id`.
+ const eventGraph = await createEntityDoc('public');
+ // An event is a document people SIGN UP TO, so its owner opens its inbox
+ // here, at creation. A document only HAS one if its owner opened it; without
+ // this, a registrant's deposit would have nothing to reach.
+ await openDocumentInbox(eventGraph);
+ const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, {
+ title: str(event.title), description: str(event.description), date: str(event.date),
+ // `date` is the human LABEL the form composed; these four are the machine
+ // values it collected, written UNALTERED as the form's own ISO forms
+ // (`YYYY-MM-DD`, `HH:MM`). They are stored alongside the label, never
+ // derived from it — the label is display text and cannot be parsed back.
+ startDate: str(event.startDate), endDate: str(event.endDate),
+ startTime: str(event.startTime), endTime: str(event.endTime),
+ location: str(event.location), distance: flt(event.distance),
+ // No host notion: the creator merely SIGNALS a public event and is NOT
+ // obliged to participate, so the count starts at 0 (the owner-materializer
+ // derives it from the active-registration set — |active|, no host baseline).
+ participantCount: int(event.participantCount || 0),
+ coverImage: str(event.coverImage),
+ });
+ // OPTION B: this event's doc is MINE (I just created it), so track it as owned
+ // → the owner-materializer subscribes to its inbox and maintains its count.
+ setOwnedEventIds(prev => (prev.includes(eventGraph) ? prev : [...prev, eventGraph]));
+ // OPTIMISTIC OVERLAY: surface the created event IMMEDIATELY (its id = eventId =
+ // the doc NURI, the SAME id `watchShape('public')` will surface as `s.subject`,
+ // so the reconciliation effect drops this add once the push arrives). Without
+ // it, `getEvent(eventId)` right after create returns undefined until the push.
+ // participantCount starts at 0 (creator does not auto-participate).
+ const optimisticEvent: FpEventData = { ...event, id: eventId, participantCount: event.participantCount || 0 };
+ setPendingAddEvents(prev => (prev.some(e => e.id === eventId) ? prev : [...prev, optimisticEvent]));
+ // The creator does NOT auto-participate (no host notion — settled product
+ // decision): NO participation is written on create. The creator sees "J'y
+ // serai" and may join/leave their own event like anyone else.
+ if (currentUserId) setSelectedEventId(eventId);
+ 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
+ // non-public doc. Best-effort: a failed submission must not roll back a
+ // successful event creation.
+ if (addedEvent) {
+ // NO index submission any more. There is no discovery: a reader reaches a
+ // document only by FOLLOWING A LINK it was given. Announcing a public event
+ // beyond its creator is therefore a Festipod-level concern (a directory
+ // document whose link the app knows), not an SDK call — see the directory
+ // work. Until it lands, a created event is reachable by its creator only.
+ }
+ return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
+ }, [currentUserId]);
+
+ const updateEvent = useCallback(async (id: string, updates: Partial) => {
+ console.log(`${logPrefix} updateEvent (NG):`, id, updates);
+ // The event's `@id` IS its own document NURI (one entity = one document), so
+ // it is both the write graph and the subject. Persist each provided mutable
+ // field DIRECTLY via SPARQL (the durable write); `watchShape` re-reads on the
+ // resulting broker push (the doc is already subscribed) — no manual re-query.
+ // No pre-flight NURI guard: every SDK entry takes the reference as it stands
+ // and validates at its own door.
+ const graph = id;
+ const persists: Promise[] = [];
+ if (updates.participantCount !== undefined) {
+ persists.push(updateEntityField(graph, id, 'participantCount', int(updates.participantCount)));
+ }
+ 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)));
+ // The machine dates/times travel with the label, not instead of it. A field
+ // arriving as `''` (the form's empty date input) CLEARS the triple rather than
+ // leaving a stale value behind — that is `updateEntityField`'s empty handling,
+ // and it is the right reading here: the user emptied the input.
+ if (updates.startDate !== undefined) persists.push(updateEntityField(graph, id, 'startDate', str(updates.startDate)));
+ if (updates.endDate !== undefined) persists.push(updateEntityField(graph, id, 'endDate', str(updates.endDate)));
+ if (updates.startTime !== undefined) persists.push(updateEntityField(graph, id, 'startTime', str(updates.startTime)));
+ if (updates.endTime !== undefined) persists.push(updateEntityField(graph, id, 'endTime', str(updates.endTime)));
+ 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(`${logPrefix} persist event update failed:`, err));
+ }, []);
+
+ const joinMeetingPoint = useCallback(async (eventId: string, userId?: string) => {
+ const uid = userId || currentUserId;
+ console.log(`${logPrefix} joinMeetingPoint (NG):`, eventId, 'user:', uid);
+ // A Participation MUST carry a user reference (SHEX `fp:user` is mandatory) —
+ // writing one without it produces an entity the read drops (the participation
+ // silently never round-trips). REJECT rather than return: returning here made
+ // the sign-up a no-op that wrote nothing, threw nothing and let the screen
+ // congratulate the user. The cause is named, because it is actionable: no
+ // profile of mine is resolved yet.
+ if (!uid) {
+ const msg =
+ `joinMeetingPoint refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
+ `so a Participation would carry no fp:user and would never round-trip. ` +
+ `Wait for the profile to be created/read, or resolve the ambiguity reported above.`;
+ console.error(`${logPrefix} ${msg}`);
+ throw new Error(msg);
+ }
+ // IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set.
+ // 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.
+ //
+ // A FAILED count is UNKNOWN, not zero, so it is NOT caught here: reading it as
+ // "not participating yet" is precisely how a duplicate gets written. The
+ // rejection propagates out of `joinMeetingPoint`, and the screen that called it says
+ // the sign-up could not be recorded — which is the truth.
+ const already = await countUserParticipations(eventId, uid);
+ if (already > 0) {
+ console.log(`${logPrefix} Already participating (broker-confirmed), skipping`);
+ return;
+ }
+ // 1) Persist the Participation as its OWN document in the PROTECTED scope
+ // (one doc per entity). Its NURI is appended to the protected scope index,
+ // which `watchShape('protected')` subscribes → the participation enters
+ // the reactive read on the push.
+ // A creation that cannot be recorded THROWS, so a failure here rejects
+ // `joinMeetingPoint` instead of depositing a registration for a participation
+ // document that would read empty forever.
+ const partGraph = await createEntityDoc('protected');
+ // WRITE the participation RDF DIRECTLY into its own document (writeEntity) —
+ // not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed
+ // per-entity doc against the real broker). The written subject is the
+ // participation's `@id` (its own graph is partGraph, used later by the
+ // authoritative delete).
+ await writeEntity(partGraph, ENTITY_TYPE.participation, {
+ event: iri(eventId), user: iri(uid), isConfirmed: bool(true),
+ });
+ // OPTIMISTIC OVERLAY: surface the participation IMMEDIATELY so the joiner shows
+ // up in the list and `isParticipating` is true before the broker push. Its id =
+ // partGraph = the doc NURI, the SAME id `watchShape('protected')` will surface
+ // as `s.subject` (adaptParticipations sets `id: s.subject`) → the reconciliation
+ // effect drops this add once the push arrives. If a stale pendingRemove targeted
+ // this exact id (re-join same doc — never happens, ids are fresh), clear it too.
+ const optimisticPart: FpParticipationData = { id: partGraph, eventId, userId: uid, isConfirmed: true };
+ setPendingAddParticipations(prev => (prev.some(p => p.id === partGraph) ? prev : [...prev, optimisticPart]));
+ // OPTION B (brief §B): the joiner does NOT write `participantCount` on the
+ // EVENT doc — that doc belongs to the OWNER, and a non-owner write there is an
+ // isolation violation (NextGraph write is membership-bound; there is no append
+ // — see brief §1). The count now moves only via the OWNER materializing this
+ // deposit onto its OWN event doc (the `hostedEventIds` materializer below).
+ // The joiner's sole writes are: their OWN participation doc (above) + the
+ // inbox DEPOSIT (below). While the owner is offline the count doesn't advance
+ // for others — accepted eventual behaviour (brief §E.2); nothing is lost.
+ // 2) Tell the host. THE DEPOSIT IS THE DELIVERY: `inbox.postToDocument(doc, …)`
+ // is what the surface publishes for reaching a document's owner — anyone
+ // may deposit, only the owner reads. The host's notification is then built
+ // on the OWNER's side, out of the deposits it reads from its own event's
+ // inbox (`readRegistrationNotifications`, in the materializer above).
+ //
+ // Nothing else is written here. A host FpNotification used to be minted at
+ // this point into the JOINER's own protected scope with `recipient` set to
+ // the event — a document the host cannot read, and never will: the joiner
+ // owns it and the surface has no way to hand it over. It also pushed that
+ // notification into THIS session's own list, so the joiner saw a
+ // "new participant" notice addressed to someone else. Both are gone; the
+ // deposit alone carries the news, and it reaches its reader.
+ //
+ // Best-effort: a failed deposit must not roll back a successful
+ // registration (the participation document is already written).
+ try {
+ const registrantId = uid || null; // no current user → anonymous deposit
+ // The event's `@id` IS its document NURI, and a deposit NAMES that document
+ // — the joiner resolves no inbox and holds no address.
+ // Carry the joiner's participation-doc NURI so the owner (if a connection)
+ // could read it in clear; the count itself does not depend on reading it.
+ console.log(
+ `${logPrefix} joinMeetingPoint — depositing participation registration into event inbox: ` +
+ `event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` +
+ `materializing this deposit on its own doc, at its next connection)`,
+ );
+ const { uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
+ // Remember the join uid so a same-session leave can cancel it precisely.
+ joinUidsRef.current.set(`${eventId}|${uid}`, depositUid);
+ } catch (err) {
+ console.error(`${logPrefix} joinMeetingPoint inbox deposit failed:`, err);
+ }
+ }, [events, currentUserId]);
+
+ const leaveMeetingPoint = useCallback(async (eventId: string, userId?: string) => {
+ const uid = userId || currentUserId;
+ console.log(`${logPrefix} leaveMeetingPoint (NG):`, eventId, 'user:', uid);
+ // 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);
+ // REJECT rather than pretend. Withdrawal is AUTHORITATIVE (see
+ // caveat_participation-deletion): the caller only reaches here because it
+ // believes a participation exists, so finding none is a real disagreement
+ // about the state — either no profile of mine is resolved (`uid` empty), or
+ // the participation the screen showed is not in the set. Returning silently
+ // deleted nothing while the screen announced a withdrawal, and the sign-up
+ // came back on the next read.
+ if (!part) {
+ const msg = uid
+ ? `leaveMeetingPoint refused for event=${canonicalEventId(eventId)}: no participation of user=${uid} ` +
+ `in the participation set — nothing was deleted, so the withdrawal must not be announced.`
+ : `leaveMeetingPoint refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
+ `so the participation to withdraw cannot even be named.`;
+ console.error(`${logPrefix} ${msg}`);
+ throw new Error(msg);
+ }
+ // 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(`${logPrefix} SPARQL DELETE participation failed:`, err);
+ throw err instanceof Error ? err : new Error(String(err));
+ }
+ // 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 = `${logPrefix} leaveMeetingPoint: SPARQL delete removed nothing ` +
+ `(before=${result.before}, remaining=${result.remaining}, bySubject=${result.bySubject}) ` +
+ `for event=${eventId} user=${uid} — NOT flipping UI (would resurrect).`;
+ console.error(msg);
+ throw new Error(msg);
+ }
+ // OPTIMISTIC OVERLAY: the delete is broker-confirmed (remaining === 0) but the
+ // reactive `watchShape('protected')` set only drops the participation on the
+ // NEXT push (a beat later). Mark its id removed NOW so the leaver disappears
+ // from the list immediately; the reconciliation effect clears this pendingRemove
+ // once the reactive set no longer carries the id. Also drop it from
+ // pendingAddParticipations in case it was still only-optimistic (join+leave in
+ // the same session before the join's push landed).
+ setPendingRemoveIds(prev => {
+ if (prev.has(part.id)) return prev;
+ const next = new Set(prev);
+ next.add(part.id);
+ return next;
+ });
+ setPendingAddParticipations(prev => {
+ const next = prev.filter(p => p.id !== part.id);
+ return next.length === prev.length ? prev : next;
+ });
+ // OPTION B, symmetric (brief §B "Désinscription"): the leaver does NOT write
+ // `participantCount` on the EVENT doc (owner-owned — same isolation violation
+ // as the join). Instead it DEPOSITS a `leave-participant` marker into the
+ // event inbox; the OWNER materializes it and recomputes the count on its OWN
+ // doc (idempotent — a re-synced leave never double-decrements, since the count
+ // is derived from the SET of distinct active registrations, not from −1).
+ try {
+ const registrantId = uid || null;
+ // Same as `joinMeetingPoint`: the leave marker NAMES the event document; no inbox
+ // address is resolved on the depositor's side.
+ // Carry the join uid when this session minted it (precise cancellation);
+ // otherwise the owner falls back to (eventId, userId) matching.
+ const regUid = joinUidsRef.current.get(`${eventId}|${uid}`);
+ console.log(
+ `${logPrefix} leaveMeetingPoint — depositing participation leave marker into event inbox: ` +
+ `event=${canonicalEventId(eventId)} user=${uid} regUid=${regUid ?? '(none)'}`,
+ );
+ await depositLeave(eventId, registrantId, regUid);
+ joinUidsRef.current.delete(`${eventId}|${uid}`);
+ } catch (err) {
+ console.error(`${logPrefix} leaveMeetingPoint inbox deposit failed:`, err);
+ }
+ // The participation doc is subscribed by `watchShape('protected')`; the SPARQL
+ // DELETE pushes → the reactive read drops it (`isParticipating` reflects it).
+ // The count itself follows the owner's materialization of the leave marker
+ // (reactive, cross-session).
+ }, [participations, events, currentUserId]);
+
+ const addMeetingPoint = useCallback((mp: Omit) => {
+ setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
+ }, []);
+
+ const addFriend = useCallback((friendId: string) => {
+ setFriendships(prev => {
+ if (prev.some(f =>
+ (f.userId === currentUserId && f.friendId === friendId) ||
+ (f.userId === friendId && f.friendId === currentUserId)
+ )) return prev;
+ return [...prev, { id: `ng-fr-${Date.now()}`, userId: currentUserId, friendId }];
+ });
+ }, [currentUserId]);
+
+ const updateProfile = useCallback(async (updates: Partial) => {
+ console.log(`${logPrefix} updateProfile (NG):`, updates);
+ // MY profile is its own document (subject IRI = doc NURI), and it is the ONLY
+ // document these edits may land in. There used to be a `?? users[0]` fallback
+ // here: with no profile resolved, a person editing their own pseudo wrote it
+ // into a stranger's profile document. Refuse instead — and say which of the
+ // two reasons it is, since only one of them clears up on its own.
+ const target = currentUser;
+ if (!target) {
+ const msg =
+ `updateProfile refused: no profile of mine is resolved, and these edits must never land in ` +
+ `someone else's profile document.`;
+ console.error(`${logPrefix} ${msg}`);
+ throw new Error(msg);
+ }
+ const graph = target.id;
+ const persists: Promise[] = [];
+ 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(`${logPrefix} persist profile update failed:`, err));
+ }, [currentUser, users]);
+
+ const loadTestData = useCallback(async (): Promise => {
+ console.log(`${logPrefix} loadTestData (NG)`);
+ // THIS NO LONGER SEEDS. `bootstrapWallet` refuses while the fixture seed is
+ // switched off (`fixtureSeedEnabled`), so what comes back is `seeded: false`
+ // with no documents, and everything below is a no-op on that empty result.
+ // The call is kept, and kept honest, rather than removed: the switch is what
+ // decides, in one place, and flipping it back restores this path unchanged.
+ // An EXPLICIT load is authoritative — SUPPRESS the dev auto-seed so only ONE
+ // seed runs (marking the guard at the START, before the awaited seed, closes
+ // the window where the auto-seed effect could also fire on a still-empty read).
+ hasTriedAutoSeed.current = true;
+ // Same reading as the auto-seed gate: my own profile is not "data in the
+ // wallet" — it exists on every wallet from the first sign-in, so counting it
+ // would make an explicit load a permanent no-op.
+ const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
+ const result = await bootstrapWallet(walletHasData, createEntityDoc);
+ // The seeded per-entity docs are appended to their scope indices, which
+ // `watchShape` subscribes → they enter the reactive reads on the push. No
+ // manual registration / re-list.
+ // OWNERSHIP is a different question from READING, and it is not answered by
+ // the push: these public event docs were created by THIS session, so claim
+ // them — otherwise the owner-materializer never opens their inboxes and their
+ // `participantCount` is never derived.
+ claimOwnedEventDocs(result.createdDocs.public);
+ // The seeded PROTECTED docs are deliberately NOT claimed as mine: they are
+ // FIXTURES (nobody signed in as them), and folding them into the owned-profile
+ // set is exactly what would make "which profile is mine" ambiguous.
+ return result;
+ }, [events.length, users, myProfileDocId, claimOwnedEventDocs]);
+
+ return {
+ currentUserId, currentUser, currentPrincipal,
+ events, users,
+ participations,
+ meetingPoints,
+ friendships,
+ notifications,
+ selectedEventId, setSelectedEventId, selectedEvent,
+ selectedUserId, setSelectedUserId, selectedUser,
+ ...queries,
+ getEventOwnership,
+ createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
+ addMeetingPoint, addFriend, updateProfile, loadTestData,
+ };
+}
+
+// ============================================================================
+// Provider
+// ============================================================================
+
+export function NgDataProvider({ children }: { children: ReactNode }) {
+ const data = useNgData();
+ return {children} ;
+}
diff --git a/src/shared/context/refineData.tsx b/src/shared/context/refineData.tsx
new file mode 100644
index 0000000..351a720
--- /dev/null
+++ b/src/shared/context/refineData.tsx
@@ -0,0 +1,501 @@
+/**
+ * RefineDataProvider — the ActivityPods data path, behind the Refine seam.
+ *
+ * MOUNTED ONLY WHEN THE DISPATCHER (`FestipodDataContext.tsx`) has both a
+ * registered Refine backend and an authenticated session to read through —
+ * the AuthGate above guarantees the session exists before this tree mounts.
+ *
+ * WHAT GOES THROUGH REFINE. Everything CRUD: the event list (`useList` over
+ * the `as:Event` container — the pod provider is own-pod scoped, so the list
+ * is what THIS user relayed; cross-pod discovery is the catalog's job, a
+ * follow-up behind this same seam), the meeting point create, the event and
+ * profile updates. Records are split into events vs meeting points BY THE
+ * BINDING (`isInstance`) — the two share one container and `as:context`
+ * presence is the only distinction.
+ *
+ * WHAT LIVES BESIDE REFINE. A participation is not a CRUD write and the
+ * refine-providers package implements no `custom` method: joining or leaving
+ * a meeting point POSTs an `as:Join`/`as:Leave` activity to the user's own
+ * outbox (`data/outbox.ts`), and the on-behalf backend maintains the
+ * `apods:attendees` collection. The collection travels inside the event
+ * record and is read by the participation binding (`membersOf`). A write made
+ * beside Refine does not refresh its cache, and the backend updates the
+ * collection asynchronously — so the join/leave reflects in an optimistic
+ * overlay here and the lists are invalidated for the write to land when it
+ * lands.
+ *
+ * THE COUNT AND THE ALICE/BOB RULE. The participant count of a meeting point
+ * is the collection's total — never the number of RESOLVED profiles, so it
+ * stays total for a reader who may read no profile. Resolving an attendee to
+ * a person is two fetches (actor document → profile) whose second is
+ * WAC-gated: a non-contact's refusal is absorbed in `data/actor.ts` and the
+ * screen renders a generic participant. `getUser` is a pure lookup over what
+ * resolved — undefined for everyone else.
+ */
+
+import React, { useState, useCallback, useEffect, useMemo, type ReactNode } from 'react';
+import { useList, useOne, useCreate, useUpdate, useInvalidate } from '@refinedev/core';
+import type { FpEventData, FpUserData, FpParticipationData, FpMeetingPointData } from '../data/types';
+import { fetchActorDocument, fetchActorProfileRecord } from '../data/actor';
+import { postJoinActivity, postLeaveActivity } from '../data/outbox';
+import { useCurrentSession } from '../utils/currentSession';
+import { useCurrentPrincipal } from '../utils/currentPrincipal';
+import type { BootstrapResult } from '../utils/ngBootstrap';
+import { RefineProvider, useBackend } from '../refine/RefineProvider';
+import type { RefineBackend } from '../refine/providers';
+import {
+ FestipodDataContext,
+ buildQueries,
+ resolveParticipantUser,
+ nextId,
+ type FestipodDataContextValue,
+ type EventOwnership,
+} from './festipodDataBase';
+
+export function RefineDataProvider({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function RefineDataInner({ children }: { children: ReactNode }) {
+ const backend = useBackend();
+ if (!backend) {
+ // Unreachable through the dispatcher (a session implies a registered
+ // backend); render an inert value rather than a tree whose data hook
+ // throws — nothing here may look like an empty wallet either.
+ console.error('[refine] RefineDataProvider mounted with no registered backend');
+ return {children} ;
+ }
+ return {children} ;
+}
+
+function RefineDataMounted({ backend, children }: { backend: RefineBackend; children: ReactNode }) {
+ const data = useRefineData(backend);
+ return {children} ;
+}
+
+/** A do-nothing context value for the unreachable no-backend mount. */
+function inertDataValue(): FestipodDataContextValue {
+ const none = {
+ currentUserId: '', currentUser: undefined, currentPrincipal: '',
+ events: [], users: [], participations: [], meetingPoints: [], friendships: [], notifications: [],
+ selectedEventId: '', selectedEvent: undefined, selectedUserId: '', selectedUser: undefined,
+ getEvent: () => undefined, getUser: () => undefined,
+ getEventParticipants: () => [], getUserEvents: () => [], isParticipating: () => false,
+ getFriends: () => [], getEventMeetingPoints: () => [], getEventOwnership: (): EventOwnership => 'unknown',
+ setSelectedEventId: () => {}, setSelectedUserId: () => {},
+ createEvent: async (event: Omit) => ({ ...event, id: nextId('event') }),
+ updateEvent: () => {}, joinMeetingPoint: () => {}, leaveMeetingPoint: () => {},
+ addMeetingPoint: () => {}, addFriend: () => {}, updateProfile: () => {}, loadTestData: async () => emptyBootstrap(),
+ };
+ return none;
+}
+
+function emptyBootstrap(): BootstrapResult {
+ return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } };
+}
+
+/** A membership overlay entry: what this session just joined or left. */
+type MembershipOverlay = Map;
+
+function useRefineData(backend: RefineBackend): FestipodDataContextValue {
+ const bindings = backend.bindings;
+ const session = useCurrentSession();
+ const webId = session?.webId;
+ const token = session?.token;
+
+ // WHO AM I — the Solid identity the session signed in as (display + the
+ // membership key; the attendees collection carries actor URIs). The app's own
+ // entity id space (`currentUserId`) is the PROFILE document URI, resolved
+ // from the actor document below — the two never mix.
+ const currentPrincipal = useCurrentPrincipal();
+
+ // --- READS over the shared as/Event container ------------------------------
+ // ONE list for both entities: events and meeting points resolve the same
+ // container, so a single fetch serves both and the bindings split it.
+ // `enabled: !!session` — pre-session this path renders empty rather than
+ // firing unauthenticated queries.
+ const containerQuery = useList({
+ resource: bindings.event.resource,
+ queryOptions: { enabled: !!session, retry: false },
+ });
+ const rawRecords: unknown[] = useMemo(
+ () => (containerQuery.result.data ?? []) as unknown[],
+ [containerQuery.result.data],
+ );
+ const containerFailed = containerQuery.query.isError;
+
+ // --- SPLIT + PARTICIPATION/COUNT DERIVATION (vocabulary lives in bindings) --
+ const derived = useMemo(() => {
+ const events: FpEventData[] = [];
+ const meetingPoints: FpMeetingPointData[] = [];
+ // participations keyed BY MEETING POINT id: joining targets the meeting
+ // point; the anchor event's participants are derived as the union across
+ // its meeting points (below).
+ const participations: FpParticipationData[] = [];
+ const totals = new Map();
+ for (const record of rawRecords) {
+ if (bindings.meetingPoint.isInstance?.(record) ?? false) {
+ const mp = bindings.meetingPoint.toDomain(record);
+ meetingPoints.push(mp);
+ const attendees = bindings.participation.membersOf?.(record);
+ const members = attendees?.members ?? [];
+ for (const member of members) {
+ const mapped = bindings.participation.toDomain(member);
+ participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id });
+ }
+ totals.set(mp.id, attendees?.total ?? members.length);
+ } else if (bindings.event.isInstance?.(record) ?? true) {
+ events.push(bindings.event.toDomain(record));
+ }
+ }
+ // The event's count is the SUM of its meeting points' collection totals —
+ // derived at read time, never a stored counter. A record whose collection
+ // could not be read contributes 0, which is the honest "unknown" reading
+ // of a total nobody could count for us.
+ for (const event of events) {
+ event.participantCount = meetingPoints
+ .filter(mp => mp.eventId === event.id)
+ .reduce((sum, mp) => sum + (totals.get(mp.id) ?? 0), 0);
+ }
+ return { events, meetingPoints, participations, totals };
+ }, [rawRecords, bindings]);
+
+ // --- OPTIMISTIC MEMBERSHIP OVERLAY ------------------------------------------
+ // `watchShape`-style latency, Refine edition: the outbox post returns before
+ // the backend has maintained the collection, and the container re-fetch can
+ // still precede it. The overlay reflects the join/leave immediately and
+ // reconciles away once the read carries the truth — no polling.
+ const [overlay, setOverlay] = useState(new Map());
+
+ const participations = useMemo(() => {
+ if (overlay.size === 0 || !webId) return derived.participations;
+ const byMp = new Map();
+ for (const p of derived.participations) {
+ const list = byMp.get(p.eventId) ?? [];
+ list.push(p);
+ byMp.set(p.eventId, list);
+ }
+ for (const [mpId, op] of overlay) {
+ const current = byMp.get(mpId) ?? [];
+ if (op === 'join') {
+ if (!current.some(p => p.userId === webId)) {
+ const mp = derived.meetingPoints.find(m => m.id === mpId);
+ byMp.set(mpId, [...current, { id: `${mpId}|${webId}`, eventId: mpId, userId: webId, isConfirmed: true }]);
+ }
+ } else {
+ byMp.set(mpId, current.filter(p => p.userId !== webId));
+ }
+ }
+ return [...byMp.values()].flat();
+ }, [derived, overlay, webId]);
+
+ // RECONCILIATION — drop each overlay entry once the container read carries
+ // what it asserts (join: membership present; leave: membership absent).
+ useEffect(() => {
+ if (overlay.size === 0 || !webId) return;
+ setOverlay(prev => {
+ let changed = false;
+ const next = new Map(prev);
+ for (const [mpId, op] of prev) {
+ const present = derived.participations.some(p => p.eventId === mpId && p.userId === webId);
+ if ((op === 'join' && present) || (op === 'leave' && !present)) {
+ next.delete(mpId);
+ changed = true;
+ }
+ }
+ return changed ? next : prev;
+ });
+ }, [derived.participations, overlay, webId]);
+
+ // --- MY PROFILE (the app's own entity id space) ------------------------------
+ // The actor (WebID) document links the `as:Profile` record via `url`; the
+ // profile read is a plain getOne on that URI. No profile link (or an
+ // unreadable profile) → no currentUser: `updateProfile` refuses loudly
+ // rather than landing edits on a stranger's document.
+ const [profileUri, setProfileUri] = useState('');
+ useEffect(() => {
+ if (!webId || !token) return;
+ let cancelled = false;
+ fetchActorDocument(webId, token)
+ .then(actor => {
+ if (!cancelled) setProfileUri(actor.profileUri ?? '');
+ })
+ .catch(err => console.error('[refine] actor document read failed:', err));
+ return () => { cancelled = true; };
+ }, [webId, token]);
+
+ const myProfileQuery = useOne({
+ resource: bindings.profile.resource,
+ id: profileUri,
+ queryOptions: { enabled: !!profileUri && !!session, retry: false },
+ });
+ const myProfile: FpUserData | undefined = useMemo(() => {
+ const record: unknown = myProfileQuery.result;
+ return record !== undefined ? bindings.profile.toDomain(record) : undefined;
+ }, [myProfileQuery.result, bindings]);
+ const currentUser = myProfile;
+ const currentUserId = myProfile?.id ?? '';
+
+ // --- PARTICIPANT RESOLUTION (the Alice/Bob rule) ------------------------------
+ // Attendee refs are actor URIs; resolving them to displayable profiles is
+ // two fetches whose second is WAC-gated. Refusals are absorbed in
+ // `data/actor.ts` (undefined) — a non-contact renders as a generic
+ // participant and the TOTAL above stays the collection's count.
+ const [resolvedProfiles, setResolvedProfiles] = useState([]);
+ const [unresolved, setUnresolved] = useState>(new Set());
+ const attendeeIds = useMemo(
+ () => [...new Set(participations.map(p => p.userId))].filter(id => id !== webId),
+ [participations, webId],
+ );
+ useEffect(() => {
+ if (!token) return;
+ const missing = attendeeIds.filter(id => !unresolved.has(id) && !resolvedProfiles.some(u => u.id === id));
+ if (missing.length === 0) return;
+ let cancelled = false;
+ void (async () => {
+ const fresh: FpUserData[] = [];
+ const refused: string[] = [];
+ for (const id of missing) {
+ const record = await fetchActorProfileRecord(id, token);
+ if (record === undefined) {
+ refused.push(id);
+ } else {
+ fresh.push(bindings.profile.toDomain(record));
+ }
+ }
+ if (cancelled) return;
+ if (fresh.length) setResolvedProfiles(prev => [...prev, ...fresh]);
+ if (refused.length) setUnresolved(prev => new Set([...prev, ...refused]));
+ })();
+ return () => { cancelled = true; };
+ // resolvedProfiles is read to compute `missing` but not listed: listing it
+ // would re-run the effect on every resolution it itself performs.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [attendeeIds, unresolved, token, bindings]);
+
+ const users = useMemo(() => {
+ const all = myProfile ? [myProfile, ...resolvedProfiles] : resolvedProfiles;
+ const seen = new Set();
+ return all.filter(u => (seen.has(u.id) ? false : (seen.add(u.id), true)));
+ }, [myProfile, resolvedProfiles]);
+
+ // --- Selection (same state contract as every provider) -----------------------
+ const [selectedEventId, setSelectedEventId] = useState('');
+ const [selectedUserId, setSelectedUserId] = useState('');
+
+ useEffect(() => {
+ const first = derived.events[0];
+ if (!selectedEventId && first) setSelectedEventId(first.id);
+ }, [derived.events, selectedEventId]);
+
+ // Identity-first log prefix — same shape as the other providers.
+ const logPrefix = currentUserId || currentPrincipal
+ ? `[${currentUserId || currentPrincipal}][app][data]`
+ : '[app][data]';
+
+ // --- Event-level derivations over meeting-point memberships -------------------
+ const mpIdsOf = (eventId: string) =>
+ derived.meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id);
+
+ // Shared queries whose participation semantics already match (memberships are
+ // keyed by the joined target's id — here, the meeting point). `currentUserId`
+ // passed here is the MEMBERSHIP default key, which on this backend is the
+ // actor URI, not the profile URI.
+ const shared = buildQueries(
+ derived.events,
+ users,
+ participations,
+ derived.meetingPoints,
+ [],
+ webId ?? '',
+ );
+
+ const getEventParticipants = useCallback((eventId: string) => {
+ // The event's participants are the union across its meeting points'
+ // memberships, resolved to profiles. Unresolved attendees are NOT here —
+ // the count on the event stays the total.
+ const ids = new Set();
+ for (const mpId of mpIdsOf(eventId)) {
+ for (const p of participations.filter(part => part.eventId === mpId)) {
+ const u = resolveParticipantUser(p.userId, users);
+ if (u) ids.add(u.id);
+ }
+ }
+ return users.filter(u => ids.has(u.id));
+ }, [derived.meetingPoints, participations, users]);
+
+ const getUserEvents = useCallback((userId: string) => {
+ // Answerable for the signed-in user only: memberships are what this
+ // session can read. Someone else's events-by-participation are not
+ // reachable through an own-pod-scoped read.
+ if (userId !== currentUserId || !webId) return [];
+ const joinedEventIds = new Set();
+ for (const p of participations) {
+ if (p.userId !== webId) continue;
+ const mp = derived.meetingPoints.find(m => m.id === p.eventId);
+ if (mp) joinedEventIds.add(mp.eventId);
+ }
+ return derived.events.filter(e => joinedEventIds.has(e.id));
+ }, [currentUserId, webId, participations, derived.meetingPoints, derived.events]);
+
+ // --- Ownership: editing is owning ------------------------------------------------
+ // The list is own-pod scoped, so membership in it IS ownership. A failed
+ // container read means the pod holds no event container — hence nothing
+ // owned: 'not-mine', said once.
+ useEffect(() => {
+ if (containerFailed) {
+ console.warn(`${logPrefix} events container unreadable (not provisioned, or grants missing) — treating as empty`);
+ }
+ }, [containerFailed, logPrefix]);
+
+ const getEventOwnership = useCallback((eventId: string): EventOwnership => {
+ if (derived.events.some(e => e.id === eventId)) return 'mine';
+ if (containerQuery.query.isPending) return 'unknown';
+ return 'not-mine';
+ }, [derived.events, containerQuery.query.isPending]);
+
+ // --- Mutations through the data provider ------------------------------------------
+ const { mutateAsync: createRecord } = useCreate();
+ const { mutateAsync: updateRecord } = useUpdate();
+ const invalidate = useInvalidate();
+
+ const createEvent = useCallback(async (event: Omit): Promise => {
+ console.log(`${logPrefix} createEvent (refine):`, event.title);
+ const response = await createRecord({
+ resource: bindings.event.resource,
+ values: bindings.event.toValues(event),
+ });
+ const created = bindings.event.toDomain(response.data);
+ void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
+ if (currentUserId) setSelectedEventId(created.id);
+ return created;
+ }, [logPrefix, createRecord, invalidate, bindings, currentUserId]);
+
+ const updateEvent = useCallback(async (id: string, updates: Partial) => {
+ console.log(`${logPrefix} updateEvent (refine):`, id, updates);
+ await updateRecord({
+ resource: bindings.event.resource,
+ id,
+ values: bindings.event.toPatch(updates),
+ });
+ void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
+ }, [logPrefix, updateRecord, invalidate, bindings]);
+
+ const addMeetingPoint = useCallback((mp: Omit) => {
+ console.log(`${logPrefix} addMeetingPoint (refine):`, mp.location);
+ // The host is the creator — this session. The screen navigates away right
+ // after; the optimistic mutation + invalidation lands the point on the
+ // detail screen when the write returns.
+ const values = bindings.meetingPoint.toValues({ ...mp, hostId: mp.hostId ?? webId });
+ void createRecord({ resource: bindings.meetingPoint.resource, values })
+ .then(() => invalidate({ resource: bindings.event.resource, invalidates: ['list'] }))
+ .catch(err => console.error(`${logPrefix} addMeetingPoint failed:`, err));
+ }, [logPrefix, createRecord, invalidate, bindings, webId]);
+
+ const updateProfile = useCallback(async (updates: Partial) => {
+ console.log(`${logPrefix} updateProfile (refine):`, updates);
+ if (!profileUri) {
+ const msg = 'updateProfile refused: no profile of mine is resolved (the actor document ' +
+ 'links none, or it is not readable) — these edits must never land in someone else\'s document.';
+ console.error(`${logPrefix} ${msg}`);
+ throw new Error(msg);
+ }
+ await updateRecord({
+ resource: bindings.profile.resource,
+ id: profileUri,
+ values: bindings.profile.toPatch(updates),
+ });
+ void invalidate({ resource: bindings.profile.resource, invalidates: ['list', 'detail'] });
+ }, [logPrefix, updateRecord, invalidate, bindings, profileUri]);
+
+ // --- Join / leave through the outbox, beside Refine --------------------------------
+ const joinMeetingPoint = useCallback(async (meetingPointId: string, userId?: string) => {
+ if (userId && userId !== webId) {
+ // Memberships are the user's own acts; the optional userId exists for
+ // parity with the other providers (and their harnesses).
+ console.warn(`${logPrefix} joinMeetingPoint for another user (${userId}) is not supported on this backend — ignored`);
+ return;
+ }
+ const mp = derived.meetingPoints.find(m => m.id === meetingPointId);
+ if (!mp || !session) {
+ throw new Error(`joinMeetingPoint refused: ${meetingPointId} is not in the read set ` +
+ '(or there is no session) — the write would target a reference this session cannot see.');
+ }
+ console.log(`${logPrefix} joinMeetingPoint (refine):`, meetingPointId);
+ await postJoinActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
+ setOverlay(prev => new Map(prev).set(mp.id, 'join'));
+ void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
+ }, [logPrefix, derived.meetingPoints, session, webId, invalidate, bindings]);
+
+ const leaveMeetingPoint = useCallback(async (meetingPointId: string, userId?: string) => {
+ if (userId && userId !== webId) {
+ console.warn(`${logPrefix} leaveMeetingPoint for another user (${userId}) is not supported on this backend — ignored`);
+ return;
+ }
+ const mp = derived.meetingPoints.find(m => m.id === meetingPointId);
+ if (!mp || !session) {
+ throw new Error(`leaveMeetingPoint refused: ${meetingPointId} is not in the read set ` +
+ '(or there is no session).');
+ }
+ const member = participations.find(p => p.eventId === mp.id && p.userId === webId);
+ if (!member) {
+ const msg = `leaveMeetingPoint refused: no membership of mine on ${mp.id} — nothing was withdrawn.`;
+ console.error(`${logPrefix} ${msg}`);
+ throw new Error(msg);
+ }
+ console.log(`${logPrefix} leaveMeetingPoint (refine):`, meetingPointId);
+ await postLeaveActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
+ setOverlay(prev => new Map(prev).set(mp.id, 'leave'));
+ void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
+ }, [logPrefix, derived.meetingPoints, participations, session, webId, invalidate, bindings]);
+
+ // --- The rest of the contract -------------------------------------------------------
+ const addFriend = useCallback((friendId: string) => {
+ // Friendship has no persistence on this backend (the seam declares four
+ // entities; friendship is deliberately not one of them). The contacts
+ // concept lives on the platform — wiring it is a follow-up, not a local
+ // lie.
+ console.log(`${logPrefix} addFriend (refine, no-op — friendship is not persisted on this backend):`, friendId);
+ }, [logPrefix]);
+
+ const loadTestData = useCallback(async (): Promise => {
+ // Seeding fixtures into a Pod is meaningless on this backend (and the
+ // fixture seed is switched off outright) — the honest answer is "nothing
+ // was seeded".
+ console.log(`${logPrefix} loadTestData (refine, no-op)`);
+ return emptyBootstrap();
+ }, [logPrefix]);
+
+ const selectedEvent = derived.events.find(e => e.id === selectedEventId);
+ const selectedUser = users.find(u => u.id === selectedUserId);
+
+ console.log(`${logPrefix} Render — refine/${backend.name} | events:`, derived.events.length,
+ '| meetingPoints:', derived.meetingPoints.length, '| participations:', participations.length,
+ '| selectedEvent:', selectedEvent?.title ?? '(none)');
+
+ return {
+ currentUserId, currentUser, currentPrincipal,
+ events: derived.events,
+ users,
+ participations,
+ meetingPoints: derived.meetingPoints,
+ friendships: [],
+ notifications: [],
+ selectedEventId, setSelectedEventId, selectedEvent,
+ selectedUserId, setSelectedUserId, selectedUser,
+ getEvent: shared.getEvent,
+ getUser: shared.getUser,
+ getEventParticipants,
+ getUserEvents,
+ isParticipating: shared.isParticipating,
+ getFriends: shared.getFriends,
+ getEventMeetingPoints: shared.getEventMeetingPoints,
+ getEventOwnership,
+ createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
+ addMeetingPoint, addFriend, updateProfile, loadTestData,
+ };
+}
diff --git a/src/shared/data/actor.test.ts b/src/shared/data/actor.test.ts
new file mode 100644
index 0000000..120a971
--- /dev/null
+++ b/src/shared/data/actor.test.ts
@@ -0,0 +1,107 @@
+import { expect, test } from 'bun:test';
+import { fetchActorDocument, fetchActorProfileRecord } from './actor';
+
+function installFetch(handler: (url: string, init: RequestInit) => Response) {
+ const calls: Array<{ url: string; init: RequestInit }> = [];
+ const original = globalThis.fetch;
+ globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
+ const urlString = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
+ const requestInit = init ?? {};
+ calls.push({ url: urlString, init: requestInit });
+ return Promise.resolve(handler(urlString, requestInit));
+ }) as typeof fetch;
+ return {
+ calls,
+ restore() {
+ globalThis.fetch = original;
+ },
+ };
+}
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/ld+json' },
+ });
+}
+
+const ACTOR = 'http://localhost:3000/alice';
+const PROFILE = 'http://localhost:3000/alice/as/profile';
+
+test('fetchActorDocument extracts the outbox and the profile link, compact or prefixed', async () => {
+ const double = installFetch((url) => {
+ if (url === ACTOR) {
+ return jsonResponse({ id: ACTOR, outbox: 'http://localhost:3000/alice/outbox', url: PROFILE });
+ }
+ return jsonResponse({}, 404);
+ });
+ try {
+ const actor = await fetchActorDocument(ACTOR, 'tok-1');
+ expect(actor).toEqual({ outbox: 'http://localhost:3000/alice/outbox', profileUri: PROFILE });
+ expect((double.calls[0]!.init.headers as Record)['Authorization']).toBe('Bearer tok-1');
+ } finally {
+ double.restore();
+ }
+});
+
+test('fetchActorDocument reads the as:-prefixed outbox form and tolerates a missing link', async () => {
+ const double = installFetch(() => jsonResponse({ '@id': ACTOR, 'as:outbox': { id: 'http://localhost:3000/alice/outbox' } }));
+ try {
+ const actor = await fetchActorDocument(ACTOR);
+ expect(actor.outbox).toBe('http://localhost:3000/alice/outbox');
+ expect(actor.profileUri).toBeUndefined();
+ } finally {
+ double.restore();
+ }
+});
+
+test('fetchActorDocument throws on a non-2xx actor read — it is a probe, not the absorbing path', async () => {
+ const double = installFetch(() => jsonResponse({}, 500));
+ try {
+ await expect(fetchActorDocument(ACTOR)).rejects.toThrow(/HTTP 500/);
+ } finally {
+ double.restore();
+ }
+});
+
+// THE ALICE/BOB RULE: every failure mode of the profile read — a WAC refusal on
+// the profile, a missing actor link, an unreadable actor — reads back as
+// undefined, never throws. The non-contact answer IS the refusal.
+test('fetchActorProfileRecord absorbs the WAC refusal and the missing-link cases', async () => {
+ const refusing = installFetch((url) => {
+ if (url === ACTOR) return jsonResponse({ id: ACTOR, url: PROFILE });
+ return jsonResponse({ error: 'Forbidden' }, 403);
+ });
+ try {
+ expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toBeUndefined();
+ } finally {
+ refusing.restore();
+ }
+
+ const noLink = installFetch(() => jsonResponse({ id: ACTOR }));
+ try {
+ expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toBeUndefined();
+ } finally {
+ noLink.restore();
+ }
+
+ const actorFails = installFetch(() => jsonResponse({}, 404));
+ try {
+ expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toBeUndefined();
+ } finally {
+ actorFails.restore();
+ }
+});
+
+test('fetchActorProfileRecord returns the raw profile record on success', async () => {
+ const record = { id: PROFILE, type: 'Profile', name: 'Alice' };
+ const double = installFetch((url) => {
+ if (url === ACTOR) return jsonResponse({ id: ACTOR, url: PROFILE });
+ return jsonResponse(record);
+ });
+ try {
+ expect(await fetchActorProfileRecord(ACTOR, 'tok-1')).toEqual(record);
+ } finally {
+ double.restore();
+ }
+});
diff --git a/src/shared/data/actor.ts b/src/shared/data/actor.ts
new file mode 100644
index 0000000..6b935cc
--- /dev/null
+++ b/src/shared/data/actor.ts
@@ -0,0 +1,80 @@
+/**
+ * Actor document and profile reads — the small, direct-HTTP side of the
+ * ActivityPods social model, living BESIDE Refine because the refine-providers
+ * package implements no `custom` method and these reads are not CRUD over the
+ * app's own containers.
+ *
+ * WHAT AN ACTOR DOCUMENT IS. The WebID document IS the ActivityPub actor: it
+ * carries the `outbox` (where `as:Join`/`as:Leave` are posted) and `url`
+ * (linking to the user's `as:Profile` record, the one readable display-name
+ * source). Neither is exposed by the auth provider's session or identity.
+ *
+ * THE ALICE/BOB RULE IS ABSORBED HERE. Resolving an attendee to a profile is
+ * two fetches (actor → `url` → profile), and the second is WAC-gated: only a
+ * contact may read it. A refusal, a missing link or a malformed document all
+ * read back as `undefined` — the caller renders a generic participant and the
+ * TOTAL stays the count of the collection, never of the resolved. A failure is
+ * not exceptional in this model; it IS the non-contact answer.
+ */
+
+import { asRecord, linkId } from './jsonRecord';
+
+/** The parts of the actor (WebID) document this app uses. */
+export interface ActorDocument {
+ /** The actor's outbox URI — where activities are posted. */
+ outbox?: string;
+ /** URI of the actor's `as:Profile` record (the `url` predicate). */
+ profileUri?: string;
+}
+
+/**
+ * Fetch the actor document and extract what the app uses. The WebID document
+ * is public on the platform (verified: an anonymous read is not refused); the
+ * token is still sent — the reference integration does the same, and a pod
+ * whose WebID is not public answers whatever its ACL says. A network or
+ * non-2xx failure THROWS: this fetch is the app's own probe, not the
+ * refusal-absorbing path.
+ */
+export async function fetchActorDocument(webId: string, token?: string): Promise {
+ const response = await fetch(webId, {
+ headers: {
+ Accept: 'application/ld+json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ });
+ if (!response.ok) {
+ throw new Error(`actor document fetch failed: HTTP ${response.status} for ${webId}`);
+ }
+ const body: unknown = await response.json();
+ const record = asRecord(body);
+ return {
+ outbox: linkId(record?.['outbox']) ?? linkId(record?.['as:outbox']),
+ profileUri: linkId(record?.['url']),
+ };
+}
+
+/**
+ * Resolve an actor (WebID) URI to its profile RECORD, or `undefined` when the
+ * profile cannot be read. Two fetches: the actor document, then the profile it
+ * links. Every failure mode — 403 (the Alice/Bob non-contact refusal), 404, a
+ * missing `url`, a non-object body — collapses to `undefined`; the record is
+ * returned raw for the binding's `toDomain` to map, so this module stays free
+ * of vocabulary.
+ */
+export async function fetchActorProfileRecord(webId: string, token?: string): Promise {
+ try {
+ const actor = await fetchActorDocument(webId, token);
+ if (!actor.profileUri) return undefined;
+ const response = await fetch(actor.profileUri, {
+ headers: {
+ Accept: 'application/ld+json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ });
+ if (!response.ok) return undefined;
+ const body: unknown = await response.json();
+ return asRecord(body) ?? undefined;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/src/shared/data/jsonRecord.ts b/src/shared/data/jsonRecord.ts
new file mode 100644
index 0000000..aeefca8
--- /dev/null
+++ b/src/shared/data/jsonRecord.ts
@@ -0,0 +1,43 @@
+/**
+ * Typed readers for untyped JSON-LD values.
+ *
+ * Pod documents arrive as plain parsed JSON whose predicates are verbatim but
+ * whose values are untyped. These helpers narrow them without ever naming
+ * `any`, so callers stay honest about what a record MAY carry. The same
+ * vocabulary-free narrowing exists inside the ActivityPods binding
+ * (`refine/providers/activitypods.ts`); both copies are deliberate — the
+ * binding never exports its internals, and the data context must not import one
+ * backend module to serve another.
+ */
+
+export type JsonRecord = Record;
+
+/** The value when it is a plain object (not null, not an array). */
+export function asRecord(value: unknown): JsonRecord | undefined {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as JsonRecord)
+ : undefined;
+}
+
+/** The value when it is a string. */
+export function asString(value: unknown): string | undefined {
+ return typeof value === 'string' ? value : undefined;
+}
+
+/** First string of a predicate that may hold a single value or an array of them. */
+export function firstString(value: unknown): string | undefined {
+ return asString(value) ?? (Array.isArray(value) ? asString(value[0]) : undefined);
+}
+
+/** A linked value in an ActivityStreams record: a bare IRI string, or an object carrying id/@id. */
+export function linkId(value: unknown): string | undefined {
+ const direct = asString(value);
+ if (direct) return direct;
+ const object = asRecord(value);
+ return object ? (asString(object['id']) ?? asString(object['@id'])) : undefined;
+}
+
+/** First entry of a predicate that may hold a single value or an array of them. */
+export function firstEntry(value: unknown): unknown {
+ return Array.isArray(value) ? value[0] : value;
+}
diff --git a/src/shared/data/outbox.test.ts b/src/shared/data/outbox.test.ts
new file mode 100644
index 0000000..e5cf26c
--- /dev/null
+++ b/src/shared/data/outbox.test.ts
@@ -0,0 +1,111 @@
+import { expect, test, afterEach } from 'bun:test';
+import { postJoinActivity, postLeaveActivity } from './outbox';
+import type { OutboxActivityInput } from './outbox';
+
+const ACTOR = 'http://localhost:3000/alice';
+const OUTBOX = 'http://localhost:3000/alice/outbox';
+const MEETING_POINT = 'http://localhost:3000/alice/data/as/event/mp-1';
+
+const input = (hostUri: string | undefined): OutboxActivityInput => ({
+ meetingPointUri: MEETING_POINT,
+ hostUri,
+ session: { webId: ACTOR, token: 'tok-1' },
+});
+
+/** Installs a scripted fetch double, returning the calls it saw. */
+function installFetch(handler: (url: string, init: RequestInit) => Response) {
+ const calls: Array<{ url: string; init: RequestInit }> = [];
+ const original = globalThis.fetch;
+ globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
+ const urlString = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
+ const requestInit = init ?? {};
+ calls.push({ url: urlString, init: requestInit });
+ return Promise.resolve(handler(urlString, requestInit));
+ }) as typeof fetch;
+ return {
+ calls,
+ restore() {
+ globalThis.fetch = original;
+ },
+ };
+}
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/ld+json' },
+ });
+}
+
+afterEach(() => {
+ // Each test installs its own double; nothing to do here beyond documenting
+ // that fetch is always restored by the per-test handle.
+});
+
+test('postJoinActivity discovers the outbox from the actor document and posts a Join to it', async () => {
+ const double = installFetch((url, init) => {
+ if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX, url: `${ACTOR}/as/profile` });
+ return jsonResponse({}, 201);
+ });
+ try {
+ await postJoinActivity(input('http://localhost:3000/bob'));
+ expect(double.calls).toHaveLength(2);
+ const [actorCall, postCall] = double.calls;
+ expect(actorCall).toBeDefined();
+ expect(postCall).toBeDefined();
+ expect(actorCall!.url).toBe(ACTOR);
+ expect(postCall!.url).toBe(OUTBOX);
+ expect(postCall!.init.method).toBe('POST');
+ const headers = postCall!.init.headers as Record;
+ expect(headers['Authorization']).toBe('Bearer tok-1');
+ expect(headers['Content-Type']).toBe('application/ld+json');
+ const body = JSON.parse(String(postCall!.init.body));
+ expect(body).toEqual({
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ type: 'Join',
+ actor: ACTOR,
+ object: MEETING_POINT,
+ to: 'http://localhost:3000/bob',
+ });
+ } finally {
+ double.restore();
+ }
+});
+
+test('postLeaveActivity posts a Leave, and a missing host means no to field', async () => {
+ const double = installFetch((url) => {
+ if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
+ return jsonResponse({}, 201);
+ });
+ try {
+ await postLeaveActivity(input(undefined));
+ const postCall = double.calls[1]!;
+ const body = JSON.parse(String(postCall.init.body));
+ expect(body.type).toBe('Leave');
+ expect(body).not.toHaveProperty('to');
+ } finally {
+ double.restore();
+ }
+});
+
+test('an actor document without an outbox refuses the post, naming the activity', async () => {
+ const double = installFetch(() => jsonResponse({ id: ACTOR }));
+ try {
+ await expect(postJoinActivity(input(undefined))).rejects.toThrow(/carries no outbox.*Join/s);
+ expect(double.calls).toHaveLength(1);
+ } finally {
+ double.restore();
+ }
+});
+
+test('a non-2xx outbox response throws with the status', async () => {
+ const double = installFetch((url) => {
+ if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
+ return jsonResponse({ error: 'Forbidden' }, 403);
+ });
+ try {
+ await expect(postLeaveActivity(input(undefined))).rejects.toThrow(/HTTP 403/);
+ } finally {
+ double.restore();
+ }
+});
diff --git a/src/shared/data/outbox.ts b/src/shared/data/outbox.ts
new file mode 100644
index 0000000..b7c4a29
--- /dev/null
+++ b/src/shared/data/outbox.ts
@@ -0,0 +1,77 @@
+/**
+ * Outbox posting — how a user joins or leaves a meeting point.
+ *
+ * WHY THIS LIVES BESIDE REFINE. The refine-providers package implements no
+ * `custom` method: there is no escape hatch through the data provider, and a
+ * participation is not a CRUD write anyway. The platform's production
+ * precedent (recorded in the data-layer brief, reconnaissance of 2026-10-09)
+ * is: a join is an `as:Join` activity posted to the USER'S OWN outbox with the
+ * user's token; the on-behalf backend watches the flow and maintains the
+ * meeting point's `apods:attendees` collection; membership IS the attendance —
+ * a leave is `as:Leave` on the same path, and an absence from the collection
+ * reads as not attending.
+ *
+ * THE FLOW. The outbox URI is read off the user's own actor (WebID) document
+ * (`outbox` predicate) at post time — the reference integration resolves it
+ * the same way, and posting before the actor document has been read is what it
+ * guards against. The activity's `object` is the meeting point's URI and its
+ * `to` is the meeting point's host, so the host's Pod receives it.
+ *
+ * ASYNCHRONY IS THE PLATFORM'S. The collection update lands when the backend
+ * has processed the activity — the caller must not treat the 2xx of the post
+ * as the membership. The data layer carries an optimistic overlay for exactly
+ * that gap.
+ */
+
+import type { CurrentSession } from '../utils/currentSession';
+import { fetchActorDocument } from './actor';
+
+/** The vocabulary context the posted activity carries. */
+const ACTIVITY_CONTEXT = 'https://www.w3.org/ns/activitystreams';
+
+export interface OutboxActivityInput {
+ /** URI of the meeting point the activity targets. */
+ meetingPointUri: string;
+ /** The meeting point's host (as:organizer) — where the activity is addressed. */
+ hostUri: string | undefined;
+ /** The authenticated session — whose outbox receives the activity. */
+ session: CurrentSession;
+}
+
+async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise {
+ const { meetingPointUri, hostUri, session } = input;
+ const actor = await fetchActorDocument(session.webId, session.token);
+ if (!actor.outbox) {
+ throw new Error(`actor document for ${session.webId} carries no outbox — cannot post ${type}`);
+ }
+ const activity: Record = {
+ '@context': ACTIVITY_CONTEXT,
+ type,
+ actor: session.webId,
+ object: meetingPointUri,
+ };
+ if (hostUri) activity['to'] = hostUri;
+
+ const response = await fetch(actor.outbox, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/ld+json',
+ Accept: 'application/ld+json',
+ Authorization: `Bearer ${session.token}`,
+ },
+ body: JSON.stringify(activity),
+ });
+ if (!response.ok) {
+ throw new Error(`outbox ${type} post failed: HTTP ${response.status} for ${actor.outbox}`);
+ }
+}
+
+/** Post an `as:Join` activity to the user's outbox — signing up to a meeting point. */
+export function postJoinActivity(input: OutboxActivityInput): Promise {
+ return postActivity('Join', input);
+}
+
+/** Post an `as:Leave` activity to the user's outbox — leaving a meeting point. */
+export function postLeaveActivity(input: OutboxActivityInput): Promise {
+ return postActivity('Leave', input);
+}
diff --git a/src/shared/data/stepDefinitions.ts b/src/shared/data/stepDefinitions.ts
index 82ff46e..dca0881 100644
--- a/src/shared/data/stepDefinitions.ts
+++ b/src/shared/data/stepDefinitions.ts
@@ -700,29 +700,29 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"pattern": "je peux m'inscrire à l'événement",
"keyword": "Then",
"file": "event.steps.ts",
- "sourceCode": "Then('je peux m\\'inscrire à l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasToggle = buttons.some(t => t.includes(\"J'y serai\") || t.includes('Je participe'));\n expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;\n});",
- "lineNumber": 80
+ "sourceCode": "Then('je peux m\\'inscrire à l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasSignup = buttons.some(t => t.includes(\"S'inscrire\") || t.includes('Inscrit'));\n expect(hasSignup, 'Event detail should expose a meeting-point signup button').to.be.true;\n});",
+ "lineNumber": 83
},
{
"pattern": "je peux me désinscrire de l'événement",
"keyword": "Then",
"file": "event.steps.ts",
- "sourceCode": "Then('je peux me désinscrire de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasToggle = buttons.some(t => t.includes(\"J'y serai\") || t.includes('Je participe'));\n expect(hasToggle, 'Event detail should expose a join/leave toggle button').to.be.true;\n});",
- "lineNumber": 89
+ "sourceCode": "Then('je peux me désinscrire de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n const hasSignup = buttons.some(t => t.includes(\"S'inscrire\") || t.includes('Inscrit'));\n expect(hasSignup, 'Event detail should expose a meeting-point signup toggle').to.be.true;\n});",
+ "lineNumber": 92
},
{
"pattern": "le formulaire contient le champ obligatoire {string}",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('le formulaire contient le champ obligatoire {string}', async function (this: FestipodWorld, fieldName: string) {\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n expectRequiredField(this, fieldName);\n});",
- "lineNumber": 100
+ "lineNumber": 103
},
{
"pattern": "le formulaire contient les champs obligatoires suivants:",
"keyword": "Then",
"file": "event.steps.ts",
"sourceCode": "Then('le formulaire contient les champs obligatoires suivants:', async function (this: FestipodWorld, dataTable) {\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n const expectedFields = dataTable.raw().flat();\n expectedFields.forEach((fieldName: string) => expectRequiredField(this, fieldName));\n});",
- "lineNumber": 105
+ "lineNumber": 108
},
{
"pattern": "l'application connectée affiche l'accueil",
diff --git a/src/shared/refine/providers/activitypods.test.ts b/src/shared/refine/providers/activitypods.test.ts
index 2fed03b..58887fd 100644
--- a/src/shared/refine/providers/activitypods.test.ts
+++ b/src/shared/refine/providers/activitypods.test.ts
@@ -300,3 +300,69 @@ test('backend satisfies the RefineBackend contract shape', () => {
time: '',
});
});
+
+// Events and meeting points share the as/Event container: the presence of
+// `as:context` is the ONLY distinction, and the discrimination lives in the
+// bindings (isInstance) so the reader splits a raw list without learning the
+// predicate.
+test('event and meetingPoint isInstance discriminate on the as:context link', () => {
+ const { event, meetingPoint } = getBackend('activitypods').bindings;
+ const plain = { id: 'http://localhost:3000/alice/data/1', type: 'Event', name: 'Soirée' };
+ const grafted = { ...plain, id: 'http://localhost:3000/alice/data/2', 'as:context': 'http://localhost:3000/alice/data/0' };
+ expect(event.isInstance?.(plain)).toBe(true);
+ expect(event.isInstance?.(grafted)).toBe(false);
+ expect(meetingPoint.isInstance?.(plain)).toBe(false);
+ expect(meetingPoint.isInstance?.(grafted)).toBe(true);
+});
+
+// The attendees collection travels inside the event record: members embedded
+// when the reader may read them, a bare collection URI when not, absent when
+// nobody joined. `totalItems` survives without members — the count stays total
+// for a reader who may resolve no profile (the Alice/Bob rule).
+test('participation membersOf reads the embedded apods:attendees collection in its shapes', () => {
+ const { participation } = getBackend('activitypods').bindings;
+ const bob = 'http://localhost:3000/bob';
+
+ const embedded = participation.membersOf?.({
+ id: 'ev-1',
+ 'apods:attendees': {
+ id: 'http://localhost:3000/alice/data/as/collection/att-1',
+ totalItems: 1,
+ items: [bob],
+ },
+ });
+ expect(embedded).toEqual({ members: [bob], total: 1 });
+
+ const prefixed = participation.membersOf?.({
+ id: 'ev-1',
+ 'apods:attendees': { totalItems: 2, 'as:items': [{ id: bob }] },
+ });
+ expect(prefixed?.members).toEqual([{ id: bob }]);
+ expect(prefixed?.total).toBe(2);
+
+ // A collection URI without an embed: nothing readable, no count.
+ expect(participation.membersOf?.({ id: 'ev-1', 'apods:attendees': 'http://localhost:3000/alice/data/as/collection/att-1' })).toBeUndefined();
+
+ // No collection predicate at all → nobody joined yet.
+ expect(participation.membersOf?.({ id: 'ev-1', name: 'Soirée' })).toBeUndefined();
+
+ // totalItems without items still yields the count (members unknown → []).
+ const totalOnly = participation.membersOf?.({
+ id: 'ev-1',
+ 'apods:attendees': { id: 'col-1', totalItems: 3 },
+ });
+ expect(totalOnly).toEqual({ members: [], total: 3 });
+});
+
+// The reader stamps the host record's id onto each member — a member carries
+// no link back to what it attends.
+test('a mapped member is a confirmed participation keyed by the attendee', () => {
+ const { participation } = getBackend('activitypods').bindings;
+ const member = participation.toDomain('http://localhost:3000/bob');
+ expect(member).toEqual({
+ id: 'http://localhost:3000/bob',
+ eventId: '',
+ userId: 'http://localhost:3000/bob',
+ isConfirmed: true,
+ });
+});
diff --git a/src/shared/refine/providers/activitypods.ts b/src/shared/refine/providers/activitypods.ts
index bf50dd0..7e615f5 100644
--- a/src/shared/refine/providers/activitypods.ts
+++ b/src/shared/refine/providers/activitypods.ts
@@ -69,8 +69,8 @@
* `setInterval` and a `visibilitychange` listener with no teardown (usage
* declaration, friction 1), and Festipod drives `getAppStatus()` on its own
* schedule. The `clientId` names the app; the app description document it points to
- * is Festipod's own to serve (a static file suffices) and lands with the Solid-OIDC
- * flow, so until then the URL is derived from the origin this page runs on.
+ * is served by Festipod's own backend, and the values resolve from the runtime
+ * config — the SAME source the AuthGate flow uses, so both halves agree.
*/
import {
@@ -79,6 +79,7 @@ import {
liveProvider as createApodsLiveProvider,
} from '@activitypods/refine-providers';
import type { FpEventData, FpMeetingPointData, FpParticipationData, FpUserData } from '../../data/types';
+import { resolveActivityPodsAuthConfig } from '../../utils/runtimeConfig';
import type { RecordBinding, RecordBindings, RefineBackend } from './types';
/** One JSON-LD record as the data provider returns it: predicates verbatim, values untyped. */
@@ -99,6 +100,11 @@ function firstString(value: unknown): string | undefined {
return asString(value) ?? (Array.isArray(value) ? asString(value[0]) : undefined);
}
+/** First entry of a predicate that may hold a single value or an array of them. */
+function firstEntry(value: unknown): unknown {
+ return Array.isArray(value) ? value[0] : value;
+}
+
/**
* The single identifier. `id` first, `@id` otherwise; a record carrying neither
* raises, per the contract's own rule.
@@ -305,12 +311,44 @@ function participationWrite(): never {
);
}
+/**
+ * The embedded `apods:attendees` read, one per event record. Three shapes:
+ * an object carrying `items`/`as:items` (members embedded — dereferenced when
+ * the reader may read them), a bare collection URI (the collection exists but
+ * does not embed), or absent (nobody joined). `totalItems` is reported
+ * whenever the collection object carries it. The member shape itself is the
+ * reconstruction's known gap (usage declaration, friction 5): a member is read
+ * as a reference — bare URI, or object with id/@id — and membership IS the
+ * attendance.
+ */
+function attendeesFromRecord(record: unknown): { members: unknown[]; total?: number } | undefined {
+ const r = asRecord(record);
+ const attendees = firstEntry(r?.['apods:attendees']);
+ const collection = asRecord(attendees);
+ if (!collection) return undefined;
+ const totalValue = collection['totalItems'];
+ const total = typeof totalValue === 'number' ? totalValue : undefined;
+ // `items` is a list, not a single-or-array predicate: take it as it comes.
+ const rawItems = collection['items'] ?? collection['as:items'];
+ let members: unknown[] = [];
+ if (Array.isArray(rawItems)) {
+ members = rawItems;
+ } else if (rawItems !== undefined && rawItems !== null) {
+ members = [rawItems];
+ }
+ if (members.length === 0 && total === undefined) return undefined;
+ return { members, total: total ?? members.length };
+}
+
const eventBinding: RecordBinding = {
recordKey: recordId,
domainId: recordId,
toDomain: eventFromRecord,
toValues: (data) => eventValues(data),
toPatch: (updates) => eventValues(updates),
+ // Events and meeting points share the as/Event container; an `as:context` link
+ // is what makes a record a MEETING point, so its absence is the event test.
+ isInstance: (record) => linkId(asRecord(record)?.['as:context']) === undefined,
updateReplaces: false,
resource: 'events',
visibility: 'public',
@@ -322,6 +360,8 @@ const meetingPointBinding: RecordBinding = {
toDomain: meetingPointFromRecord,
toValues: (data) => meetingPointValues(data),
toPatch: (updates) => meetingPointValues(updates),
+ // The distinction lives in the record, not the container — see the event binding.
+ isInstance: (record) => linkId(asRecord(record)?.['as:context']) !== undefined,
updateReplaces: false,
resource: 'meetingPoints',
// A meeting point hangs off a public event; who joins it is the protected fact, not the point itself.
@@ -347,6 +387,13 @@ const participationBinding: RecordBinding = {
toDomain: participationFromMember,
toValues: participationWrite,
toPatch: participationWrite,
+ // The collection travels with the event record: an embedded object whose
+ // `items`/`as:items` hold the members (dereferenced when the reader may read
+ // them), a bare collection URI when it does not embed, absent when nobody
+ // joined yet. `totalItems` survives even when members are not dereferenced,
+ // so the COUNT can stay total when the members are not readable — the
+ // Alice/Bob rule. No embed → undefined (nothing readable, count unknown → 0).
+ membersOf: attendeesFromRecord,
updateReplaces: false,
// Read through the events resource: the collection is embedded in the event record
// or fetched by `getOne` on the collection URI it carries — it is not a listed resource.
@@ -377,11 +424,13 @@ const JSON_CONTEXT = 'https://activitypods.org/context.json';
export const activitypods: RefineBackend = {
name: 'activitypods',
build() {
+ // The SAME config the AuthGate flow resolves (runtime config is the shared
+ // source of truth — T04.d): one clientId/redirect for both halves, so the
+ // session the OIDC flow establishes is the session the data provider reads.
+ const authConfig = resolveActivityPodsAuthConfig(window.location.origin);
const apodsAuth = createApodsAuthProvider({
- // The app description document this names is Festipod's own to serve — a static
- // file suffices — and lands with the Solid-OIDC flow; until then the URL is the
- // one this page can already answer for.
- clientId: `${window.location.origin}/client.json`,
+ clientId: authConfig.clientId,
+ redirectUri: authConfig.redirectUri,
// Disabled deliberately: the construction-time status timer leaks (usage
// declaration, friction 1). Festipod drives getAppStatus() itself.
appStatusCheckInterval: false,
diff --git a/src/shared/refine/providers/types.ts b/src/shared/refine/providers/types.ts
index 4749ad8..78cc088 100644
--- a/src/shared/refine/providers/types.ts
+++ b/src/shared/refine/providers/types.ts
@@ -55,6 +55,27 @@ export interface RecordBinding {
* `updateReplaces` is true.
*/
toPatch(updates: Partial): Record;
+ /**
+ * Whether a raw record IS an instance of this binding's entity — the
+ * container-discrimination case. On ActivityPods, events and meeting points
+ * share the `as:Event` container and the presence of `as:context` is the only
+ * distinction (the container does not discriminate, and Refine's in-memory
+ * filters are value-based, not presence-based). The vocabulary lives here,
+ * in the binding, so the reader can split a raw list without learning the
+ * predicate. Absent means "every record of the resource is an instance".
+ */
+ isInstance?(record: unknown): boolean;
+ /**
+ * The embedded-collection read, for entities whose records ARRIVE inside
+ * another record: an event's `apods:attendees` collection travels with the
+ * event record — its members dereferenced when the reader may read them, as
+ * bare references otherwise, and the collection URI alone when not embedded.
+ * Returns the members plus the total when known (a reader that may not
+ * dereference members can still hold a `totalItems`), or `undefined` when
+ * the record carries no readable collection. The reader stamps the host
+ * record's id onto each mapped member — a member has no link back.
+ */
+ membersOf?(record: unknown): { members: unknown[]; total?: number } | undefined;
/** True when `update` replaces the record rather than patching it. */
readonly updateReplaces: boolean;
/**
diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx
index fa47228..d647e90 100644
--- a/src/shared/test-harness/harness-ng.tsx
+++ b/src/shared/test-harness/harness-ng.tsx
@@ -245,10 +245,10 @@ function ScopedHarness({ publicGraph, protectedGraph }: { publicGraph: Nuri; pro
// --- Mutations (app path: per-entity docs + reactivity) ---
async joinEvent(eventId: string, userId: string) {
- await AD().joinEvent(eventId, userId);
+ await AD().joinMeetingPoint(eventId, userId);
},
async leaveEvent(eventId: string, userId: string) {
- await AD().leaveEvent(eventId, userId);
+ await AD().leaveMeetingPoint(eventId, userId);
},
// --- Real app-path registration (T02.c) ----------------------------
@@ -276,10 +276,10 @@ function ScopedHarness({ publicGraph, protectedGraph }: { publicGraph: Nuri; pro
return { id, title };
},
async appJoinEvent(eventId: string, userId?: string) {
- await AD().joinEvent(eventId, userId);
+ await AD().joinMeetingPoint(eventId, userId);
},
async appLeaveEvent(eventId: string, userId?: string) {
- await AD().leaveEvent(eventId, userId);
+ await AD().leaveMeetingPoint(eventId, userId);
},
/** A LIVE current user id, resolved from the users set AT CALL TIME (not
* frozen at bridge-build). Prefers the app context's principal; falls
diff --git a/src/shared/utils/currentSession.test.ts b/src/shared/utils/currentSession.test.ts
new file mode 100644
index 0000000..bfb7698
--- /dev/null
+++ b/src/shared/utils/currentSession.test.ts
@@ -0,0 +1,33 @@
+import { expect, test } from 'bun:test';
+import { getCurrentSession, setCurrentSession } from './currentSession';
+
+// The store is module-level and shared across the app; restore the absent
+// state after each test so no test leaks a session into its neighbours.
+function reset() {
+ setCurrentSession(undefined);
+}
+
+test('no session until the auth flow publishes one', () => {
+ reset();
+ expect(getCurrentSession()).toBeUndefined();
+});
+
+test('the published session reads back, and a same-value republish is a no-op', () => {
+ reset();
+ const session = { webId: 'http://localhost:3000/alice', token: 'tok-1' };
+ setCurrentSession(session);
+ expect(getCurrentSession()).toEqual(session);
+ // Same reference AND same value: the store must not churn its listeners.
+ setCurrentSession({ webId: 'http://localhost:3000/alice', token: 'tok-1' });
+ expect(getCurrentSession()).toEqual(session);
+ reset();
+ expect(getCurrentSession()).toBeUndefined();
+});
+
+test('a changed session replaces the previous one', () => {
+ reset();
+ setCurrentSession({ webId: 'http://localhost:3000/alice', token: 'tok-1' });
+ setCurrentSession({ webId: 'http://localhost:3000/alice', token: 'tok-2' });
+ expect(getCurrentSession()?.token).toBe('tok-2');
+ reset();
+});
diff --git a/src/shared/utils/currentSession.ts b/src/shared/utils/currentSession.ts
new file mode 100644
index 0000000..4621022
--- /dev/null
+++ b/src/shared/utils/currentSession.ts
@@ -0,0 +1,64 @@
+/**
+ * currentSession — the ActivityPods session the AuthGate flow established
+ * (`webId` + OIDC token), as a module store observed with
+ * `useSyncExternalStore`.
+ *
+ * WHY A MODULE STORE and not a React context: the data provider's dispatch
+ * decision ("is there an authenticated session to read a Pod through?") is made
+ * by `FestipodDataProvider`, while the component that drives the Solid-OIDC
+ * flow — `AuthGate` — mounts inside it. A context the AuthGate published would
+ * be invisible there. The same constraint and solution as
+ * `utils/currentPrincipal.ts`.
+ *
+ * WHAT IT IS FOR: gating the Refine data path and authorizing the writes that
+ * live BESIDE Refine (the outbox `as:Join`/`as:Leave` service and the actor /
+ * profile reads). Every value here comes from the auth provider instance that
+ * owns the flow, set at the exact points `AuthGate` records the principal.
+ *
+ * WHAT IT IS NOT: it is not a second identity space. `webId` is the Solid
+ * identity; the app's entity space (`currentUserId`) is the profile document
+ * URI, resolved separately from the actor document.
+ */
+
+import { useSyncExternalStore } from 'react';
+
+/** The authenticated session's credentials. */
+export interface CurrentSession {
+ webId: string;
+ token: string;
+}
+
+let session: CurrentSession | undefined;
+const listeners = new Set<() => void>();
+
+/** Record the session the auth flow established. `undefined` clears it. */
+export function setCurrentSession(next: CurrentSession | undefined): void {
+ const same = session?.webId === next?.webId && session?.token === next?.token;
+ if (same) return;
+ session = next;
+ for (const l of listeners) l();
+}
+
+/** The current session, or `undefined` when unauthenticated / offline demo. */
+export function getCurrentSession(): CurrentSession | undefined {
+ return session;
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+function snapshot(): CurrentSession | undefined {
+ return session;
+}
+
+/**
+ * Subscribe a component to the current session. The snapshot is a stable
+ * reference between real changes — no render loop.
+ */
+export function useCurrentSession(): CurrentSession | undefined {
+ return useSyncExternalStore(subscribe, snapshot, snapshot);
+}
diff --git a/src/shared/utils/runtimeConfig.ts b/src/shared/utils/runtimeConfig.ts
index 62daa3e..07d3061 100644
--- a/src/shared/utils/runtimeConfig.ts
+++ b/src/shared/utils/runtimeConfig.ts
@@ -20,6 +20,40 @@ export interface RuntimeConfig {
podProviderUrl?: string;
}
+/**
+ * The resolved ActivityPods auth configuration.
+ *
+ * `clientId` names the app description document the backend serves, as the
+ * Solid-OIDC flow requires; `redirectUri` is fixed to `${origin}/login` because
+ * the app renders its own callback route. THE DATA PROVIDER AND THE AUTH GATE
+ * MUST RESOLVE THE SAME VALUES — both go through this function so the two
+ * halves of the app can never drift apart.
+ */
+export interface ResolvedAuthConfig {
+ clientId: string;
+ redirectUri: string;
+ podProviderUrl: string | undefined;
+}
+
+/**
+ * Resolve the auth configuration from runtime values.
+ *
+ * - `backendClientId` wins when explicitly set.
+ * - Otherwise `backendUrl` derives `${backendUrl}/app`.
+ * - Otherwise fall back to `${origin}/app` so a page without a configured
+ * backend still typechecks and can be exercised.
+ */
+export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig {
+ const backendClientId = runtimeConfig.backendClientId?.trim();
+ const backendUrl = runtimeConfig.backendUrl?.trim();
+ const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`);
+ return {
+ clientId,
+ redirectUri: `${origin}/login`,
+ podProviderUrl: runtimeConfig.podProviderUrl?.trim() || undefined,
+ };
+}
+
let runtimeConfig: RuntimeConfig = {};
/** Replace the runtime config. Called once by `frontend.tsx` before `App` loads. */