The ActivityPods backend registers as the default behind the seam

This commit is contained in:
Sylvain Duchesne
2026-10-09 13:37:28 +02:00
parent 9823a45bab
commit e27cfa4e0a
4 changed files with 718 additions and 18 deletions
@@ -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<FpEventData, 'id'> = {
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: '',
});
});
+404
View File
@@ -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<string, unknown>;
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<FpEventData>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<FpMeetingPointData>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<FpUserData>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<FpEventData> = {
recordKey: recordId,
domainId: recordId,
toDomain: eventFromRecord,
toValues: (data) => eventValues(data),
toPatch: (updates) => eventValues(updates),
updateReplaces: false,
resource: 'events',
visibility: 'public',
};
const meetingPointBinding: RecordBinding<FpMeetingPointData> = {
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<FpUserData> = {
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<FpParticipationData> = {
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,
};
+11 -18
View File
@@ -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<string, RefineBackend> = {};
/** The default first: `initialBackendName` falls back to the first registered name. */
const BACKENDS: Record<string, RefineBackend> = { activitypods };
/** Every registered backend — what the variant links offer. */
export function listBackends(): RefineBackend[] {