Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
19 changed files with 472 additions and 127 deletions
Showing only changes of commit db3dbba294 - Show all commits
@@ -7,3 +7,5 @@
- 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)
- TOUCHED src/modules/event/screens/CreateEventScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/screens/MeetingPointsScreen.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+8
View File
@@ -0,0 +1,8 @@
# Doc-debt — bdd-testing
> 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/test-harness/harness-ng.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/test-harness/harness.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+6
View File
@@ -6,3 +6,9 @@
## 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)
- TOUCHED src/shared/shapes/shex/festipodShapes.shex @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/autoSeed.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/ngBootstrap.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/ngBootstrap.test.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/data/types.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/data/shapeAdapters.ts @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
@@ -101,8 +101,6 @@ export function CreateEventScreen() {
// truth (and every other viewer) was 0.
participantCount: 0,
themes: ['Social'],
hostName: 'Moi',
hostInitials: 'MD',
});
} catch (err) {
console.error('[CreateEvent] createEvent failed:', err);
@@ -238,11 +236,6 @@ export function CreateEventScreen() {
<Text style={{ margin: '4px 0', fontSize: 13, color: '#888' }}>
{ev.date} · {ev.location}
</Text>
{ev.hostName && (
<Text style={{ margin: '0 0 10px 0', fontSize: 12, color: '#888' }}>
Relayé par {ev.hostName}
</Text>
)}
<Button
variant="primary"
style={{ width: '100%', padding: 10, fontSize: 13 }}
@@ -14,6 +14,7 @@ export function EventDetailScreen() {
leaveEvent,
getEventParticipants,
getEventMeetingPoints,
getEventOwnership,
} = useFestipodData();
const event = eventId ? getEvent(eventId) : undefined;
@@ -29,7 +30,19 @@ export function EventDetailScreen() {
lieu: mp.location,
}));
const isOwner = true;
// 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.
//
// The third answer, `'unknown'`, is rendered as a DISABLED affordance rather
// than resolved either way. Hiding it would be the worst outcome: an owner
// would be told, silently and wrongly, that their own event is not theirs, with
// nothing on screen to suggest otherwise. Showing it live would be the opposite
// lie — a control that promises an edit it may not be able to make, and only
// says so after the person has typed. Visible-but-inert says the true thing:
// the answer is still coming.
const ownership = eventId ? getEventOwnership(eventId) : 'unknown';
const canEdit = ownership === 'mine';
// Preview list shows the OTHER participants (deliberate — the total is in the
// header count; the full list at "Voir tous les participants" shows everyone).
// Compare on the PROFILE id: `currentUser.id` is the resolved profile NURI, the
@@ -85,8 +98,25 @@ export function EventDetailScreen() {
<div style={{ fontSize: 12, color: '#888' }}>{event.distance} km</div>
)}
</div>
{isOwner && (
<span onClick={() => navigate(`/events/${eventId}/edit`)} style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}></span>
{canEdit && (
<span
onClick={() => navigate(`/events/${eventId}/edit`)}
title="Modifier l'événement"
style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}
></span>
)}
{ownership === 'unknown' && (
// DELIBERATELY NOT A ✎. The slot stays occupied, so an owner is never
// silently told the event is not theirs — but a pending control must
// not look like the actionable one it is not: a greyed-out twin of the
// pencil reads as "edit, broken" and invites a click that does nothing.
// A distinct mark reads as "still working it out", which is the truth.
<span
aria-busy="true"
aria-label="Vérification de vos droits de modification"
title="Vérification de vos droits de modification…"
style={{ cursor: 'default', fontSize: 18, color: '#ddd' }}
></span>
)}
</div>
@@ -26,8 +26,6 @@ export function MeetingPointsScreen() {
eventId,
location: title || lieu || 'Point de rencontre',
time: when || duration,
hostName: currentUser?.name?.split(' ')[0] ?? 'Moi',
hostInitials: currentUser?.initials ?? '?',
});
showToast(title ? `Point de rencontre créé : ${title}` : 'Point de rencontre créé', 'success');
navigate(`/events/${eventId}`);
+142 -6
View File
@@ -39,19 +39,41 @@ import {
import { useCurrentPrincipal } from '../utils/currentPrincipal';
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 {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
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, shouldAutoSeed } from '../utils/autoSeed';
import { autoSeedEnabled, fixtureSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
// ============================================================================
// 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;
@@ -78,6 +100,11 @@ interface FestipodDataContextValue {
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;
@@ -283,6 +310,13 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
console.log(`${logPrefix} loadTestData (local, no-op)`);
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } };
}, []);
// Demo mode owns its whole fixture world. There is no document, no wallet and
// no writing here — every mutation above is a local no-op — so there is no
// ownership to look up and no listing that could be pending: the answer is
// settled, and it is the same one demo mode gives everywhere else, which is
// "yes, the app is yours to drive". `'unknown'` would leave the affordance
// pending forever on a path where nothing is ever going to resolve.
const getEventOwnership = useCallback((): EventOwnership => 'mine', []);
return {
currentUserId, currentUser, currentPrincipal,
@@ -291,6 +325,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
createEvent, updateEvent, joinEvent, leaveEvent,
addMeetingPoint, addFriend, updateProfile, loadTestData,
};
@@ -440,6 +475,24 @@ function useNgData(): FestipodDataContextValue {
// 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<Nuri[]>([]);
// 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<ReadonlySet<string>>(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):
@@ -480,8 +533,30 @@ function useNgData(): FestipodDataContextValue {
// 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++) {
@@ -489,6 +564,35 @@ function useNgData(): FestipodDataContextValue {
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<string>();
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];
@@ -510,7 +614,7 @@ function useNgData(): FestipodDataContextValue {
}
})();
return () => { cancelled = true; };
}, [ready]);
}, [ready, unclassifiedEventKey]);
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
@@ -541,8 +645,15 @@ function useNgData(): FestipodDataContextValue {
// (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;
@@ -788,6 +899,25 @@ function useNgData(): FestipodDataContextValue {
// 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, so we only persist a genuine change.
const materializedCountRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
@@ -985,7 +1115,7 @@ function useNgData(): FestipodDataContextValue {
// 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), hostName: str(event.hostName), hostInitials: str(event.hostInitials),
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.
@@ -1269,6 +1399,11 @@ function useNgData(): FestipodDataContextValue {
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
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).
@@ -1302,6 +1437,7 @@ function useNgData(): FestipodDataContextValue {
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
createEvent, updateEvent, joinEvent, leaveEvent,
addMeetingPoint, addFriend, updateProfile, loadTestData,
};
-14
View File
@@ -130,8 +130,6 @@ export const seedEvents: FpEventData[] = [
distance: 142,
participantCount: 24,
description: 'Une semaine collaborative pour se rencontrer, co-créer et faire avancer le projet de Réseau Social Universel. Au programme : sessions plénières en intelligence collective, ateliers en forum ouvert, et randonnée au Cirque de Navacelles. Hébergement sur place au Revel, écolieu à Rogues dans le Gard.',
hostName: 'Reconnexion',
hostInitials: 'RC',
themes: ['Social'],
},
{
@@ -145,8 +143,6 @@ export const seedEvents: FpEventData[] = [
distance: 3,
participantCount: 12,
description: 'Un atelier pratique pour découvrir les low-tech et apprendre à fabriquer des objets du quotidien.',
hostName: 'La Maison du Vélo',
hostInitials: 'MV',
themes: ['Tech', 'Nature'],
},
{
@@ -160,8 +156,6 @@ export const seedEvents: FpEventData[] = [
distance: 89,
participantCount: 45,
description: "Un forum ouvert sur la transition écologique et sociale, dans le tiers-lieu L'Hermitage.",
hostName: "L'Hermitage",
hostInitials: 'LH',
themes: ['Social', 'Nature'],
},
{
@@ -175,8 +169,6 @@ export const seedEvents: FpEventData[] = [
distance: 5,
participantCount: 16,
description: 'Initiation à la Communication Non Violente. Venez découvrir les bases de la CNV pour améliorer vos relations.',
hostName: 'MJC Montplaisir',
hostInitials: 'MJ',
themes: ['Social'],
},
{
@@ -190,8 +182,6 @@ export const seedEvents: FpEventData[] = [
distance: 7,
participantCount: 30,
description: "Rencontre mensuelle du groupe local des Colibris pour échanger sur les projets en cours.",
hostName: 'Les Colibris',
hostInitials: 'LC',
themes: ['Social', 'Nature'],
},
];
@@ -214,16 +204,12 @@ export const seedMeetingPoints: FpMeetingPointData[] = [
eventId: 'event-1',
location: 'Café de la Place',
time: '30 min avant',
hostName: 'Marie',
hostInitials: 'MD',
},
{
id: 'mp-2',
eventId: 'event-1',
location: 'Station de métro Bellecour',
time: '15h30',
hostName: 'Jean',
hostInitials: 'JD',
},
];
-2
View File
@@ -41,8 +41,6 @@ export function adaptEvent(s: UnionSubject): FpEventData {
distance: s.props[`${FP}distance`] ? num(s, 'distance') : undefined,
participantCount: num(s, 'participantCount'),
coverImage: one(s, 'coverImage') || undefined,
hostName: one(s, 'hostName') || undefined,
hostInitials: one(s, 'hostInitials') || undefined,
};
}
+4 -4
View File
@@ -17,8 +17,10 @@ export interface FpEventData {
distance?: number;
participantCount: number;
coverImage?: string;
hostName?: string;
hostInitials?: string;
// NO HOST. An event is only the anchor — a public event someone referenced so
// meeting points can be grafted onto it. Its declarer is not a host, is not
// required to attend, and is not named on it. The host lives one level down, on
// the meeting point (`FpMeetingPointData.hostId` / SHEX `fp:MeetingPoint.host`).
themes?: string[];
}
@@ -54,8 +56,6 @@ export interface FpMeetingPointData {
place?: string;
location: string;
time: string;
hostName: string;
hostInitials: string;
/** NURI of the meeting point's inbox (wired in T02.b/c). */
inbox?: string;
}
+149 -40
View File
@@ -5,7 +5,7 @@ import type { Schema } from "@ng-org/shex-orm";
* festipodShapesSchema: Schema for festipodShapes
* =============================================================================
*/
export const festipodShapesSchema: Schema = {
export const festipodShapesSchema = {
"http://festipod.org/Event": {
iri: "http://festipod.org/Event",
predicates: [
@@ -22,67 +22,92 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/title",
readablePredicate: "title",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/description",
readablePredicate: "description",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/date",
readablePredicate: "date",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/location",
readablePredicate: "location",
},
{
dataTypes: [{ valType: "number" }],
dataTypes: [
{
valType: "number",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/distance",
readablePredicate: "distance",
},
{
dataTypes: [{ valType: "number" }],
dataTypes: [
{
valType: "number",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/participantCount",
readablePredicate: "participantCount",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/coverImage",
readablePredicate: "coverImage",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/hostName",
readablePredicate: "hostName",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/hostInitials",
readablePredicate: "hostInitials",
iri: "http://festipod.org/inbox",
readablePredicate: "inbox",
},
],
},
@@ -102,35 +127,55 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/name",
readablePredicate: "name",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/initials",
readablePredicate: "initials",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/username",
readablePredicate: "username",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/role",
readablePredicate: "role",
},
{
dataTypes: [{ valType: "boolean" }],
dataTypes: [
{
valType: "boolean",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/isPublic",
@@ -154,21 +199,33 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/event",
readablePredicate: "event",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/user",
readablePredicate: "user",
},
{
dataTypes: [{ valType: "boolean" }],
dataTypes: [
{
valType: "boolean",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/isConfirmed",
@@ -192,49 +249,77 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/event",
readablePredicate: "event",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/host",
readablePredicate: "host",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/title",
readablePredicate: "title",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/description",
readablePredicate: "description",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/place",
readablePredicate: "place",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/time",
readablePredicate: "time",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/inbox",
@@ -258,42 +343,66 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/recipient",
readablePredicate: "recipient",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/type",
readablePredicate: "type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/ref",
readablePredicate: "ref",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/payload",
readablePredicate: "payload",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/timestamp",
readablePredicate: "timestamp",
},
{
dataTypes: [{ valType: "boolean" }],
dataTypes: [
{
valType: "boolean",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/isRead",
@@ -301,4 +410,4 @@ export const festipodShapesSchema: Schema = {
},
],
},
};
} as const satisfies Schema;
@@ -1,31 +1,35 @@
import type { ShapeType } from "@ng-org/shex-orm";
import { festipodShapesSchema } from "./festipodShapes.schema";
import { festipodShapesSchema } from "./festipodShapes.schema.ts";
import type {
FpEvent,
FpUserProfile,
FpParticipation,
FpMeetingPoint,
FpNotification,
} from "./festipodShapes.typings";
Event,
UserProfile,
Participation,
MeetingPoint,
Notification,
} from "./festipodShapes.typings.ts";
// ShapeTypes for festipodShapes
export const FpEventShapeType: ShapeType<FpEvent> = {
export const EventShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Event",
};
export const FpUserProfileShapeType: ShapeType<FpUserProfile> = {
} as const satisfies ShapeType<Event>;
export const UserProfileShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/UserProfile",
};
export const FpParticipationShapeType: ShapeType<FpParticipation> = {
} as const satisfies ShapeType<UserProfile>;
export const ParticipationShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Participation",
};
export const FpMeetingPointShapeType: ShapeType<FpMeetingPoint> = {
} as const satisfies ShapeType<Participation>;
export const MeetingPointShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/MeetingPoint",
};
export const FpNotificationShapeType: ShapeType<FpNotification> = {
} as const satisfies ShapeType<MeetingPoint>;
export const NotificationShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Notification",
};
} as const satisfies ShapeType<Notification>;
+13 -19
View File
@@ -9,9 +9,9 @@ export type IRI = string;
/**
* Event Type
*/
export interface FpEvent {
export interface Event {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -65,25 +65,19 @@ export interface FpEvent {
*/
coverImage?: string;
/**
* Name of the event host or relay
* NURI of this event's own inbox, published so that anyone holding the event can deposit into it (an inbox belongs to someone and its address must be given, never derived)
*
* Original IRI: http://festipod.org/hostName
* Original IRI: http://festipod.org/inbox
*/
hostName?: string;
/**
* Initials of the event host
*
* Original IRI: http://festipod.org/hostInitials
*/
hostInitials?: string;
inbox?: string;
}
/**
* UserProfile Type
*/
export interface FpUserProfile {
export interface UserProfile {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -129,9 +123,9 @@ export interface FpUserProfile {
/**
* Participation Type
*/
export interface FpParticipation {
export interface Participation {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -165,9 +159,9 @@ export interface FpParticipation {
/**
* MeetingPoint Type
*/
export interface FpMeetingPoint {
export interface MeetingPoint {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -225,9 +219,9 @@ export interface FpMeetingPoint {
/**
* Notification Type
*/
export interface FpNotification {
export interface Notification {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -18,10 +18,6 @@ fp:Event {
// rdfs:comment "Number of participants" ;
fp:coverImage xsd:string ?
// rdfs:comment "URL of the cover image" ;
fp:hostName xsd:string ?
// rdfs:comment "Name of the event host or relay" ;
fp:hostInitials xsd:string ?
// rdfs:comment "Initials of the event host" ;
fp:inbox xsd:string ?
// rdfs:comment "NURI of this event's own inbox, published so that anyone holding the event can deposit into it (an inbox belongs to someone and its address must be given, never derived)" ;
}
+10 -4
View File
@@ -21,12 +21,18 @@ import { listMyEntityDocs, openDocumentInbox, resolveScopeGraph } from '../utils
import { setCurrentPrincipal } from '../utils/currentPrincipal';
import { materializeAttendance, NOTIF_TYPE_NEW_PARTICIPANT } from '../data/registration';
import type { RegistrationPayload } from '../data/registration';
// Bare generated names aliased back to the `Fp*` spelling at the import — see the
// same note in `shared/context/FestipodDataContext.tsx`.
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
EventShapeType as FpEventShapeType,
UserProfileShapeType as FpUserProfileShapeType,
ParticipationShapeType as FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import type {
Event as FpEvent,
UserProfile as FpUserProfile,
Participation as FpParticipation,
} from '../shapes/orm/festipodShapes.typings';
// ============================================================================
// App — uses real providers (same tree as the real app)
+7 -1
View File
@@ -12,7 +12,13 @@ import React, { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { deepSignal } from '@ng-org/alien-deepsignals';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
// Bare generated names aliased back to the `Fp*` spelling at the import — see the
// same note in `shared/context/FestipodDataContext.tsx`.
import type {
Event as FpEvent,
UserProfile as FpUserProfile,
Participation as FpParticipation,
} from '../shapes/orm/festipodShapes.typings';
// ============================================================================
// Seed data — same events/users as the app's seedData.ts
+22 -3
View File
@@ -13,11 +13,30 @@
* `/festipod-config.json` (read by frontend.tsx, which sets the global before the
* app tree loads). Absent env → global undefined → seed OFF.
*
* NOTE: this gate only affects the app's AUTOMATIC seed. Explicit seeding via
* `loadTestData()` (the "Load test data" action + every @data test through the
* harness bridge) is UNAFFECTED — it calls bootstrapWallet directly.
* NOTE: this gate only governs the app's AUTOMATIC seed. It is subordinate to
* `fixtureSeedEnabled()` below, which currently switches EVERY route off.
*/
/**
* MASTER SWITCH — may a fixture seed be written into the connected wallet AT ALL?
*
* Currently OFF, a product decision: no fixture reaches the connected wallet by
* any route — neither the opt-in auto-seed (`FESTIPOD_AUTO_SEED`, whose gate
* below is subordinate to this one) nor an explicit `loadTestData()`.
*
* OFF, not deleted. The fixtures (`data/seedData.ts`) and the seeding code stay,
* because two paths still need them and NEITHER writes to a wallet: the
* demo/disconnected provider (`LocalDataProvider`) reads the fixtures straight
* into React state, and the rendering-layer tests read them directly too. Only
* the connected wallet is closed off.
*
* Enforced at the single place fixtures enter a wallet — `bootstrapWallet`
* (`utils/ngBootstrap.ts`) — so no caller can bypass it by construction; the
* call sites consult it too, only so they neither log nor await work that will
* not happen.
*/
export const fixtureSeedEnabled = (): boolean => false;
// Build-injected global (not `process.env`, absent in the browser); any path that
// doesn't inject it reads `undefined` → false safely (no ReferenceError).
declare global {
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from 'bun:test';
import { bootstrapWallet } from './ngBootstrap';
// THE SEED IS OFF, AND THIS IS WHAT HOLDS IT OFF.
//
// `bootstrapWallet` is the one place fixtures enter a wallet — the opt-in
// auto-seed and an explicit `loadTestData()` both funnel through it — so proving
// it writes nothing proves no route writes. The proof is not "it returned
// seeded: false" (a seed that half-ran could say that too) but that it never
// asked for a document at all: the `createEntityDoc` handed in below FAILS the
// test if it is called even once.
test('the fixture seed is OFF — bootstrapWallet creates no document, on an empty wallet', async () => {
let createCalls = 0;
const createEntityDoc = async () => {
createCalls++;
throw new Error('createEntityDoc must not be called while the fixture seed is disabled');
};
// `walletHasData: false` is the ONE input that used to make the seed run. Even
// there — a genuinely empty wallet, the case the seed existed for — nothing is
// created.
const result = await bootstrapWallet(false, createEntityDoc);
expect(createCalls).toBe(0);
expect(result.seeded).toBe(false);
expect(result.createdDocs.public).toEqual([]);
expect(result.createdDocs.protected).toEqual([]);
expect(result.userIdMap.size).toBe(0);
expect(result.eventIdMap.size).toBe(0);
});
test('the fixture seed is OFF — bootstrapWallet writes nothing on a populated wallet either', async () => {
let createCalls = 0;
const createEntityDoc = async () => {
createCalls++;
throw new Error('createEntityDoc must not be called while the fixture seed is disabled');
};
const result = await bootstrapWallet(true, createEntityDoc);
expect(createCalls).toBe(0);
expect(result.seeded).toBe(false);
});
+11 -1
View File
@@ -18,6 +18,7 @@ import {
seedUsers,
} from '../data/seedData';
import { writeEntity, ENTITY_TYPE, str, int, flt, bool } from '../data/entityWrites';
import { fixtureSeedEnabled } from './autoSeed';
/** Scope of a seed entity + how to create its own document (SDK create). */
export type Scope = 'public' | 'protected' | 'private';
@@ -46,6 +47,15 @@ export async function bootstrapWallet(
createEntityDoc: CreateEntityDoc,
): Promise<BootstrapResult> {
const createdDocs: { public: Nuri[]; protected: Nuri[] } = { public: [], protected: [] };
// THE SEED IS OFF (see `fixtureSeedEnabled`). This is the one place fixtures
// enter a wallet, so the switch is enforced HERE rather than at each caller:
// every route — the opt-in auto-seed, an explicit `loadTestData()` — funnels
// through this function, and none of them can write around it. Callers get the
// ordinary "nothing was seeded" answer, which is exactly true.
if (!fixtureSeedEnabled()) {
console.log('[Bootstrap] Fixture seed is disabled — nothing written to the wallet');
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs };
}
// Already has data → returning user, nothing to seed
if (walletHasData) {
console.log('[Bootstrap] Wallet already has data — skipping seed');
@@ -110,7 +120,7 @@ export async function bootstrapWallet(
title: str(e.title), description: str(e.description), date: str(e.date),
location: str(e.location), distance: flt(e.distance),
participantCount: int(e.participantCount),
coverImage: str(e.coverImage), hostName: str(e.hostName), hostInitials: str(e.hostInitials),
coverImage: str(e.coverImage),
});
eventIdMap.set(e.id, id);
// The seeded event is NOT announced anywhere: there is no discovery index to