The ActivityPods backend registers as the default behind the seam
This commit is contained in:
@@ -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: '',
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user