import { type NewStoredSelection, type PlaybackChanges, type SelectionChanges, type SignagePlaybackStore, SignageStoreConflict, type StoredChannel, type StoredChannelGroup, type StoredChannelGroupDetail, type StoredPlaybackState, type StoredSelection, } from "../../src/server/store/types.js"; /** * The playback half of the in-memory store. * * It enforces the guarantees the database enforces — one default channel that cannot be removed, a * sequence that is never handed out twice, variants replaced wholesale — so a decision proven here * holds against PostgreSQL too. */ export interface MemoryPlaybackStore extends SignagePlaybackStore { readonly groups: Map; readonly channels: Map; readonly playback: Map; readonly selections: Map; } export function createMemoryPlaybackStore( options: { readonly now?: () => Date } = {}, ): MemoryPlaybackStore { const now = options.now ?? (() => new Date()); const groups = new Map(); const channels = new Map(); const playback = new Map(); const selections = new Map(); function detail(groupId: string): StoredChannelGroupDetail | undefined { const group = groups.get(groupId); const state = playback.get(groupId); if (!group || !state) { return undefined; } return { channels: [...channels.values()] .filter((channel) => channel.groupId === groupId) .sort((left, right) => left.isDefault === right.isDefault ? left.key.localeCompare(right.key) : left.isDefault ? -1 : 1, ), group, playback: state, }; } return { addChannel(channel: StoredChannel): Promise { channels.set(channel.id, channel); const group = groups.get(channel.groupId); if (group) { groups.set(group.id, { ...group, revision: group.revision + 1 }); } return Promise.resolve(); }, channels, createChannelGroup(input: { readonly groupId: string; readonly name: string; readonly channelId: string; readonly channelName: string; }): Promise { groups.set(input.groupId, { id: input.groupId, name: input.name, revision: 1 }); channels.set(input.channelId, { groupId: input.groupId, id: input.channelId, isDefault: true, key: "default", name: input.channelName, }); playback.set(input.groupId, { activeTimelineId: null, blackout: false, currentSelectionId: null, currentSlotId: null, groupId: input.groupId, nextSequence: 1, paused: false, pausedRemainingMs: null, pendingRequestedAt: null, pendingTimelineId: null, revision: 1, scheduledActivationAt: null, scheduledTimelineId: null, slotDeadlineAt: null, slotStartedAt: null, staticAssetId: null, updatedAt: now(), }); return Promise.resolve(); }, deleteChannelGroup(groupId: string): Promise { groups.delete(groupId); playback.delete(groupId); for (const [id, channel] of channels) { if (channel.groupId === groupId) { channels.delete(id); } } for (const [id, selection] of selections) { if (selection.groupId === groupId) { selections.delete(id); } } return Promise.resolve(); }, findChannelGroup(groupId: string): Promise { return Promise.resolve(detail(groupId)); }, findLatestSelectionForScope( groupId: string, selectionScope: string, ): Promise { const matching = [...selections.values()] .filter( (selection) => selection.groupId === groupId && selection.selectionScope === selectionScope, ) .sort((left, right) => right.sequence - left.sequence); return Promise.resolve(matching[0]); }, findSelection(selectionId: string): Promise { return Promise.resolve(selections.get(selectionId)); }, groups, insertSelection(selection: NewStoredSelection): Promise { selections.set(selection.id, { ...selection, resolvedAt: now(), revision: 1 }); return Promise.resolve(); }, listChannelGroups(): Promise { return Promise.resolve( [...groups.values()] .sort((left, right) => left.name.localeCompare(right.name)) .map((group) => detail(group.id)) .filter((value): value is StoredChannelGroupDetail => value !== undefined), ); }, listSelections(groupId: string, limit: number): Promise { return Promise.resolve( [...selections.values()] .filter((selection) => selection.groupId === groupId) .sort((left, right) => right.sequence - left.sequence) .slice(0, limit), ); }, playback, pruneSelections(groupId: string, limit: number): Promise { const ordered = [...selections.values()] .filter((selection) => selection.groupId === groupId) .sort((left, right) => right.sequence - left.sequence); for (const selection of ordered.slice(limit)) { selections.delete(selection.id); } return Promise.resolve(); }, removeChannel(channelId: string): Promise { const channel = channels.get(channelId); if (!channel) { return Promise.resolve(); } if (channel.isDefault) { return Promise.reject(new SignageStoreConflict("channel.default")); } channels.delete(channelId); const group = groups.get(channel.groupId); if (group) { groups.set(group.id, { ...group, revision: group.revision + 1 }); } return Promise.resolve(); }, selections, takeSelectionSequence(groupId: string): Promise { const state = playback.get(groupId); if (!state) { return Promise.reject(new SignageStoreConflict("playback.sequence")); } playback.set(groupId, { ...state, nextSequence: state.nextSequence + 1 }); return Promise.resolve(state.nextSequence); }, updatePlayback( groupId: string, changes: PlaybackChanges, updateOptions?: { readonly bumpRevision?: boolean }, ): Promise { const state = playback.get(groupId); if (!state) { return Promise.resolve(); } const next: Record = { ...state }; for (const key of Object.keys(changes)) { next[key] = (changes as Record)[key] ?? null; } playback.set(groupId, { ...(next as unknown as StoredPlaybackState), revision: updateOptions?.bumpRevision === false ? state.revision : state.revision + 1, updatedAt: now(), }); return Promise.resolve(); }, updateSelection(selectionId: string, changes: SelectionChanges): Promise { const selection = selections.get(selectionId); if (!selection) { return Promise.resolve(); } selections.set(selectionId, { ...selection, ...(changes.availableUntil === undefined ? {} : { availableUntil: changes.availableUntil }), ...(changes.deadlineAt === undefined ? {} : { deadlineAt: changes.deadlineAt }), ...(changes.durationMs === undefined ? {} : { durationMs: changes.durationMs }), ...(changes.itemRevision === undefined ? {} : { itemRevision: changes.itemRevision }), ...(changes.presentation === undefined ? {} : { presentation: changes.presentation }), ...(changes.stale === undefined ? {} : { stale: changes.stale }), ...(changes.startedAt === undefined ? {} : { startedAt: changes.startedAt }), ...(changes.withdrawn === undefined ? {} : { withdrawn: changes.withdrawn }), ...(changes.variants === undefined ? {} : { variants: changes.variants }), revision: changes.bumpRevision === true ? selection.revision + 1 : selection.revision, }); return Promise.resolve(); }, }; }