Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
6 changed files with 317 additions and 97 deletions
Showing only changes of commit df971df135 - Show all commits
@@ -0,0 +1,9 @@
# Doc-debt — app-architecture
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/screens/EventDetailScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/user/screens/UpdateProfileScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+8
View File
@@ -0,0 +1,8 @@
# Doc-debt — data-layer
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/currentPrincipal.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
@@ -40,23 +40,23 @@ export function EventDetailScreen() {
const handleToggleJoin = () => {
if (!eventId) return;
// The optimistic toast stays immediate (the overlay already reflects the
// change), but the write can genuinely FAIL — a participation document that
// cannot be recorded throws instead of reading empty forever, and an
// unconfirmed withdrawal throws too. Surface it rather than leave the user
// with a success message and nothing written.
// 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"));
showToast('Participation annulée', 'info');
} 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"));
showToast('Tu participes à cet événement', 'success');
}
};
@@ -18,9 +18,18 @@ export function UpdateProfileScreen() {
const handleSave = () => {
const fullName = `${firstName} ${lastName}`.trim();
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase();
updateProfile({ name: fullName, initials, username, city, bio });
showToast('Profil mis à jour', 'success');
navigate('/profile');
// The edit REJECTS when no profile of mine is resolved — it refuses to write
// into someone else's document. So confirm and leave the screen only once the
// write has settled, never before.
void Promise.resolve(updateProfile({ name: fullName, initials, username, city, bio }))
.then(() => {
showToast('Profil mis à jour', 'success');
navigate('/profile');
})
.catch((err: unknown) => {
console.error('[UpdateProfile] profile write failed:', err);
showToast("Le profil n'a pas pu être enregistré", 'error');
});
};
return (
+276 -83
View File
@@ -10,8 +10,6 @@ import type {
import {
depositRegistration,
depositLeave,
buildNotification,
insertNotification,
readRegistrationNotifications,
materializeAttendance,
deleteParticipation,
@@ -116,24 +114,44 @@ function nextId(prefix: string): string {
// old form, or those participations render as "participant inconnu".
const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:';
/** Waits between attempts at resolving the owned-event set (see its effect). */
/** Waits between attempts at resolving an owned-document set (see the effects). */
const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000];
/**
* Resolve a Participation's `fp:user` to its UserProfile across the TWO id spaces
* that meet at this join (the root cause of the "unknown participant" bug):
* • a Participation stores `urn:festipod:user:<normalized-identifier>` (the stable
* principal = `currentUserId`), while
* • a UserProfile's `id` is its `did:ng:` document NURI — never that principal.
* The bridge is the NORMALIZED IDENTIFIER, which equals `normalizeIdentifier(username)`
* for the matching profile (the exact equality `currentUser` resolution already uses).
* So: strip the principal prefix off the participation's userId, and compare the
* remainder to `normalizeIdentifier(profile.username)`. In demo/local mode both sides
* are the bare seed id (`user-1`), matched directly by `u.id === userId` — which is
* why the direct match is tried FIRST (the seed username `@mariedupont` would not
* normalize to `user-1`). 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 — it does not participate in this join.
* 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:<normalized-handle>`, 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.
@@ -304,10 +322,10 @@ function useNgData(): FestipodDataContextValue {
const eventQuery = useShapeQuery(FpEventShapeType, 'public');
const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected');
const partQuery = useShapeQuery(FpParticipationShapeType, 'protected');
const users = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
// The RAW reactive sets straight from `watchShape` (before the optimistic
// overlay). The exposed `events`/`participations` merge these with the pending
// overlay below — see the "OPTIMISTIC OVERLAY" block.
// 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),
@@ -331,9 +349,17 @@ function useNgData(): FestipodDataContextValue {
// dropped from pendingRemoves. No re-query, no interval — the overlay only reacts
// to `watchShape`'s own pushes.
const [pendingAddEvents, setPendingAddEvents] = useState<FpEventData[]>([]);
const [pendingAddUsers, setPendingAddUsers] = useState<FpUserData[]>([]);
const [pendingAddParticipations, setPendingAddParticipations] = useState<FpParticipationData[]>([]);
const [pendingRemoveIds, setPendingRemoveIds] = useState<Set<string>>(() => 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));
@@ -365,6 +391,15 @@ function useNgData(): FestipodDataContextValue {
});
}, [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));
@@ -406,6 +441,17 @@ function useNgData(): FestipodDataContextValue {
// and writes `participantCount` on THAT (owned) doc — never on someone else's.
const [ownedEventIds, setOwnedEventIds] = useState<Nuri[]>([]);
// 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<Nuri[] | null>(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<string>('');
/**
* Fold documents THIS SESSION just created into the owned set.
*
@@ -501,7 +547,12 @@ function useNgData(): FestipodDataContextValue {
if (hasTriedAutoSeed.current) return;
if (!ready) return;
if (!readReady) return; // still syncing — do NOT mistake pending for empty
const walletHasData = events.length > 0 || users.length > 0;
// 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;
@@ -516,40 +567,51 @@ function useNgData(): FestipodDataContextValue {
// 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.length, claimOwnedEventDocs]);
}, [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. It is for display and log attribution; no data call takes it,
// and it is never written into an entity.
// (2) `currentUserId` — the app's own entity space: the `@id` of the profile
// DOCUMENT read back in the protected scope (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. It stays
// empty until the protected read lands — mutations that need it refuse
// rather than write an entity the read would drop.
// 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.
//
// THE JOIN between the two is explicit and lives HERE, in one place: a profile
// belongs to the signed-in person when its username normalizes to the
// principal — the same bridge `resolveParticipantUser` uses for the legacy
// `urn:festipod:user:` space. Nothing merges the spaces: the principal selects
// a profile, it never stands in for one.
// 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();
const currentUser =
(currentPrincipal
? users.find(u => u.username && normalizeIdentifier(u.username) === currentPrincipal)
: undefined)
// No profile answers to the signed-in person (the wallet holds fixtures, or
// the profile read has not landed): fall back to the demo-seed pick. KNOWN
// HAZARD — this GUESSES a profile, so the app can show the wrong person as
// "you" while the real answer has simply not been read yet. Note what is and
// is not guessed: the identity itself never is (it is exactly what
// `ensureIdentity()` returned); only the profile it selects can be wrong.
|| users.find(u => u.username === '@mariedupont')
|| users[0];
/** The profiles I own: the reactive profiles whose document is one of mine. */
const myOwnedProfiles = React.useMemo<FpUserData[] | null>(() => {
if (myProtectedDocs === null) return null; // UNKNOWN — never "none"
const mine = new Set<string>(myProtectedDocs);
return users.filter(u => mine.has(u.id));
}, [users, myProtectedDocs]);
const currentUser = React.useMemo<FpUserData | undefined>(() => {
// 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];
return undefined; // none, or ambiguous
}, [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
@@ -573,6 +635,106 @@ function useNgData(): FestipodDataContextValue {
}
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 ambiguous ------------------------
// 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). There
// is no honest way to tell which one is the person at the keyboard, so nothing
// is picked — `currentUser` stays undefined and the mutations that need it
// refuse. Silence here would look exactly like "the read has not landed".
const warnedAmbiguousProfile = useRef(false);
useEffect(() => {
if (myProfileDocId) return;
if (myOwnedProfiles === null || myOwnedProfiles.length <= 1) return;
if (warnedAmbiguousProfile.current) return;
warnedAmbiguousProfile.current = true;
console.error(
`${logPrefix} ${myOwnedProfiles.length} profile documents are mine and none was created by ` +
`this session — which one is me cannot be told apart, so NO profile is resolved (a fixture ` +
`seed run on this wallet is the usual cause). Sign-up and profile edition will refuse.`,
);
}, [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
@@ -866,14 +1028,19 @@ function useNgData(): FestipodDataContextValue {
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 principal (SHEX `fp:user` is mandatory) —
// writing one without it produces an entity the ORM drops on read (the
// participation silently never round-trips). Refuse an empty principal rather
// than persist a broken participation. The caller resolves a real user id (the
// current user's IRI) before joining.
// 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) {
console.error(`${logPrefix} joinEvent: empty user principal — refusing to write a participation with no fp:user.`);
return;
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
@@ -921,16 +1088,24 @@ function useNgData(): FestipodDataContextValue {
// 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) 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.
// 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
// 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;
// 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)
@@ -940,24 +1115,11 @@ function useNgData(): FestipodDataContextValue {
`event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` +
`materializing this deposit on its own doc, at its next connection)`,
);
const { ts, uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
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);
const notif = buildNotification(recipientId, eventId, registrantId, ts);
// The host FpNotification is its OWN document in the PROTECTED scope (one
// doc per entity). The inbox materialization remains the source of truth;
// this direct write only pre-warms the reactive read — but a FAILED write is
// not swallowed: it used to be dropped silently, and the line below then
// surfaced a notification nothing had recorded. The rejection reaches the
// catch under this block, which names it.
const notifGraph = await createEntityDoc('protected');
await insertNotification(notifGraph, notif);
// Surface immediately in reactive state (materialization also refreshes it).
// Use the stable per-deposit uid for the id (F5 dedup) so it matches the
// notification id from the inbox and same-ms/anon deposits never collide.
setNotifications(prev => [...prev, { ...notif, id: `notif-${depositUid}` }]);
} catch (err) {
console.error(`${logPrefix} joinEvent inbox/notify failed:`, err);
console.error(`${logPrefix} joinEvent inbox deposit failed:`, err);
}
}, [events, currentUserId]);
@@ -968,7 +1130,22 @@ function useNgData(): FestipodDataContextValue {
// 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);
if (!part) return;
// 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.
@@ -1054,9 +1231,19 @@ function useNgData(): FestipodDataContextValue {
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
console.log(`${logPrefix} updateProfile (NG):`, updates);
// The current user's profile is its own document (subject IRI = doc NURI).
const target = currentUser ?? users[0];
if (!target) return;
// 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<void>[] = [];
if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name)));
@@ -1073,7 +1260,10 @@ function useNgData(): FestipodDataContextValue {
// 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;
const walletHasData = events.length > 0 || users.length > 0;
// 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
@@ -1083,8 +1273,11 @@ function useNgData(): FestipodDataContextValue {
// 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.length, claimOwnedEventDocs]);
}, [events.length, users, myProfileDocId, claimOwnedEventDocs]);
return {
currentUserId, currentUser, currentPrincipal,
+5 -4
View File
@@ -14,10 +14,11 @@
*
* WHAT IT IS NOT — it is NOT the id space the app's own entities live in. A
* UserProfile's id is its document NURI, and a Participation's `fp:user` carries
* that NURI; this principal is a third space. The join between the two is
* explicit and lives in one place (`FestipodDataContext`, where the principal
* selects the current user's profile through `normalizeIdentifier(username)`).
* Never compare this value to an entity id directly.
* that NURI; this principal is a third space, and there is NO join between them.
* A profile is Festipod's own object and this identity says nothing about it:
* **my profile is the profile document I own** (`listMyEntityDocs('protected')`,
* resolved in `FestipodDataContext`). Never compare this value to an entity id,
* and never match it against a profile field to decide who the current user is.
*
* WHY A MODULE STORE and not a React context: `AuthGate` — the component that
* makes the await — is mounted INSIDE `FestipodDataProvider`, so a context it