diff --git a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md index c4683f6..47d3bd3 100644 --- a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md +++ b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md @@ -59,6 +59,7 @@ What must keep holding for this backend to serve the app. 2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience. 3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible. 4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins. +5. **The member shape of an `apods:attendees` collection is not pinned anywhere Festipod may read at work time.** The reconstruction documents the container discovery, not what a membership record is: a bare actor URI, or an object carrying event/participant/status. The project reconnaissance records only that a join "adds the actor to the collection" and that members come back "as full objects for a reader who is allowed, and as URIs otherwise". The ActivityPods backend's participation binding is designed against that: a member maps to `{ id, userId, isConfirmed: true }` (membership IS the attendance), with `eventId` supplied by the reader — the event the collection was read from — because a member carries no event link. The first runtime read against the local platform (T04.g) must confirm or correct this mapping. ## Needs diff --git a/src/shared/refine/providers/activitypods.test.ts b/src/shared/refine/providers/activitypods.test.ts new file mode 100644 index 0000000..2fed03b --- /dev/null +++ b/src/shared/refine/providers/activitypods.test.ts @@ -0,0 +1,302 @@ +import { expect, test } from 'bun:test'; +import { getBackend, listBackends, resourceNames } from './index'; +import type { FpEventData, FpMeetingPointData, FpUserData } from '../../data/types'; + +// The backend is registered as the default under a name no top-level route uses — the +// variant prefix and the route would collide otherwise (providers/index.ts). +test('registered as the default backend, under a URL-safe name', () => { + const backends = listBackends(); + expect(backends).toHaveLength(1); + const backend = getBackend('activitypods'); + expect(backends[0]).toBe(backend); + expect(backend.name).toBe('activitypods'); + expect(['events', 'profile', 'home', 'settings', 'users']).not.toContain(backend.name); +}); + +test('an unknown backend name throws rather than falling back', () => { + expect(() => getBackend('nextgraph')).toThrow(/Unknown Refine backend/); +}); + +// Refine resources are derived from the bindings; participations read through the +// events resource, so they add no resource of their own. +test('resource names are the deduplicated binding resources', () => { + expect(resourceNames(getBackend('activitypods'))).toEqual(['events', 'meetingPoints', 'profiles']); +}); + +// One identifier on this backend: the record URI is both the key Refine addresses the +// record by and the id the app's entities reference (the contract's own guarantee). +test('recordKey and domainId are the record URI, id or @id whichever is present', () => { + const { event, meetingPoint, profile, participation } = getBackend('activitypods').bindings; + const record = { id: 'http://localhost:3000/alice/data/1', name: 'X' }; + expect(event.recordKey(record)).toBe('http://localhost:3000/alice/data/1'); + expect(event.domainId(record)).toBe('http://localhost:3000/alice/data/1'); + expect(profile.recordKey({ '@id': 'http://localhost:3000/alice' })).toBe('http://localhost:3000/alice'); + expect(meetingPoint.domainId({ '@id': 'http://localhost:3000/alice/data/2' })).toBe( + 'http://localhost:3000/alice/data/2', + ); + expect(() => event.recordKey({ name: 'no identifier' })).toThrow(/neither id nor @id/); + // A participation is a collection member: a bare attendee URI is its own key. + expect(participation.recordKey('http://localhost:3000/bob')).toBe('http://localhost:3000/bob'); + expect(participation.domainId({ id: 'http://localhost:3000/bob', name: 'Bob' })).toBe( + 'http://localhost:3000/bob', + ); +}); + +// The contract's update fetches the record and merges shallowly, so a partial write +// is safe: every binding declares the backend as patching. +test('updateReplaces is false on every binding', () => { + const { bindings } = getBackend('activitypods'); + for (const binding of Object.values(bindings)) { + expect(binding.updateReplaces).toBe(false); + } +}); + +test('event toDomain maps an ActivityStreams record, splitting the datetimes', () => { + const { event } = getBackend('activitypods').bindings; + const domain = event.toDomain({ + id: 'http://localhost:3000/alice/data/550e8400-e29b-41d4-a716-446655440000', + '@context': 'https://activitypods.org/context.json', + type: 'Event', + name: 'Soirée T04.a', + content: 'Une soirée de test', + startTime: '2026-10-10T18:00:00Z', + endTime: '2026-10-10T23:00:00Z', + location: 'Le Belle Électrique, Grenoble', + image: 'https://example.org/cover.jpg', + }); + expect(domain).toEqual({ + id: 'http://localhost:3000/alice/data/550e8400-e29b-41d4-a716-446655440000', + title: 'Soirée T04.a', + description: 'Une soirée de test', + date: '2026-10-10', + startDate: '2026-10-10', + endDate: '2026-10-10', + startTime: '18:00', + endTime: '23:00', + location: 'Le Belle Électrique, Grenoble', + participantCount: 0, + coverImage: 'https://example.org/cover.jpg', + }); +}); + +test('event toDomain tolerates object forms and derives the display date like the forms do', () => { + const { event } = getBackend('activitypods').bindings; + const domain = event.toDomain({ + id: 'http://localhost:3000/alice/data/3', + name: 'Résidence', + startTime: '2026-02-16T09:00:00', + endTime: '2026-02-20T18:00:00', + location: { name: 'Le Revel, Rogues (30)' }, + image: { url: 'https://example.org/residence.jpg' }, + }); + expect(domain.date).toBe('2026-02-16 - 2026-02-20'); + expect(domain.startDate).toBe('2026-02-16'); + expect(domain.endDate).toBe('2026-02-20'); + expect(domain.startTime).toBe('09:00'); + expect(domain.location).toBe('Le Revel, Rogues (30)'); + expect(domain.coverImage).toBe('https://example.org/residence.jpg'); +}); + +test('event toValues composes the datetimes and never stores the derived fields', () => { + const { event } = getBackend('activitypods').bindings; + const data: Omit = { + title: 'Atelier low-tech', + description: 'Fabriquer des objets', + date: 'Sam. 8 fév. · 14h00', + startDate: '2026-02-08', + endDate: '2026-02-08', + startTime: '14:00', + endTime: '17:00', + location: 'La Maison du Vélo, Lyon', + distance: 3, + participantCount: 12, + coverImage: 'https://example.org/atelier.jpg', + themes: ['Tech'], + }; + const values = event.toValues(data); + expect(values).toEqual({ + name: 'Atelier low-tech', + content: 'Fabriquer des objets', + startTime: '2026-02-08T14:00', + endTime: '2026-02-08T17:00', + location: 'La Maison du Vélo, Lyon', + image: 'https://example.org/atelier.jpg', + }); +}); + +test('event toValues keeps a date-only end and drops a time-only start', () => { + const { event } = getBackend('activitypods').bindings; + expect( + event.toValues({ + title: 'T', + description: '', + date: '', + startDate: '2026-03-01', + endDate: '2026-03-03', + location: '', + participantCount: 0, + }), + ).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: '' }); + expect( + event.toValues({ title: 'T', description: '', date: '', startTime: '09:00', location: '', participantCount: 0 }), + ).toEqual({ name: 'T', content: '', location: '' }); +}); + +test('event toPatch sends only what changed', () => { + const { event } = getBackend('activitypods').bindings; + expect(event.toPatch({ title: 'Nouveau titre' })).toEqual({ name: 'Nouveau titre' }); +}); + +test('meetingPoint toDomain reads the context link in both its forms', () => { + const { meetingPoint } = getBackend('activitypods').bindings; + const objectForm = meetingPoint.toDomain({ + id: 'http://localhost:3000/alice/data/mp-1', + type: 'Event', + name: 'Rdv entrée', + content: 'On se retrouve là-bas', + 'as:context': { id: 'http://localhost:3000/alice/data/event-1' }, + organizer: 'http://localhost:3000/alice', + location: { name: 'Café de la Place' }, + }); + expect(objectForm).toEqual({ + id: 'http://localhost:3000/alice/data/mp-1', + eventId: 'http://localhost:3000/alice/data/event-1', + hostId: 'http://localhost:3000/alice', + title: 'Rdv entrée', + description: 'On se retrouve là-bas', + location: 'Café de la Place', + time: '', + }); + const stringForm = meetingPoint.toDomain({ + id: 'http://localhost:3000/alice/data/mp-2', + 'as:context': 'http://localhost:3000/alice/data/event-1', + organizer: { id: 'http://localhost:3000/alice' }, + location: 'Station Bellecour', + }); + expect(stringForm.eventId).toBe('http://localhost:3000/alice/data/event-1'); + expect(stringForm.hostId).toBe('http://localhost:3000/alice'); + expect(stringForm.title).toBeUndefined(); +}); + +test('meetingPoint toValues writes the context and organizer, not the unmapped fields', () => { + const { meetingPoint } = getBackend('activitypods').bindings; + const values = meetingPoint.toValues({ + eventId: 'http://localhost:3000/alice/data/event-1', + hostId: 'http://localhost:3000/alice', + title: 'Rdv entrée', + description: 'Sous l’horloge', + place: 'Entrée principale', + location: 'Café de la Place', + time: '30 min avant', + }); + expect(values).toEqual({ + name: 'Rdv entrée', + content: 'Sous l’horloge', + location: 'Café de la Place', + organizer: 'http://localhost:3000/alice', + 'as:context': 'http://localhost:3000/alice/data/event-1', + }); +}); + +test('profile toDomain maps the AS2 profile fields and derives the initials', () => { + const { profile } = getBackend('activitypods').bindings; + const domain = profile.toDomain({ + id: 'http://localhost:3000/alice', + type: 'Profile', + name: 'Marie Dupont', + preferredUsername: 'mariedupont', + summary: 'Organisatrice d’ateliers', + location: { name: 'Lyon' }, + }); + expect(domain).toEqual({ + id: 'http://localhost:3000/alice', + name: 'Marie Dupont', + initials: 'MD', + username: 'mariedupont', + bio: 'Organisatrice d’ateliers', + city: 'Lyon', + }); + expect(profile.toDomain({ id: 'http://localhost:3000/bob', name: 'Bob' })).toMatchObject({ + initials: 'BO', + username: '', + }); +}); + +test('profile toValues writes the mapped fields and none of the derived ones', () => { + const { profile } = getBackend('activitypods').bindings; + const values = profile.toValues({ + name: 'Marie Dupont', + initials: 'MD', + username: 'mariedupont', + role: 'Créatrice', + isPublic: true, + bio: 'Bio', + city: 'Lyon', + eventsCount: 23, + friendsCount: 7, + participationsCount: 4, + }); + expect(values).toEqual({ + name: 'Marie Dupont', + preferredUsername: 'mariedupont', + summary: 'Bio', + location: 'Lyon', + }); +}); + +test('participation toDomain maps a bare URI member and a dereferenced one', () => { + const { participation } = getBackend('activitypods').bindings; + expect(participation.toDomain('http://localhost:3000/bob')).toEqual({ + id: 'http://localhost:3000/bob', + eventId: '', + userId: 'http://localhost:3000/bob', + isConfirmed: true, + }); + expect(participation.toDomain({ id: 'http://localhost:3000/bob', name: 'Bob' })).toEqual({ + id: 'http://localhost:3000/bob', + eventId: '', + userId: 'http://localhost:3000/bob', + isConfirmed: true, + }); + expect(() => participation.toDomain({ name: 'no reference' })).toThrow(/no attendee reference/); +}); + +test('participation rejects writes: joins and leaves are not Refine writes', () => { + const { participation } = getBackend('activitypods').bindings; + expect(() => + participation.toValues({ eventId: 'e', userId: 'u', isConfirmed: true }), + ).toThrow(/read-only/); + expect(() => participation.toPatch({ isConfirmed: false })).toThrow(/read-only/); +}); + +// The visibility declarations mirror the product's confidentiality model: events, +// meeting points and participations are public; the profile link is protected. +test('visibility declarations follow the confidentiality model', () => { + const { bindings } = getBackend('activitypods'); + expect(bindings.event.visibility).toBe('public'); + expect(bindings.meetingPoint.visibility).toBe('public'); + expect(bindings.participation.visibility).toBe('public'); + expect(bindings.profile.visibility).toBe('protected'); +}); + +// Type-level guard: the exported backend satisfies the seam contract as a whole, and +// a written meeting point reads back with the unmapped display fields empty. +test('backend satisfies the RefineBackend contract shape', () => { + const backend = getBackend('activitypods'); + expect(typeof backend.build).toBe('function'); + expect(Object.keys(backend.bindings).sort()).toEqual(['event', 'meetingPoint', 'participation', 'profile']); + const sample: FpMeetingPointData = { + id: 'mp', + eventId: 'event-1', + location: 'Café de la Place', + time: '15h30', + }; + const stored = { id: 'mp', ...backend.bindings.meetingPoint.toValues(sample) }; + expect(backend.bindings.meetingPoint.toDomain(stored)).toMatchObject({ + id: 'mp', + eventId: 'event-1', + title: undefined, + location: 'Café de la Place', + time: '', + }); +}); diff --git a/src/shared/refine/providers/activitypods.ts b/src/shared/refine/providers/activitypods.ts new file mode 100644 index 0000000..bf50dd0 --- /dev/null +++ b/src/shared/refine/providers/activitypods.ts @@ -0,0 +1,404 @@ +/** + * The ActivityPods backend — `@activitypods/refine-providers` behind the seam. + * + * This backend is specified by the pinned reconstruction + * `.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md` + * and by nothing else: the package's sources are never read at work time, so every + * behaviour relied on here is either a guarantee of that contract or a fact recorded + * in the project's own documents (brief, hand-off, cookbook). + * + * WHAT A RECORD IS. A record is a JSON-LD resource whose `id` (or `@id`) is its own + * URI, and that URI is the ONLY identifier: `getOne`, `update` and `deleteOne` use it + * directly as the URL they fetch. The seam carries `recordKey` and `domainId` + * separately because they differ on the other targeted backend; here they coincide, + * so both return the record URI. + * + * UPDATE MERGES, IT DOES NOT REPLACE. `update` fetches the current resource and PUTs + * `{...current, ...variables}`, so a partial write is safe — the backend PATCHES and + * `updateReplaces` is `false`. The merge is SHALLOW: a nested object sent in variables + * replaces its counterpart whole. Every value this module sends is a flat scalar, so + * mapping only the changed fields (`toPatch` = same mapper as `toValues`, restricted + * by what the caller passes) is both safe and complete. + * + * THE VOCABULARY IS ACTIVITYSTREAMS, NOT Festipod's. Directive of 2026-10-09 + * (concept `data-layer`, `brief_2026-10-09_activitypods-first-backend`): no new + * shapes, interoperability first. So an event is an `as/Event` and a meeting point is + * a plain `as/Event` distinguished by an `as:context` link to its anchor event (the + * `as/Event` shape is open, so the extra predicate breaks nothing); a profile is an + * `as/Profile`. Fields the app's domain type carries but ActivityStreams has no slot + * for are left unmapped on purpose, one comment per field — recording them would need + * a Festipod-owned predicate, and a custom prefix must be registered at prefix.cc + * before the Pod provider accepts it. App fields with no slot read back as their + * type's empty value (`''` / `undefined`) and are re-derived by the reader. + * + * EVENTS AND MEETING POINTS SHARE ONE CONTAINER. Container discovery is per type + * (TypeIndex `solid:forClass`), and both resources resolve to `as:Event`, so both + * resolve to the SAME container: a list of events also contains meeting points. The + * presence of `as:context` is what distinguishes a meeting point, so the reader must + * filter on it — that is where the distinction lives, since the container does not. + * + * PARTICIPATIONS ARE A COLLECTION, READ EMBEDDED. The production precedent + * (hand-off, reconnaissance of 2026-10-09): a join adds the attendee to the event's + * `apods:attendees` collection and a leave removes it, so membership IS the + * attendance. That collection is per-event and is NOT a TypeIndex container, so it is + * not a listed resource here: it arrives inside the event record — the collection URI + * under `apods:attendees`, its members dereferenced when the reader may read them and + * as bare URIs otherwise — and it can also be read by `getOne` on the collection URI, + * which fetches a URI directly. Reading members is done from the event record rather + * than by `getMany`-ing member URIs because `getMany` silently drops what it cannot + * fetch, which would erase exactly the "reference that does not resolve" state the + * app model needs. Writing a participation is an `as:Join`/`as:Leave` flow owned by + * the on-behalf backend, not a Refine `create` — so this binding throws on write. + * A member carries no link to its event: the reader knows the event because it read + * the collection from it, which is why `eventId` maps to `''` here. + * + * THE RESOURCE CONFIG CONFIGURES `types` ONLY. `types` wins over `shapeTreeUri` when + * both are set (the opposite of the configuration type's own documentation), so + * exactly one is configured, and `types` names the class directly. + * + * THE CONTEXT IS CALLER-SUPPLIED. `jsonContext` is sent as `@context` on every create + * and update and is what expands the configured CURIE types. The platform context + * (`https://activitypods.org/context.json`) is used because it defines the `as:` and + * `apods:` prefixes and its round-trip on a real pod is verified (concept + * `data-layer`, `cookbook_local-activitypods-platform`); the package default, the + * bare ActivityStreams context, does not define the `as:` prefix a CURIE needs. + * + * THE AUTH PROVIDER COMES WITH THE SET. The contract ships all three providers + * together, and the data/live providers take the auth provider as a parameter. Its + * `appStatusCheckInterval` is disabled deliberately: construction installs a + * `setInterval` and a `visibilitychange` listener with no teardown (usage + * declaration, friction 1), and Festipod drives `getAppStatus()` on its own + * schedule. The `clientId` names the app; the app description document it points to + * is Festipod's own to serve (a static file suffices) and lands with the Solid-OIDC + * flow, so until then the URL is derived from the origin this page runs on. + */ + +import { + authProvider as createApodsAuthProvider, + dataProvider as createApodsDataProvider, + liveProvider as createApodsLiveProvider, +} from '@activitypods/refine-providers'; +import type { FpEventData, FpMeetingPointData, FpParticipationData, FpUserData } from '../../data/types'; +import type { RecordBinding, RecordBindings, RefineBackend } from './types'; + +/** One JSON-LD record as the data provider returns it: predicates verbatim, values untyped. */ +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as JsonRecord) + : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** First string of a predicate that may hold a single value or an array of them. */ +function firstString(value: unknown): string | undefined { + return asString(value) ?? (Array.isArray(value) ? asString(value[0]) : undefined); +} + +/** + * The single identifier. `id` first, `@id` otherwise; a record carrying neither + * raises, per the contract's own rule. + */ +function recordId(record: unknown): string { + const r = asRecord(record); + const id = r ? (asString(r['id']) ?? asString(r['@id'])) : undefined; + if (!id) { + throw new Error('ActivityPods record carries neither id nor @id'); + } + return id; +} + +/** A linked value in an ActivityStreams record: a bare IRI string, or an object carrying id/@id. */ +function linkId(value: unknown): string | undefined { + return asString(value) ?? (() => { + const o = asRecord(value); + return o ? (asString(o['id']) ?? asString(o['@id'])) : undefined; + })(); +} + +/** A display value: a bare string, or a named object such as an `as:Place`. */ +function namedValue(value: unknown): string | undefined { + const direct = asString(value); + if (direct) return direct; + const o = asRecord(value); + return o ? asString(o['name']) ?? asString(o['url']) : undefined; +} + +/** A media value: a bare URL string, or an object such as an `as:Link` carrying url. */ +function urlValue(value: unknown): string | undefined { + const direct = asString(value); + if (direct) return direct; + const o = asRecord(value); + return o ? (asString(o['url']) ?? asString(o['href'])) : undefined; +} + +/** + * Compose the form's separate date and time fields into one AS2 `startTime`/`endTime` + * value. A date alone is kept as a date; a time alone is dropped — an `as:startTime` + * without a date is not a machine value any consumer can use, and the creation forms + * guarantee a date (submit is disabled without one). + */ +function composeDateTime(date: string | undefined, time: string | undefined): string | undefined { + if (date) return time ? `${date}T${time}` : date; + return undefined; +} + +/** The inverse of `composeDateTime`, tolerant of real-world datetimes (offsets, seconds). */ +function splitDateTime(value: unknown): { date?: string; time?: string } { + const s = asString(value); + if (!s) return {}; + const sep = s.indexOf('T'); + if (sep === -1) { + return /^\d{4}-\d{2}-\d{2}$/.test(s) ? { date: s } : {}; + } + const date = s.slice(0, sep); + const rest = s.slice(sep + 1); + const time = /^\d{2}:\d{2}/.test(rest) ? rest.slice(0, 5) : undefined; + return time ? { date, time } : { date }; +} + +/** The display date the forms compute: the start date, extended with the end date when it differs. */ +function dateLabel(startDate: string | undefined, endDate: string | undefined): string { + if (!startDate) return ''; + return endDate && endDate !== startDate ? `${startDate} - ${endDate}` : startDate; +} + +/** Initials for avatar display, derived from the display name — no AS2 slot exists for them. */ +function initialsOf(name: string): string { + const parts = name.trim().split(/\s+/).filter((p) => p.length > 0); + const first = parts[0]; + if (!first) return ''; + const last = parts.length > 1 ? parts[parts.length - 1] : undefined; + if (!last) return first.slice(0, 2).toUpperCase(); + return (first.slice(0, 1) + last.slice(0, 1)).toUpperCase(); +} + +/** + * The event field mapping, shared by `toValues` and `toPatch`: every mapped value is + * a flat scalar, so sending only the fields that changed is complete under the + * backend's shallow merge. + * NOT mapped, on purpose: `date` (a display label, re-derived from the machine dates + * on read), `participantCount` (derived from the participations at read time — never + * a stored, incremented counter), `distance` (per-viewer, not record data) and + * `themes` (no slot in the SHEX shape either). + */ +function eventValues(data: Partial): Record { + const out: Record = {}; + if (data.title !== undefined) out['name'] = data.title; + if (data.description !== undefined) out['content'] = data.description; + const startTime = composeDateTime(data.startDate, data.startTime); + if (startTime !== undefined) out['startTime'] = startTime; + const endTime = composeDateTime(data.endDate, data.endTime); + if (endTime !== undefined) out['endTime'] = endTime; + if (data.location !== undefined) out['location'] = data.location; + if (data.coverImage !== undefined) out['image'] = data.coverImage; + return out; +} + +function eventFromRecord(record: unknown): FpEventData { + const r = asRecord(record); + const start = splitDateTime(r?.['startTime']); + const end = splitDateTime(r?.['endTime']); + return { + id: recordId(record), + title: firstString(r?.['name']) ?? '', + description: firstString(r?.['content']) ?? '', + date: dateLabel(start.date, end.date), + startDate: start.date, + endDate: end.date, + startTime: start.time, + endTime: end.time, + location: namedValue(r?.['location']) ?? '', + // The count is derived by the reader from the event's participations; a freshly + // mapped event has been counted by nobody yet, so this is a placeholder the + // reader overwrites — never a value read off the record. + participantCount: 0, + coverImage: urlValue(r?.['image']), + }; +} + +/** + * The meeting point field mapping. A meeting point is an `as/Event` plus `as:context` + * pointing at the anchor event; `as:organizer` carries the host. + * NOT mapped, on purpose: `time` (a display string such as '30 min avant', which is + * not an AS2 datetime — unmapped until the creation flow produces one), `place` (AS2 + * has a single location slot, taken by `location`) and `inbox` (nothing provisions an + * inbox on this side yet — the on-behalf backend owns that when it lands). + */ +function meetingPointValues(data: Partial): Record { + const out: Record = {}; + if (data.title !== undefined) out['name'] = data.title; + if (data.description !== undefined) out['content'] = data.description; + if (data.location !== undefined) out['location'] = data.location; + if (data.hostId !== undefined) out['organizer'] = data.hostId; + if (data.eventId !== undefined) out['as:context'] = data.eventId; + return out; +} + +function meetingPointFromRecord(record: unknown): FpMeetingPointData { + const r = asRecord(record); + return { + id: recordId(record), + // A meeting point IS an `as/Event` with a context; one without it is an event, + // and the reader tells the two apart by this very field. + eventId: linkId(r?.['as:context']) ?? '', + hostId: linkId(r?.['organizer']), + title: firstString(r?.['name']), + description: firstString(r?.['content']), + location: namedValue(r?.['location']) ?? '', + time: '', + }; +} + +/** + * The profile field mapping. A profile is an `as/Profile`. + * NOT mapped, on purpose: `initials` (derived from the name on read), `role` (no AS2 + * slot), `isPublic` (visibility is enforced by the backend's ACLs, not stored on the + * record) and the three counts (derived by the reader). + */ +function profileValues(data: Partial): Record { + const out: Record = {}; + if (data.name !== undefined) out['name'] = data.name; + if (data.username !== undefined) out['preferredUsername'] = data.username; + if (data.bio !== undefined) out['summary'] = data.bio; + if (data.city !== undefined) out['location'] = data.city; + return out; +} + +function profileFromRecord(record: unknown): FpUserData { + const r = asRecord(record); + const name = firstString(r?.['name']) ?? ''; + return { + id: recordId(record), + name, + initials: initialsOf(name), + username: firstString(r?.['preferredUsername']) ?? '', + bio: firstString(r?.['summary']), + city: namedValue(r?.['location']), + }; +} + +/** + * One member of an event's `apods:attendees` collection, mapped to a participation. + * A member is a reference to the attendee: a bare URI when the reader may not + * dereference it, an object when the event read dereferenced it. Membership IS the + * attendance — a leave removes the member, so a member reads as confirmed. A member + * carries no link to its event (`eventId` is `''`; the reader knows the event it read + * the collection from) — the member shape is the one fact the pinned reconstruction + * does not document, recorded as friction 5 of the usage declaration. + */ +function participationFromMember(member: unknown): FpParticipationData { + const attendee = linkId(member); + if (!attendee) { + throw new Error('apods:attendees member carries no attendee reference'); + } + return { id: attendee, eventId: '', userId: attendee, isConfirmed: true }; +} + +function participationWrite(): never { + throw new Error( + 'participations are read-only through this binding: a join or leave is an as:Join/as:Leave flow owned by the on-behalf backend, not a Refine write', + ); +} + +const eventBinding: RecordBinding = { + recordKey: recordId, + domainId: recordId, + toDomain: eventFromRecord, + toValues: (data) => eventValues(data), + toPatch: (updates) => eventValues(updates), + updateReplaces: false, + resource: 'events', + visibility: 'public', +}; + +const meetingPointBinding: RecordBinding = { + recordKey: recordId, + domainId: recordId, + toDomain: meetingPointFromRecord, + toValues: (data) => meetingPointValues(data), + toPatch: (updates) => meetingPointValues(updates), + updateReplaces: false, + resource: 'meetingPoints', + // A meeting point hangs off a public event; who joins it is the protected fact, not the point itself. + visibility: 'public', +}; + +const profileBinding: RecordBinding = { + recordKey: recordId, + domainId: recordId, + toDomain: profileFromRecord, + toValues: (data) => profileValues(data), + toPatch: (updates) => profileValues(updates), + updateReplaces: false, + resource: 'profiles', + // The link between a participation and a person is protected: only a connection + // reading the profile can recognise who it is (functional-domain confidentiality). + visibility: 'protected', +}; + +const participationBinding: RecordBinding = { + recordKey: (member) => linkId(member) ?? participationWrite(), + domainId: (member) => linkId(member) ?? participationWrite(), + toDomain: participationFromMember, + toValues: participationWrite, + toPatch: participationWrite, + updateReplaces: false, + // Read through the events resource: the collection is embedded in the event record + // or fetched by `getOne` on the collection URI it carries — it is not a listed resource. + resource: 'events', + visibility: 'public', +}; + +const bindings: RecordBindings = { + event: eventBinding, + meetingPoint: meetingPointBinding, + profile: profileBinding, + participation: participationBinding, +}; + +/** + * The resource configuration handed to the package's providers, keyed by the binding + * resource names. `types` only — see the file header for why never `shapeTreeUri`. + */ +const APODS_RESOURCES = { + events: { types: 'as:Event' }, + meetingPoints: { types: 'as:Event' }, + profiles: { types: 'as:Profile' }, +}; + +/** The platform context: defines the `as:`/`apods:` prefixes and is verified round-tripping on a real pod. */ +const JSON_CONTEXT = 'https://activitypods.org/context.json'; + +export const activitypods: RefineBackend = { + name: 'activitypods', + build() { + const apodsAuth = createApodsAuthProvider({ + // The app description document this names is Festipod's own to serve — a static + // file suffices — and lands with the Solid-OIDC flow; until then the URL is the + // one this page can already answer for. + clientId: `${window.location.origin}/client.json`, + // Disabled deliberately: the construction-time status timer leaks (usage + // declaration, friction 1). Festipod drives getAppStatus() itself. + appStatusCheckInterval: false, + }); + return { + authProvider: apodsAuth, + dataProvider: createApodsDataProvider({ + authProvider: apodsAuth, + resources: APODS_RESOURCES, + jsonContext: JSON_CONTEXT, + }), + liveProvider: createApodsLiveProvider({ + authProvider: apodsAuth, + resources: APODS_RESOURCES, + jsonContext: JSON_CONTEXT, + }), + }; + }, + bindings, +}; diff --git a/src/shared/refine/providers/index.ts b/src/shared/refine/providers/index.ts index 1558a89..8198d5d 100644 --- a/src/shared/refine/providers/index.ts +++ b/src/shared/refine/providers/index.ts @@ -1,11 +1,6 @@ /** * The backend registry — which Refine backends exist, and which one this page runs. * - * IT IS EMPTY ON PURPOSE, and that is the current state of the work rather than - * an oversight. Festipod must end up supporting SEVERAL Refine backends, and the - * seam for that is what this directory is: the contract in `types.ts`, the - * selection below, the mounting in `RefineProvider`. What is missing is backends. - * * ONE VARIANT PER PAGE, NOT A SWITCH. A backend is selected by the URL — the * default one at the root, another through a path prefix naming it (`variant.ts`) * — so a page runs exactly one backend for its whole life, and changing backend @@ -17,10 +12,15 @@ * route (`events`, `profile`, `home`, `settings`, `users`) — registering one would * turn that route into a variant switch. Nothing checks it: the names are ours. * + * THE DEFAULT IS ACTIVITYPODS, per the directive of 2026-10-09 (concept + * `data-layer`, `brief_2026-10-09_activitypods-first-backend`). The registry holds + * exactly one backend until the suspended NextGraph+polyfill one resumes; the seam + * around it (selection, mounting, the binding contract) exists for that second. + * * WHY THE FIRST ONE WAS WITHDRAWN. `@ng-org/refine-providers` was wired and * measured, and it works as plain CRUD over SHEX shapes. It was withdrawn because - * it is not the target: what is coming is a Refine provider over NextGraph *plus - * the polyfill*, and hardening this one meanwhile buys nothing. + * it is not the target: ActivityPods was made the sole priority, and hardening the + * other one meanwhile buys nothing. * * NOT because it was incapable — an earlier version of this comment said so, and * was wrong. It blamed the absence of an inbox for the participant count, having @@ -32,16 +32,7 @@ * * The worked implementation is not in this branch's history: it lives on branch * `festipod-refine` (`providers/nextgraph.ts`, up to the commit that withdrew it), - * and is the reference for the next one's shape. - * - * WHAT COMES NEXT, and why the seam stays: that NextGraph+polyfill provider, and - * `@activitypods/refine-providers`. Festipod has to support both, so the structure - * is kept and only the entry is gone. - * - * ADDING ONE is a file implementing `RefineBackend` plus a line here. What that - * file must answer is in `types.ts` and cannot be guessed: how its providers are - * configured, what its records look like, which key addresses a record versus - * which id the app references, and whether its `update` replaces or patches. + * and is the reference for the shape of the one that resumes. * * WITH NO BACKEND the app runs in demo mode: `FestipodDataProvider` has nothing * to read a connected wallet through, and says so rather than mounting a data @@ -49,12 +40,14 @@ */ import { resolveVariant } from '../variant'; +import { activitypods } from './activitypods'; import type { RefineBackend } from './types'; export type { RecordBinding, RecordBindings, RefineBackend } from './types'; export { resourceNames } from './types'; -const BACKENDS: Record = {}; +/** The default first: `initialBackendName` falls back to the first registered name. */ +const BACKENDS: Record = { activitypods }; /** Every registered backend — what the variant links offer. */ export function listBackends(): RefineBackend[] {