feat(data): PdR registration via inbox, notifications, public discovery, protected store
Polyfill-enabled features (T02). All NextGraph I/O goes through @ng-eventually/client (docs/inbox/storeRegistry); no direct @ng-org. - Shapes: FpMeetingPoint + FpNotification are now real SHEX shapes with ORM bindings (previously app-TS-only, unpersisted). - Registration (registration.ts, new): joinEvent persists a Participation + deposits to the host's inbox + creates a Notification (from = registrant if connected, anonymous otherwise). leaveEvent deletes the Participation authoritatively via SPARQL DELETE-WHERE (sweep by event+user AND by subject, then re-query to confirm) — the désinscription CRDT-resurrection bug is fixed: the reactive delete is applied only once the broker confirms 0 remaining. - Public discovery: useNgData fans out over every account's public docs so a user sees others' public events without a connection (dedup union). - Cap attribution: createEntityDoc declares the ReadCap (open + makePublic/ grantRead per scope), activating the per-document read filter. - Protected store (T02.h): the default path now reads/writes shareable domain entities in the native protected store (did🆖${protected_store_id}) instead of private — verified openable against the broker — matching the per-wallet target. Private still anchors the shim/inbox + settings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,16 @@ import type {
|
||||
FpParticipationData,
|
||||
FpMeetingPointData,
|
||||
FpFriendshipData,
|
||||
FpNotificationData,
|
||||
} from '../data/types';
|
||||
import {
|
||||
hostInboxNuri,
|
||||
depositRegistration,
|
||||
buildNotification,
|
||||
insertNotification,
|
||||
readRegistrationNotifications,
|
||||
deleteParticipation,
|
||||
} from '../data/registration';
|
||||
import {
|
||||
CURRENT_USER_ID,
|
||||
seedEvents,
|
||||
@@ -49,6 +58,8 @@ interface FestipodDataContextValue {
|
||||
participations: FpParticipationData[];
|
||||
meetingPoints: FpMeetingPointData[];
|
||||
friendships: FpFriendshipData[];
|
||||
/** Host-facing notifications, surfaced from the inbox curator (T02.c). */
|
||||
notifications: FpNotificationData[];
|
||||
|
||||
getEvent(id: string): FpEventData | undefined;
|
||||
getUser(id: string): FpUserData | undefined;
|
||||
@@ -67,8 +78,8 @@ interface FestipodDataContextValue {
|
||||
|
||||
createEvent(event: Omit<FpEventData, 'id'>): Promise<FpEventData>;
|
||||
updateEvent(id: string, updates: Partial<FpEventData>): void;
|
||||
joinEvent(eventId: string, userId?: string): void;
|
||||
leaveEvent(eventId: string, userId?: string): void;
|
||||
joinEvent(eventId: string, userId?: string): Promise<void> | void;
|
||||
leaveEvent(eventId: string, userId?: string): Promise<void> | void;
|
||||
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
|
||||
addFriend(friendId: string): void;
|
||||
updateProfile(updates: Partial<FpUserData>): void;
|
||||
@@ -230,6 +241,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
return {
|
||||
currentUserId, currentUser,
|
||||
events, users, participations, meetingPoints, friendships,
|
||||
notifications: [],
|
||||
selectedEventId, setSelectedEventId, selectedEvent,
|
||||
selectedUserId, setSelectedUserId, selectedUser,
|
||||
...queries,
|
||||
@@ -245,9 +257,17 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
function useNgData(): FestipodDataContextValue {
|
||||
const { session } = useNextGraph();
|
||||
const { username } = useAccount();
|
||||
// Mono-store fallback scope: the shared wallet's private store NURI. Opens the
|
||||
// repo in the verifier (enables reads + writes), per the data-layer rule.
|
||||
// Mono-store scopes (MULTISTORE off). Two native stores of the shared wallet:
|
||||
// - privateNuri: kept as the anchor for the inbox shim (host inbox deposits)
|
||||
// and for private settings. Opens the repo in the verifier.
|
||||
// - protectedNuri: T02.h (axe A) — the SHAREABLE domain entities (events,
|
||||
// profiles, participations) now READ from and WRITE to the real protected
|
||||
// native store, representative of the target per-user wallet. Verified to
|
||||
// open for ORM reads+writes exactly like private (round-trip, no
|
||||
// RepoNotFound — see protected-store.feature). Subscribing useShape with
|
||||
// this NURI opens its repo in the verifier (required for writes).
|
||||
const privateNuri = session ? `did:ng:${session.private_store_id}` : undefined;
|
||||
const protectedNuri = session ? `did:ng:${session.protected_store_id}` : undefined;
|
||||
|
||||
// Multi-document state (storeRegistry): read fan-out (all accounts' docs per
|
||||
// scope) and the current account's write docs. Populated by the effect below.
|
||||
@@ -277,14 +297,45 @@ function useNgData(): FestipodDataContextValue {
|
||||
return () => { cancelled = true; };
|
||||
}, [privateNuri, username]);
|
||||
|
||||
// --- Public discovery (T02.e): cross-account fan-out, ALWAYS on ------------
|
||||
// Materialize the cross-account source of PUBLIC entities so a user discovers
|
||||
// other accounts' public events *without a connection* (Alice sees Bob's
|
||||
// public event even if they're not friends). This is the "simple" model: the
|
||||
// shared wallet makes every account's public index physically listable, so we
|
||||
// aggregate `allAccounts → each docPublic → listEntityDocs('public')` and read
|
||||
// the resulting per-entity documents via useShape({graphs}). Public docs are
|
||||
// `makePublic` (T02.d), so the ReadCap filter never blocks this fan-out.
|
||||
//
|
||||
// Additive & non-regressive: runs in BOTH modes but only contributes when the
|
||||
// shim has registered public entity docs. In the default mono-store path the
|
||||
// shim is empty (no account ever registered → fan-out is []), so the discovery
|
||||
// shape stays empty and the mono-store `events` read is untouched. When the
|
||||
// shim IS populated (multi-account staging), discovery unions those events in.
|
||||
const [discoveryGraphs, setDiscoveryGraphs] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!privateNuri) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const pub = await listEntityDocs('public'); // fans out over ALL accounts
|
||||
if (!cancelled) setDiscoveryGraphs(pub);
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] public discovery fan-out failed:', err);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [privateNuri, username]);
|
||||
const discoveryScope: ShapeScope = discoveryGraphs.length ? { graphs: discoveryGraphs } : undefined;
|
||||
|
||||
// Scope per entity: events live in the PUBLIC docs, profiles + participations
|
||||
// in the PROTECTED docs. Mono-store mode collapses all to the private store.
|
||||
// in the PROTECTED docs. Mono-store mode collapses all to the PROTECTED native
|
||||
// store (T02.h, axe A) — the shareable domain entities read from there.
|
||||
const publicScope: ShapeScope = MULTISTORE
|
||||
? (readGraphs.public.length ? { graphs: readGraphs.public } : undefined)
|
||||
: privateNuri;
|
||||
: protectedNuri;
|
||||
const protectedScope: ShapeScope = MULTISTORE
|
||||
? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined)
|
||||
: privateNuri;
|
||||
: protectedNuri;
|
||||
|
||||
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
|
||||
const emptyEvents: FpEventData[] = [];
|
||||
@@ -295,13 +346,29 @@ function useNgData(): FestipodDataContextValue {
|
||||
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
|
||||
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
|
||||
|
||||
const events = eventsShape.items;
|
||||
// Cross-account public discovery: read the fan-out documents as events.
|
||||
const discoveryShape = useShapeWithDefaults(FpEventShapeType, discoveryScope, emptyEvents, mapEvent, true);
|
||||
|
||||
// Union the current-scope events with the cross-account discovered ones,
|
||||
// de-duplicated by id (an event already read via publicScope must not appear
|
||||
// twice). Discovery is purely additive — it never hides an existing event.
|
||||
const events = React.useMemo(() => {
|
||||
const seen = new Set(eventsShape.items.map(e => e.id));
|
||||
const merged = [...eventsShape.items];
|
||||
for (const e of discoveryShape.items) {
|
||||
if (e.id && !seen.has(e.id)) { seen.add(e.id); merged.push(e); }
|
||||
}
|
||||
return merged;
|
||||
}, [eventsShape.items, discoveryShape.items]);
|
||||
const users = usersShape.items;
|
||||
const participations = participationsShape.items;
|
||||
|
||||
// Not in SHEX shapes yet
|
||||
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
|
||||
const [friendships, setFriendships] = useState<FpFriendshipData[]>([]);
|
||||
// Host-facing notifications, materialized from the current user's inboxes
|
||||
// (the emulated curator, T02.b/c). Data-level surfacing of "new participants".
|
||||
const [notifications, setNotifications] = useState<FpNotificationData[]>([]);
|
||||
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>('');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
@@ -351,6 +418,44 @@ function useNgData(): FestipodDataContextValue {
|
||||
const selectedEvent = events.find(e => e.id === selectedEventId);
|
||||
const selectedUser = users.find(u => u.id === selectedUserId);
|
||||
|
||||
// --- Notification materialization (T02.c) ---------------------------------
|
||||
// Run the emulated inbox curator over the current user's hosted events and
|
||||
// surface "new participant" deposits as host-facing FpNotifications. Keyed on
|
||||
// the events the user hosts/selects; polls once per (events, selectedEvent).
|
||||
// Data-level surfacing — the notification module reads `notifications`.
|
||||
const hostedEventIds = React.useMemo(
|
||||
() => events.filter(e => currentUserId && e.id).map(e => e.id),
|
||||
[events, currentUserId],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!privateNuri || hostedEventIds.length === 0) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
// In the polyfill every host inbox resolves to the shared private store,
|
||||
// so read it ONCE and let the curator filter deposits per hosted event.
|
||||
const targetInbox = await hostInboxNuri('');
|
||||
const all: FpNotificationData[] = [];
|
||||
for (const evId of hostedEventIds) {
|
||||
const notifs = await readRegistrationNotifications(targetInbox, evId);
|
||||
all.push(...notifs);
|
||||
}
|
||||
if (!cancelled && all.length) {
|
||||
setNotifications(prev => {
|
||||
const seen = new Set(prev.map(n => n.id));
|
||||
const merged = [...prev];
|
||||
for (const n of all) if (!seen.has(n.id)) { seen.add(n.id); merged.push(n); }
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] notification materialization failed:', err);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [privateNuri, hostedEventIds.join('|')]);
|
||||
|
||||
// Isolation (staging realism): the app honors the matrix in connected mode —
|
||||
// participations/connections narrowed to self + connections. See isolation.ts.
|
||||
const isolated = applyIsolation(
|
||||
@@ -368,8 +473,9 @@ function useNgData(): FestipodDataContextValue {
|
||||
|
||||
// --- Mutations (NG) ---
|
||||
// Participations stay GROUPED in the account's protected index document.
|
||||
// Mono-store mode collapses everything to the private store.
|
||||
const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || privateNuri || '';
|
||||
// Mono-store mode writes to the PROTECTED native store (T02.h, axe A) — the
|
||||
// same store the domain read scopes subscribe, so writes round-trip.
|
||||
const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || protectedNuri || '';
|
||||
|
||||
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
|
||||
console.log('[FestipodData] createEvent (NG):', event.title);
|
||||
@@ -378,7 +484,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// is appended to the read fan-out so it shows after re-subscribe.)
|
||||
const eventGraph = MULTISTORE
|
||||
? await createEntityDoc(username || '', 'public')
|
||||
: (privateNuri || '');
|
||||
: (protectedNuri || '');
|
||||
if (MULTISTORE && eventGraph) {
|
||||
setReadGraphs(prev =>
|
||||
prev.public.includes(eventGraph) ? prev : { ...prev, public: [...prev.public, eventGraph] },
|
||||
@@ -400,7 +506,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
setSelectedEventId(addedEvent["@id"]);
|
||||
}
|
||||
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
|
||||
}, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, privateNuri, username]);
|
||||
}, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, protectedNuri, username]);
|
||||
|
||||
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
|
||||
console.log('[FestipodData] updateEvent (NG):', id, updates);
|
||||
@@ -415,7 +521,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
}
|
||||
}, [eventsShape.ngSet]);
|
||||
|
||||
const joinEvent = useCallback((eventId: string, userId?: string) => {
|
||||
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
|
||||
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
|
||||
@@ -423,6 +529,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
console.log('[FestipodData] Already participating, skipping');
|
||||
return;
|
||||
}
|
||||
// 1) Persist the Participation (reactive ORM set — mono-store default path).
|
||||
participationsShape.ngSet.add({
|
||||
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
|
||||
event: eventId, user: uid, isConfirmed: true,
|
||||
@@ -431,21 +538,77 @@ function useNgData(): FestipodDataContextValue {
|
||||
if (ngEvent) {
|
||||
ngEvent.participantCount = ngEvent.participantCount + 1;
|
||||
}
|
||||
// 2) Notify the host: deposit into the event/host inbox via the GENERIC lib
|
||||
// inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant
|
||||
// when connected, anonymous (null) otherwise. Best-effort: a failed deposit
|
||||
// must not roll back a successful registration.
|
||||
try {
|
||||
const registrantId = uid || null; // no current user → anonymous deposit
|
||||
// Recipient = the event host. The Event shape carries no host IRI yet, so
|
||||
// we key the host inbox/notification on the eventId (the host of THAT
|
||||
// event). This is the domain injection the generic lib deliberately omits.
|
||||
const recipientId = eventId;
|
||||
const targetInbox = await hostInboxNuri(eventId);
|
||||
const { ts, uid: depositUid } = await depositRegistration(targetInbox, eventId, registrantId);
|
||||
const notif = buildNotification(recipientId, eventId, registrantId, ts);
|
||||
await insertNotification(protectedGraph, notif).catch(() => { /* data-level best-effort */ });
|
||||
// Surface immediately in reactive state (materialization also refreshes it).
|
||||
// Use the stable per-deposit uid for the id (F5 dedup) so it matches the
|
||||
// curator-materialized id and same-ms/anon deposits never collide.
|
||||
setNotifications(prev => [...prev, { ...notif, id: `notif-${depositUid}` }]);
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] joinEvent inbox/notify failed:', err);
|
||||
}
|
||||
}, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
|
||||
|
||||
const leaveEvent = useCallback((eventId: string, userId?: string) => {
|
||||
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid);
|
||||
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
|
||||
if (ngPart) {
|
||||
console.log('[FestipodData] Deleting participation via ngSet.delete():', ngPart["@id"]);
|
||||
participationsShape.ngSet.delete(ngPart);
|
||||
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
|
||||
if (ngEvent) {
|
||||
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
|
||||
}
|
||||
if (!ngPart) return;
|
||||
// DÉSINSCRIPTION FIX (caveat_participation-deletion): `ngSet.delete()` alone
|
||||
// triggers reactivity but the item RESURRECTS via broker sync. The AUTHORITATIVE
|
||||
// deletion is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate),
|
||||
// which removes the Participation server-side so it does NOT come back after
|
||||
// re-sync. The delete targets the participation's own @graph (the doc it lives
|
||||
// in) — mono-store: the private store; multistore: the protected write doc —
|
||||
// and is identified by the participation's OWN subject IRI (ngPart["@id"]),
|
||||
// not a string-match on the object IRIs (the F2 bug: object string-match could
|
||||
// hit 0 rows on IRI-form drift → silent no-op → resurrection).
|
||||
const graphNuri = ngPart["@graph"] || protectedGraph;
|
||||
const subjectIri = ngPart["@id"];
|
||||
let result;
|
||||
try {
|
||||
result = await deleteParticipation(graphNuri, eventId, uid, subjectIri);
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] SPARQL DELETE participation failed:', err);
|
||||
// Do NOT flip the UI: the broker still holds the triple, so flipping the
|
||||
// reactive set would resurrect on the next sync. Surface the failure.
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
|
||||
// AUTHORITATIVE verification: only flip the UI once the broker RE-QUERY confirms
|
||||
// the participation is actually gone (remaining === 0). If the delete matched
|
||||
// nothing (weak match / IRI-form drift / wrong graph), remaining stays > 0 —
|
||||
// flipping the reactive set here would show "not participating" while the broker
|
||||
// still holds the triple, and it would resurrect after re-sync. Surface instead.
|
||||
if (result.remaining > 0) {
|
||||
const msg = `[FestipodData] 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);
|
||||
}
|
||||
// Confirmed gone server-side → reflect it in the reactive UI. This is the LOCAL
|
||||
// reflection of the authoritative delete (not a second persistence path): the
|
||||
// button flips to not-registered and STAYS so — the broker no longer holds the
|
||||
// triple to resurrect. `isParticipating` reads this set, so the item must leave
|
||||
// it for the UI to update immediately.
|
||||
participationsShape.ngSet.delete(ngPart);
|
||||
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
|
||||
if (ngEvent) {
|
||||
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
|
||||
}
|
||||
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId, protectedGraph]);
|
||||
|
||||
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
|
||||
@@ -489,6 +652,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
participations: isolated.participations,
|
||||
meetingPoints,
|
||||
friendships: isolated.friendships,
|
||||
notifications,
|
||||
selectedEventId, setSelectedEventId, selectedEvent,
|
||||
selectedUserId, setSelectedUserId, selectedUser,
|
||||
...queries,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Registration domain glue — the FESTIPOD interpretation layered on top of the
|
||||
* GENERIC `@ng-eventually/client` `inbox` mechanism (T02.b) and the low-level
|
||||
* `docs` SPARQL primitives.
|
||||
*
|
||||
* The lib stays domain-agnostic: it knows only "deposit an opaque payload into
|
||||
* an inbox document NURI" and "run a SPARQL update against the real injected
|
||||
* ng". THIS module supplies the Festipod domain:
|
||||
* - how to derive a meeting-point / host inbox NURI (`hostInboxNuri`),
|
||||
* - the shape of the deposit payload (`RegistrationPayload`),
|
||||
* - how a deposit becomes a host-facing `FpNotification` (`buildNotification`),
|
||||
* - the SPARQL DELETE-WHERE that DURABLY removes a Participation server-side
|
||||
* (`deleteParticipation`) — the documented fallback for the CRDT resurrection
|
||||
* bug (see caveat_participation-deletion).
|
||||
*
|
||||
* Importable by `shared/` and by domain modules (meeting/notification) — it never
|
||||
* imports a module, only the lib. See T02.a (shapes) / T02.b (inbox).
|
||||
*/
|
||||
|
||||
import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
import type { FpNotificationData } from './types';
|
||||
|
||||
/** Notification IRI/type constants (mirror the SHEX Notification shape). */
|
||||
export const NOTIF_TYPE_NEW_PARTICIPANT = 'new-participant';
|
||||
const NOTIF_TYPE_IRI = 'http://festipod.org/Notification';
|
||||
const P = {
|
||||
recipient: 'http://festipod.org/recipient',
|
||||
type: 'http://festipod.org/type',
|
||||
ref: 'http://festipod.org/ref',
|
||||
payload: 'http://festipod.org/payload',
|
||||
timestamp: 'http://festipod.org/timestamp',
|
||||
isRead: 'http://festipod.org/isRead',
|
||||
partType: 'http://festipod.org/Participation',
|
||||
partEvent: 'http://festipod.org/event',
|
||||
partUser: 'http://festipod.org/user',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The opaque payload a registrant deposits into the host's inbox on join. The
|
||||
* lib treats this as `unknown`; only this domain module reads its fields.
|
||||
*/
|
||||
export interface RegistrationPayload {
|
||||
kind: typeof NOTIF_TYPE_NEW_PARTICIPANT;
|
||||
eventId: string;
|
||||
/** The registrant's user id, or null when the deposit was anonymous. */
|
||||
userId: string | null;
|
||||
/**
|
||||
* A STABLE, per-deposit unique id minted at deposit time (F5 dedup). The lib's
|
||||
* `Deposit` surfaces only `{ from, payload, ts }` — no stable id — so two
|
||||
* deposits in the same ms by the same anon principal would otherwise both mint
|
||||
* `notif-${ts}-anon` and collide (a re-join would silently duplicate OR be
|
||||
* dropped by the seen-set). Carrying our own `uid` in the payload makes the
|
||||
* derived notification id collision-free without changing the lib. */
|
||||
uid: string;
|
||||
}
|
||||
|
||||
/** Mint a stable, collision-resistant per-deposit uid (time + randomness). */
|
||||
function mintDepositUid(): string {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the inbox document NURI for a meeting point / host.
|
||||
*
|
||||
* Preference order: the explicit MeetingPoint `inbox` NURI (SHEX field, T02.a)
|
||||
* when known → else the shared-wallet PRIVATE STORE NURI. The lib's `inbox`
|
||||
* uses `targetInbox` as BOTH the RDF graph AND the SPARQL anchor, and the anchor
|
||||
* must be a REAL repo NURI (a `urn:` is rejected as `InvalidNuri` by the broker)
|
||||
* — so in the polyfill every host inbox physically resolves to the shared wallet
|
||||
* private store; deposits are DISCRIMINATED by their `eventId` payload (the
|
||||
* curator filters per event). At migration this becomes the host's native inbox
|
||||
* NURI and the deposits move to per-host docs. Async because the session (hence
|
||||
* the private_store_id) is resolved lazily.
|
||||
*/
|
||||
export async function hostInboxNuri(eventId: string, explicitInbox?: string): Promise<string> {
|
||||
void eventId; // reserved: per-event inbox docs at migration
|
||||
if (explicitInbox) return explicitInbox;
|
||||
const { private_store_id } = await sessionPromise;
|
||||
return `did:ng:${private_store_id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the host-facing notification from a registration deposit. The recipient
|
||||
* is the event host; `ref` points at the event; the payload carries the raw
|
||||
* registration deposit (who joined). Kept pure so callers own persistence.
|
||||
*/
|
||||
export function buildNotification(
|
||||
recipientId: string,
|
||||
eventId: string,
|
||||
registrantId: string | null,
|
||||
ts: number,
|
||||
): Omit<FpNotificationData, 'id'> {
|
||||
return {
|
||||
recipientId,
|
||||
type: NOTIF_TYPE_NEW_PARTICIPANT,
|
||||
ref: eventId,
|
||||
payload: JSON.stringify({ eventId, userId: registrantId }),
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
isRead: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit a registration into the host's inbox (generic lib `inbox.post`) +
|
||||
* return the deposit ts so the caller can mint a matching notification.
|
||||
* `from` = the registrant id when connected, or `null` for an anonymous deposit.
|
||||
*/
|
||||
export async function depositRegistration(
|
||||
targetInbox: string,
|
||||
eventId: string,
|
||||
registrantId: string | null,
|
||||
): Promise<{ ts: number; uid: string }> {
|
||||
const ts = Date.now();
|
||||
const uid = mintDepositUid();
|
||||
const payload: RegistrationPayload = {
|
||||
kind: NOTIF_TYPE_NEW_PARTICIPANT,
|
||||
eventId,
|
||||
userId: registrantId,
|
||||
uid,
|
||||
};
|
||||
await inbox.post(targetInbox, { from: registrantId ?? null, payload, ts });
|
||||
return { ts, uid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize a host inbox's deposits into host-facing notifications (data-level
|
||||
* surfacing). The emulated curator (`inbox.read`) returns the raw deposits; we
|
||||
* map each registration deposit to an `FpNotificationData` for `recipientId`.
|
||||
*/
|
||||
export async function readRegistrationNotifications(
|
||||
targetInbox: string,
|
||||
recipientEventId: string,
|
||||
): Promise<FpNotificationData[]> {
|
||||
const deposits = await inbox.read(targetInbox);
|
||||
const notifs: FpNotificationData[] = [];
|
||||
for (const d of deposits) {
|
||||
const p = d.payload as Partial<RegistrationPayload> | null;
|
||||
if (!p || p.kind !== NOTIF_TYPE_NEW_PARTICIPANT || !p.eventId) continue;
|
||||
// The polyfill inbox is shared (private store): keep only deposits for the
|
||||
// event whose host is reading. `recipientEventId` doubles as the recipient.
|
||||
if (recipientEventId && p.eventId !== recipientEventId) continue;
|
||||
const built = buildNotification(recipientEventId, p.eventId, d.from ?? null, d.ts);
|
||||
// F5 dedup: prefer the stable per-deposit uid carried in the payload so
|
||||
// same-ms / anonymous deposits never collide. Fall back to the legacy
|
||||
// ts+principal id for deposits minted before the uid existed.
|
||||
const id = p.uid ? `notif-${p.uid}` : `notif-${d.ts}-${p.userId ?? 'anon'}`;
|
||||
notifs.push({ ...built, id });
|
||||
}
|
||||
return notifs;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the deletion identified the Participation, for the caller's verification.
|
||||
* `remaining` is the authoritative post-delete count of Participations still
|
||||
* matching (event, user) in `graphNuri` — re-queried from the broker AFTER the
|
||||
* update. A durable delete leaves `remaining === 0`; a non-zero value means the
|
||||
* delete matched nothing (or partially), so the caller must NOT flip the UI.
|
||||
*/
|
||||
export interface DeleteParticipationResult {
|
||||
/** Participations matching (event, user) BEFORE the delete (re-queried). */
|
||||
before: number;
|
||||
/** Participations matching (event, user) AFTER the delete (re-queried). */
|
||||
remaining: number;
|
||||
/** Whether we deleted by the participation's own subject IRI (vs. fallback). */
|
||||
bySubject: boolean;
|
||||
}
|
||||
|
||||
/** Count Participations matching (event, user) in `graphNuri`, authoritatively
|
||||
* (re-query the real broker via `docs.sparqlQuery`). We match ?event / ?user
|
||||
* by IRI OR by literal string value so the count is tolerant of how the ORM
|
||||
* serialized these fields — this is a COUNT (read-only), so tolerance here is
|
||||
* safe (unlike a DELETE, it can never over-remove). */
|
||||
async function countParticipations(
|
||||
sid: string,
|
||||
graphNuri: string,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
const g = assertNuri(graphNuri);
|
||||
const evL = escapeLiteral(eventId);
|
||||
const usL = escapeLiteral(userId);
|
||||
const query = `
|
||||
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE {
|
||||
GRAPH <${g}> {
|
||||
?s a <${P.partType}> ;
|
||||
<${P.partEvent}> ?event ;
|
||||
<${P.partUser}> ?user .
|
||||
FILTER( STR(?event) = "${evL}" && STR(?user) = "${usL}" )
|
||||
}
|
||||
}`;
|
||||
const result = await docs.sparqlQuery(sid, query, undefined, graphNuri);
|
||||
// Tolerant binding extraction (mirrors the lib's readBindings shape).
|
||||
const anyRes = result as {
|
||||
results?: { bindings?: Array<Record<string, { value?: string }>> };
|
||||
};
|
||||
const rows = Array.isArray(result)
|
||||
? (result as Array<Record<string, { value?: string }>>)
|
||||
: anyRes.results?.bindings ?? [];
|
||||
const raw = rows[0]?.n?.value ?? '0';
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* DURABLY delete a Participation server-side via SPARQL DELETE-WHERE (the real
|
||||
* injected `ng` through `docs.sparqlUpdate`) — the documented fallback for the
|
||||
* CRDT resurrection bug (see caveat_participation-deletion): `ngSet.delete()`
|
||||
* triggers reactivity but the item resurrects via broker sync. This removes
|
||||
* every triple of the matching Participation subject in `graphNuri`, then
|
||||
* re-queries the broker to CONFIRM the removal (returns the affected counts so
|
||||
* the caller can verify before flipping the UI).
|
||||
*
|
||||
* Identification (durable, removes ALL matches — the broker can hold DUPLICATE
|
||||
* participations for one (event, user)):
|
||||
* 1. AUTHORITATIVE SWEEP by (event, user) — binds ?event/?user by IRI value
|
||||
* (`sameTerm`) AND literal string value, covering the absolute IRI form and
|
||||
* any base-resolved literal form (NOT the weak `STR()`-only match of the F2
|
||||
* bug). Removes every matching participation, including duplicates.
|
||||
* 2. BELT-AND-SUSPENDERS by the participation's OWN subject IRI (`subjectIri`,
|
||||
* from `ngPart["@id"]`) when known — exact, bound as an IRI, catches any
|
||||
* residual the sweep missed on object-form drift.
|
||||
* Only deleting the single reactive subject (the naive fix) leaves duplicates
|
||||
* behind → the participation resurrects.
|
||||
*
|
||||
* IMPORTANT (caveat): do NOT rely on `ngSet.delete()` to persist the removal in
|
||||
* the SAME flow — combining the two paths creates a CRDT conflict. The caller
|
||||
* uses THIS as the authoritative delete and only reflects the result in reactive
|
||||
* state for the immediate UI, AND only once `remaining === 0`.
|
||||
*/
|
||||
export async function deleteParticipation(
|
||||
graphNuri: string,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
subjectIri?: string,
|
||||
): Promise<DeleteParticipationResult> {
|
||||
const sid = (await sessionPromise).session_id;
|
||||
const g = assertNuri(graphNuri);
|
||||
// The ORM batches `ngSet.add` into a microtask + the broker needs a moment to
|
||||
// land it in the SPARQL-queryable graph. A leave that follows a join tightly
|
||||
// (tests; a fast user) can reach here BEFORE the join's write is queryable —
|
||||
// deleting then would no-op on absent triples (the F2 resurrection root). Flush
|
||||
// the ORM microtask and poll the authoritative count until the participation is
|
||||
// visible (bounded) so the delete operates on real triples. In the running app
|
||||
// the triple is already present, so the first poll returns immediately.
|
||||
await Promise.resolve(); // flush pending ORM microtask batch
|
||||
let before = await countParticipations(sid, graphNuri, eventId, userId);
|
||||
for (let i = 0; before === 0 && i < 10; i++) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
before = await countParticipations(sid, graphNuri, eventId, userId);
|
||||
}
|
||||
|
||||
// A usable subject IRI is a non-empty, IRI-safe value (the ORM assigns "" to
|
||||
// freshly-added, not-yet-persisted items — that's not a real subject).
|
||||
const hasSubject = typeof subjectIri === 'string' && subjectIri.length > 0;
|
||||
|
||||
// AUTHORITATIVE SWEEP: delete EVERY Participation matching (event, user) — not
|
||||
// just the one subject the reactive set surfaced. The broker can hold DUPLICATE
|
||||
// participations for the same (event, user) (observed: a re-join / stale item
|
||||
// leaves 2); deleting only `ngPart["@id"]` removes one and the other survives →
|
||||
// the désinscription silently no-ops on the duplicate and resurrects. So the
|
||||
// durable delete matches by (event, user), binding ?event/?user by IRI value
|
||||
// (sameTerm) AND by literal string value — covering the absolute IRI form and
|
||||
// any base-resolved literal form (NOT the weak `STR()`-only match of the F2 bug).
|
||||
const evIri = escapeIri(eventId);
|
||||
const usIri = escapeIri(userId);
|
||||
const evL = escapeLiteral(eventId);
|
||||
const usL = escapeLiteral(userId);
|
||||
const sweep = `
|
||||
DELETE {
|
||||
GRAPH <${g}> { ?s ?p ?o }
|
||||
}
|
||||
WHERE {
|
||||
GRAPH <${g}> {
|
||||
?s a <${P.partType}> ;
|
||||
<${P.partEvent}> ?event ;
|
||||
<${P.partUser}> ?user ;
|
||||
?p ?o .
|
||||
FILTER(
|
||||
( sameTerm(?event, <${evIri}>) || STR(?event) = "${evL}" ) &&
|
||||
( sameTerm(?user, <${usIri}>) || STR(?user) = "${usL}" )
|
||||
)
|
||||
}
|
||||
}`;
|
||||
await docs.sparqlUpdate(sid, sweep, graphNuri);
|
||||
|
||||
// BELT-AND-SUSPENDERS: also delete by the participation's OWN subject IRI when
|
||||
// known. This catches the residual case where an object-form drift makes the
|
||||
// (event, user) sweep miss a subject we nonetheless hold the id for — exact,
|
||||
// bound as an IRI, cannot no-op on drift.
|
||||
if (hasSubject) {
|
||||
const s = assertNuri(subjectIri!);
|
||||
const bySubject = `
|
||||
DELETE {
|
||||
GRAPH <${g}> { <${s}> ?p ?o }
|
||||
}
|
||||
WHERE {
|
||||
GRAPH <${g}> { <${s}> ?p ?o }
|
||||
}`;
|
||||
await docs.sparqlUpdate(sid, bySubject, graphNuri);
|
||||
}
|
||||
|
||||
// Re-query the broker to CONFIRM every matching participation is gone
|
||||
// (authoritative — not the reactive set). Caller checks `remaining === 0`.
|
||||
const remaining = await countParticipations(sid, graphNuri, eventId, userId);
|
||||
return { before, remaining, bySubject: hasSubject };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a host notification server-side as an `FpNotification` (SHEX shape,
|
||||
* T02.a) via SPARQL INSERT DATA into `graphNuri`. Complements the reactive path;
|
||||
* used so notifications survive a refresh at the data level.
|
||||
*/
|
||||
export async function insertNotification(
|
||||
graphNuri: string,
|
||||
notif: Omit<FpNotificationData, 'id'>,
|
||||
): Promise<string> {
|
||||
const sid = (await sessionPromise).session_id;
|
||||
const g = assertNuri(graphNuri);
|
||||
const subject = `urn:festipod:notif:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
||||
// recipient/ref are bare domain ids ("user-1", "event-1"), not absolute IRIs;
|
||||
// store them as string literals to keep the INSERT valid (the raw shape read
|
||||
// is not the primary surfacing path — the inbox curator is). Every literal is
|
||||
// escaped via the lib's escapeLiteral (guards \ " \n \r \t — SPARQL injection).
|
||||
const refTriple = notif.ref ? `\n <${P.ref}> "${escapeLiteral(notif.ref)}" ;` : '';
|
||||
const payloadTriple = notif.payload
|
||||
? `\n <${P.payload}> "${escapeLiteral(notif.payload)}" ;`
|
||||
: '';
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
GRAPH <${g}> {
|
||||
<${assertNuri(subject)}> a <${NOTIF_TYPE_IRI}> ;
|
||||
<${P.recipient}> "${escapeLiteral(notif.recipientId)}" ;
|
||||
<${P.type}> "${escapeLiteral(notif.type)}" ;${refTriple}${payloadTriple}
|
||||
<${P.timestamp}> "${escapeLiteral(notif.timestamp)}" ;
|
||||
<${P.isRead}> "${notif.isRead}" .
|
||||
}
|
||||
}`;
|
||||
await docs.sparqlUpdate(sid, update, graphNuri);
|
||||
return subject;
|
||||
}
|
||||
@@ -46,10 +46,29 @@ export interface FpParticipationData {
|
||||
export interface FpMeetingPointData {
|
||||
id: string;
|
||||
eventId: string;
|
||||
/** userId of the host (aligned with the SHEX MeetingPoint.host reference). */
|
||||
hostId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** Where participants gather (SHEX MeetingPoint.place). */
|
||||
place?: string;
|
||||
location: string;
|
||||
time: string;
|
||||
hostName: string;
|
||||
hostInitials: string;
|
||||
/** NURI of the meeting point's inbox (wired in T02.b/c). */
|
||||
inbox?: string;
|
||||
}
|
||||
|
||||
export interface FpNotificationData {
|
||||
id: string;
|
||||
recipientId: string;
|
||||
type: string;
|
||||
/** Reference (IRI/id) to the subject resource. */
|
||||
ref?: string;
|
||||
payload?: string;
|
||||
timestamp: string;
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
export interface FpFriendshipData {
|
||||
|
||||
@@ -176,4 +176,129 @@ export const festipodShapesSchema: Schema = {
|
||||
},
|
||||
],
|
||||
},
|
||||
"http://festipod.org/MeetingPoint": {
|
||||
iri: "http://festipod.org/MeetingPoint",
|
||||
predicates: [
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "iri",
|
||||
literals: ["http://festipod.org/MeetingPoint"],
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "@type",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "iri" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/event",
|
||||
readablePredicate: "event",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "iri" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/host",
|
||||
readablePredicate: "host",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/title",
|
||||
readablePredicate: "title",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/description",
|
||||
readablePredicate: "description",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/place",
|
||||
readablePredicate: "place",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/time",
|
||||
readablePredicate: "time",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/inbox",
|
||||
readablePredicate: "inbox",
|
||||
},
|
||||
],
|
||||
},
|
||||
"http://festipod.org/Notification": {
|
||||
iri: "http://festipod.org/Notification",
|
||||
predicates: [
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "iri",
|
||||
literals: ["http://festipod.org/Notification"],
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "@type",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "iri" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/recipient",
|
||||
readablePredicate: "recipient",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/type",
|
||||
readablePredicate: "type",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "iri" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/ref",
|
||||
readablePredicate: "ref",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/payload",
|
||||
readablePredicate: "payload",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/timestamp",
|
||||
readablePredicate: "timestamp",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "boolean" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/isRead",
|
||||
readablePredicate: "isRead",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ShapeType } from "@ng-org/shex-orm";
|
||||
import { festipodShapesSchema } from "./festipodShapes.schema";
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from "./festipodShapes.typings";
|
||||
import type {
|
||||
FpEvent,
|
||||
FpUserProfile,
|
||||
FpParticipation,
|
||||
FpMeetingPoint,
|
||||
FpNotification,
|
||||
} from "./festipodShapes.typings";
|
||||
|
||||
// ShapeTypes for festipodShapes
|
||||
export const FpEventShapeType: ShapeType<FpEvent> = {
|
||||
@@ -15,3 +21,11 @@ export const FpParticipationShapeType: ShapeType<FpParticipation> = {
|
||||
schema: festipodShapesSchema,
|
||||
shape: "http://festipod.org/Participation",
|
||||
};
|
||||
export const FpMeetingPointShapeType: ShapeType<FpMeetingPoint> = {
|
||||
schema: festipodShapesSchema,
|
||||
shape: "http://festipod.org/MeetingPoint",
|
||||
};
|
||||
export const FpNotificationShapeType: ShapeType<FpNotification> = {
|
||||
schema: festipodShapesSchema,
|
||||
shape: "http://festipod.org/Notification",
|
||||
};
|
||||
|
||||
@@ -161,3 +161,117 @@ export interface FpParticipation {
|
||||
*/
|
||||
isConfirmed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MeetingPoint Type
|
||||
*/
|
||||
export interface FpMeetingPoint {
|
||||
/**
|
||||
* The graph IRI.
|
||||
*/
|
||||
readonly "@graph": IRI;
|
||||
/**
|
||||
* The subject IRI.
|
||||
*/
|
||||
readonly "@id": IRI;
|
||||
/**
|
||||
* Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type
|
||||
*/
|
||||
"@type": "http://festipod.org/MeetingPoint";
|
||||
/**
|
||||
* Reference to the public event this meeting point is anchored to
|
||||
*
|
||||
* Original IRI: http://festipod.org/event
|
||||
*/
|
||||
event: IRI;
|
||||
/**
|
||||
* Reference to the user hosting the meeting point
|
||||
*
|
||||
* Original IRI: http://festipod.org/host
|
||||
*/
|
||||
host: IRI;
|
||||
/**
|
||||
* The title of the meeting point
|
||||
*
|
||||
* Original IRI: http://festipod.org/title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* A description of the meeting point
|
||||
*
|
||||
* Original IRI: http://festipod.org/description
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Where participants gather (e.g. 'Entrée principale, sous l'horloge')
|
||||
*
|
||||
* Original IRI: http://festipod.org/place
|
||||
*/
|
||||
place?: string;
|
||||
/**
|
||||
* When participants gather (display string)
|
||||
*
|
||||
* Original IRI: http://festipod.org/time
|
||||
*/
|
||||
time?: string;
|
||||
/**
|
||||
* NURI of the meeting point's inbox (wired in T02.b/c)
|
||||
*
|
||||
* Original IRI: http://festipod.org/inbox
|
||||
*/
|
||||
inbox?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification Type
|
||||
*/
|
||||
export interface FpNotification {
|
||||
/**
|
||||
* The graph IRI.
|
||||
*/
|
||||
readonly "@graph": IRI;
|
||||
/**
|
||||
* The subject IRI.
|
||||
*/
|
||||
readonly "@id": IRI;
|
||||
/**
|
||||
* Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type
|
||||
*/
|
||||
"@type": "http://festipod.org/Notification";
|
||||
/**
|
||||
* Reference to the user receiving the notification
|
||||
*
|
||||
* Original IRI: http://festipod.org/recipient
|
||||
*/
|
||||
recipient: IRI;
|
||||
/**
|
||||
* Notification kind (e.g. 'new-participant', 'meeting-point-join')
|
||||
*
|
||||
* Original IRI: http://festipod.org/type
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Reference to the subject resource (meeting point, participation, user)
|
||||
*
|
||||
* Original IRI: http://festipod.org/ref
|
||||
*/
|
||||
ref?: IRI;
|
||||
/**
|
||||
* Opaque JSON payload for the notification
|
||||
*
|
||||
* Original IRI: http://festipod.org/payload
|
||||
*/
|
||||
payload?: string;
|
||||
/**
|
||||
* ISO8601 timestamp when the notification was created
|
||||
*
|
||||
* Original IRI: http://festipod.org/timestamp
|
||||
*/
|
||||
timestamp: string;
|
||||
/**
|
||||
* Whether the recipient has read the notification
|
||||
*
|
||||
* Original IRI: http://festipod.org/isRead
|
||||
*/
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
@@ -47,3 +47,37 @@ fp:Participation {
|
||||
fp:isConfirmed xsd:boolean
|
||||
// rdfs:comment "Whether the participation is confirmed" ;
|
||||
}
|
||||
|
||||
fp:MeetingPoint {
|
||||
a [fp:MeetingPoint] ;
|
||||
fp:event IRI
|
||||
// rdfs:comment "Reference to the public event this meeting point is anchored to" ;
|
||||
fp:host IRI
|
||||
// rdfs:comment "Reference to the user hosting the meeting point" ;
|
||||
fp:title xsd:string
|
||||
// rdfs:comment "The title of the meeting point" ;
|
||||
fp:description xsd:string ?
|
||||
// rdfs:comment "A description of the meeting point" ;
|
||||
fp:place xsd:string ?
|
||||
// rdfs:comment "Where participants gather (e.g. 'Entrée principale, sous l'horloge')" ;
|
||||
fp:time xsd:string ?
|
||||
// rdfs:comment "When participants gather (display string)" ;
|
||||
fp:inbox xsd:string ?
|
||||
// rdfs:comment "NURI of the meeting point's inbox (wired in T02.b/c)" ;
|
||||
}
|
||||
|
||||
fp:Notification {
|
||||
a [fp:Notification] ;
|
||||
fp:recipient IRI
|
||||
// rdfs:comment "Reference to the user receiving the notification" ;
|
||||
fp:type xsd:string
|
||||
// rdfs:comment "Notification kind (e.g. 'new-participant', 'meeting-point-join')" ;
|
||||
fp:ref IRI ?
|
||||
// rdfs:comment "Reference to the subject resource (meeting point, participation, user)" ;
|
||||
fp:payload xsd:string ?
|
||||
// rdfs:comment "Opaque JSON payload for the notification" ;
|
||||
fp:timestamp xsd:string
|
||||
// rdfs:comment "ISO8601 timestamp when the notification was created" ;
|
||||
fp:isRead xsd:boolean
|
||||
// rdfs:comment "Whether the recipient has read the notification" ;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* NextGraph graph NURI management.
|
||||
*
|
||||
* Returns the private store NURI as @graph for ORM entity creation.
|
||||
* This matches the expense-tracker-rdf approach: useShape with
|
||||
* private_store_id scope opens the repo, and writes target the same NURI.
|
||||
* Returns the PROTECTED store NURI as @graph for ORM entity creation of the
|
||||
* SHAREABLE domain entities (events, profiles, participations). T02.h (axe A)
|
||||
* switched the default domain scope from the private store to the real
|
||||
* protected native store (`did:ng:${protected_store_id}`) — the store
|
||||
* representative of the target per-user wallet. Verified empirically: the
|
||||
* protected store opens for ORM reads AND writes the same way private does
|
||||
* (round-trip probe, no RepoNotFound). Like private, subscribing useShape with
|
||||
* the protected store NURI as scope opens its repo in the verifier, and writes
|
||||
* target the same NURI. (Private stays the anchor for the inbox shim + settings.)
|
||||
*/
|
||||
|
||||
import { sessionPromise } from './ngSession';
|
||||
@@ -11,8 +17,8 @@ import { sessionPromise } from './ngSession';
|
||||
let cachedGraphNuri: string | undefined;
|
||||
|
||||
/**
|
||||
* Get the graph NURI for adding ORM entities.
|
||||
* Uses the private store NURI (same as expense-tracker-rdf).
|
||||
* Get the graph NURI for adding shareable ORM entities.
|
||||
* Uses the protected store NURI (T02.h; was the private store before).
|
||||
*/
|
||||
export async function ensureGraphNuri(
|
||||
...sets: Iterable<{ readonly "@graph": string }>[]
|
||||
@@ -30,9 +36,9 @@ export async function ensureGraphNuri(
|
||||
}
|
||||
}
|
||||
|
||||
// Use private store NURI (the repo is opened by useShape with this scope)
|
||||
// Use PROTECTED store NURI (the repo is opened by useShape with this scope).
|
||||
const session = await sessionPromise;
|
||||
cachedGraphNuri = `did:ng:${session.private_store_id}`;
|
||||
console.log('[ngGraph] Using private store as graph:', cachedGraphNuri);
|
||||
cachedGraphNuri = `did:ng:${session.protected_store_id}`;
|
||||
console.log('[ngGraph] Using protected store as graph:', cachedGraphNuri);
|
||||
return cachedGraphNuri;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
storeRegistry as libStoreRegistry,
|
||||
type AccountRecord as LibAccountRecord,
|
||||
} from '@ng-eventually/client';
|
||||
import { configureStoreRegistry } from '@ng-eventually/client/polyfill';
|
||||
import { configureStoreRegistry, getCaps } from '@ng-eventually/client/polyfill';
|
||||
import { sessionPromise } from './ngSession';
|
||||
import { normalizeUsername } from '../context/AccountContext';
|
||||
|
||||
@@ -60,9 +60,35 @@ export const {
|
||||
loadShim,
|
||||
ensureAccount,
|
||||
resolveWriteGraph,
|
||||
createEntityDoc,
|
||||
listEntityDocs,
|
||||
allAccounts,
|
||||
resolveReadGraphs,
|
||||
resetRegistryCache,
|
||||
} = libStoreRegistry;
|
||||
|
||||
/**
|
||||
* Create a per-entity document AND declare its ReadCap/WriteCap policy — the
|
||||
* app-side ACTIVATION of the emulated cap registry (dormant until an app
|
||||
* declares a policy). The lib's `createEntityDoc` stays domain-agnostic; the
|
||||
* DOMAIN mapping (scope → who may read) is Festipod's, so it lives here.
|
||||
*
|
||||
* In the target this is a native cap operation attached at store/repo creation;
|
||||
* here it is `getCaps().open(doc, scope, owner)`:
|
||||
* - public → world-readable (`makePublic`) — events, meeting points
|
||||
* - protected → owner reads now; connections granted later (a separate grant)
|
||||
* - private → owner only
|
||||
* The owner always holds the WRITE cap (so only the owner may `sparql_update`
|
||||
* the doc once the guard is active). `owner` = the account username (the same
|
||||
* principal key the shim uses and that the app sets via `setCurrentUser`).
|
||||
*
|
||||
* NOTE ON BASELINE: `createEntityDoc` is only reached in MULTISTORE mode; the
|
||||
* default mono-store path never calls it and never sets a current user, so both
|
||||
* the ReadCap filter and the write guard stay inert (passthrough) by default.
|
||||
*/
|
||||
export async function createEntityDoc(username: string, scope: Scope): Promise<string> {
|
||||
const entityNuri = await libStoreRegistry.createEntityDoc(username, scope);
|
||||
// Declare the cap policy for the freshly-created entity document. `owner` is
|
||||
// the account username (principal). This is what makes ReadCap ACTIVE.
|
||||
getCaps().open(entityNuri, scope, normalizeUsername(username));
|
||||
return entityNuri;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user