import { by_lurid } from '@andyburke/fsdb/organizers'; import { FSDB_COLLECTION } from '@andyburke/fsdb'; import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; import { EMOJIS } from '../public/js/emojis/en.ts'; /** * @typedef {object} TIMESTAMPS * @property {string} created when the event was created * @property {string} updated when the event was last updated */ /** * Event * * @property {string} id - lurid * @property {string} creator_id - id of the source user * @property {string} type - event type * @property {string} [parent_id] - optional parent event id * @property {string} [channel] - optional channel * @property {string} [topic] - optional topic * @property {string[]} [tags] - optional tags * @property {Record} [data] - optional data payload of the event * @property {TIMESTAMPS} timestamps - timestamps that will be set by the server */ export type EVENT = { id: string; creator_id: string; type: string; parent_id?: string; channel?: string; topic?: string; tags?: string[]; data?: Record; timestamps: { created: string; updated: string; }; }; // TODO: separate out these different validators somewhere? export function VALIDATE_EVENT(event: EVENT) { const errors: any[] = []; const [type, id] = (event.id ?? '').split(':', 2); if (typeof type !== 'string' || type.length === 0) { errors.push({ cause: 'missing_event_type_in_id', message: 'An event must have a type that is also encoded into its id, eg: chat:able-fish-wife...' }); } if (typeof id !== 'string' || id.length !== 49) { errors.push({ cause: 'invalid_event_id', message: 'An event must have a type and a lurid id, eg: chat:able-fish-gold-wing-trip-form-seed-cost-rope-wife' }); } switch (event.type) { case 'chat': if (event.data?.message?.length <= 0) { errors.push({ cause: 'chat_message_missing', message: 'A chat message event cannot be empty.' }); } break; case 'post': if (event.data?.subject?.length <= 0) { errors.push({ cause: 'post_missing_subject', message: 'A post cannot have an empty subject.' }); } break; case 'blurb': if (event.data?.blurb?.length <= 0) { errors.push({ cause: 'blurb_missing', message: 'A blurb cannot be empty.' }); } else if (event.data?.blurb?.length > 2 ** 8) { errors.push({ cause: 'blurb_length_limit_exceeded', message: 'A blurb cannot be longer than 256 characters.' }); } break; case 'essay': if (event.data?.title?.length <= 0) { errors.push({ cause: 'essay_title_missing', message: 'An essay must have a title.' }); } else if (event.data?.title?.length > 2 ** 7) { errors.push({ cause: 'essay_title_length_limit_exceeded', message: 'An essay title cannot be longer than 128 characters.' }); } else if (event.data?.essay?.length <= 0) { errors.push({ cause: 'essay_missing', message: 'An essay cannot be empty.' }); } else if (event.data?.essay?.length > 2 ** 16) { errors.push({ cause: 'essay_length_limit_exceeded', message: 'An essay cannot be longer than 65536 characters - that would be a screed, which we do not yet support.' }); } break; case 'presence': break; case 'reaction': if (typeof event.parent_id !== 'string') { errors.push({ cause: 'reaction_missing_parent_id', message: 'A reaction must have a parent_id that refers to another event.' }); } else if (typeof event.data?.reaction !== 'string') { errors.push({ cause: 'reaction_must_be_an_emoji', message: 'A reaction event must have a `data.reaction` that is an emoji.' }); } else if (typeof EMOJIS.MAP[event.data?.reaction] === 'undefined') { errors.push({ cause: 'reaction_must_be_an_emoji', message: 'A reaction event must have a `data.reaction` that is an emoji.' }); } break; case 'test': if (Deno.env.get('DENO_ENV') !== 'test') { errors.push({ cause: 'unknown_event_type', message: 'Event types allowed: ' + ['chat', 'post', 'blurb', 'essay', 'reaction'].join(', ') }); } break; default: errors.push({ cause: 'unknown_event_type', message: 'Event types allowed: ' + ['chat', 'post', 'blurb', 'essay', 'reaction'].join(', ') }); break; } return errors.length ? errors : undefined; } const EVENT_ID_EXTRACTOR = /^(?.*):(?.*)$/; function smart_event_id_organizer(id: string) { const [event_type, event_id] = id.split(':', 2); const event_dirs = by_lurid(event_id).slice(0, -1); return [event_type, ...event_dirs, `${id}.json`]; } export const EVENTS = new FSDB_COLLECTION({ name: `events`, id_field: 'id', organize: (id) => { EVENT_ID_EXTRACTOR.lastIndex = 0; const groups: Record | undefined = EVENT_ID_EXTRACTOR.exec(id ?? '')?.groups; if (!groups) { throw new Error('Could not parse event id: ' + id); } const event_type = groups.event_type; const event_id = groups.event_id; return [ event_type, event_id.slice(0, 14), event_id.slice(0, 34), event_id, `${id}.json` ]; }, indexers: { creator_id: new FSDB_INDEXER_SYMLINKS({ name: 'creator_id', field: 'creator_id', to_many: true, organize: by_lurid }), parent_id: new FSDB_INDEXER_SYMLINKS({ name: 'parent_id', field: 'parent_id', to_many: true, organize: smart_event_id_organizer }), channel: new FSDB_INDEXER_SYMLINKS({ name: 'channel', field: 'channel', to_many: true, organize: (channel: string) => [channel], organize_id: smart_event_id_organizer }), topic: new FSDB_INDEXER_SYMLINKS({ name: 'topic', field: 'topic', to_many: true, organize: (topic: string) => [topic], organize_id: smart_event_id_organizer }), tags: new FSDB_INDEXER_SYMLINKS({ name: 'tags', get_values_to_index: (event: EVENT): string[] => { return (event.tags ?? []).map((tag: string) => tag.toLowerCase()); }, to_many: true, organize: (tag: string) => tag.length > 3 ? [tag.substring(0, 3), tag] : [tag], organize_id: smart_event_id_organizer }) } });