import { type NewStoredProviderSource, type NewStoredSlot, type NewStoredTimeline, SignageStoreConflict, type SignageTimelineStore, type SlotChanges, type StoredProviderSource, type StoredSlot, type StoredTimeline, type StoredTimelineDetail, type StoredTimelineUsage, type TimelineChanges, } from "../../src/server/store/types.js"; /** * The timeline half of the in-memory store. * * It enforces the same invariants the database does — contiguous positions, an exhaustive reorder, * a revision that increments once per semantic change — so a test cannot pass here and fail against * PostgreSQL. Where the production store uses a transaction, this uses the fact that JavaScript does * not interleave synchronous work; the property being proven is the same. */ export interface MemoryTimelineStore extends SignageTimelineStore { /** Test-only: pretend a channel group has this timeline active, pending, or scheduled. */ setTimelineUsage(timelineId: string, usage: StoredTimelineUsage): void; readonly timelines: Map; readonly slots: Map; readonly sources: Map; } export function createMemoryTimelineStore( options: { readonly now?: () => Date } = {}, ): MemoryTimelineStore { const now = options.now ?? (() => new Date()); const timelines = new Map(); const slots = new Map(); const sources = new Map(); const usage = new Map(); function slotsOf(timelineId: string): StoredSlot[] { return [...slots.values()] .filter((slot) => slot.timelineId === timelineId) .sort((left, right) => left.position - right.position); } function bump(timelineId: string): void { const timeline = timelines.get(timelineId); if (timeline) { timelines.set(timelineId, { ...timeline, revision: timeline.revision + 1, updatedAt: now(), }); } } function renumber(timelineId: string, orderedSlotIds: readonly string[]): void { for (const [index, slotId] of orderedSlotIds.entries()) { const slot = slots.get(slotId); if (slot) { slots.set(slotId, { ...slot, position: index }); } } bump(timelineId); } return { addSlot(slot: NewStoredSlot, position?: number): Promise { const existing = slotsOf(slot.timelineId).map((entry) => entry.id); const index = position === undefined ? existing.length : Math.max(0, Math.min(position, existing.length)); slots.set(slot.id, { ...slot, position: -1 }); renumber(slot.timelineId, [...existing.slice(0, index), slot.id, ...existing.slice(index)]); return Promise.resolve(); }, addSource(source: NewStoredProviderSource): Promise { sources.set(source.id, { ...source, createdAt: now() }); return Promise.resolve(); }, createTimeline(input: { readonly timeline: NewStoredTimeline; readonly sources: readonly NewStoredProviderSource[]; readonly slots: readonly NewStoredSlot[]; }): Promise { const createdAt = now(); timelines.set(input.timeline.id, { ...input.timeline, createdAt, revision: 1, updatedAt: createdAt, }); for (const source of input.sources) { sources.set(source.id, { ...source, createdAt }); } for (const [index, slot] of input.slots.entries()) { slots.set(slot.id, { ...slot, position: index }); } return Promise.resolve(); }, deleteTimeline(timelineId: string): Promise { timelines.delete(timelineId); for (const [id, slot] of slots) { if (slot.timelineId === timelineId) { slots.delete(id); } } for (const [id, source] of sources) { if (source.timelineId === timelineId) { sources.delete(id); } } return Promise.resolve(); }, findTimeline(timelineId: string): Promise { const timeline = timelines.get(timelineId); if (!timeline) { return Promise.resolve(undefined); } return Promise.resolve({ slots: slotsOf(timelineId), sources: [...sources.values()] .filter((source) => source.timelineId === timelineId) .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()), timeline, }); }, listTimelines(): Promise { return Promise.resolve( [...timelines.values()].sort((left, right) => left.name.localeCompare(right.name)), ); }, removeSlot(slotId: string): Promise { const slot = slots.get(slotId); if (!slot) { return Promise.resolve(); } slots.delete(slotId); renumber( slot.timelineId, slotsOf(slot.timelineId).map((entry) => entry.id), ); return Promise.resolve(); }, removeSource(sourceId: string): Promise { const source = sources.get(sourceId); if (!source) { return Promise.resolve(); } sources.delete(sourceId); for (const [id, slot] of slots) { if (slot.sourceId === sourceId) { slots.delete(id); } } renumber( source.timelineId, slotsOf(source.timelineId).map((entry) => entry.id), ); return Promise.resolve(); }, reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise { const current = slotsOf(timelineId).map((slot) => slot.id); const given = new Set(orderedSlotIds); if (given.size !== orderedSlotIds.length || given.size !== current.length) { return Promise.reject(new SignageStoreConflict("timeline.slotOrder")); } for (const slotId of current) { if (!given.has(slotId)) { return Promise.reject(new SignageStoreConflict("timeline.slotOrder")); } } renumber(timelineId, orderedSlotIds); return Promise.resolve(); }, setTimelineUsage(timelineId: string, value: StoredTimelineUsage): void { usage.set(timelineId, value); }, slots, sources, timelineUsage(timelineId: string): Promise { return Promise.resolve( usage.get(timelineId) ?? { activeGroups: 0, pendingGroups: 0, scheduledGroups: 0 }, ); }, timelines, updateSlot(slotId: string, changes: SlotChanges): Promise { const slot = slots.get(slotId); if (!slot) { return Promise.resolve(); } slots.set(slotId, { ...slot, ...(changes.enabled === undefined ? {} : { enabled: changes.enabled }), ...(changes.durationOverrideMs === undefined ? {} : { durationOverrideMs: changes.durationOverrideMs }), ...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }), ...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }), ...(changes.itemId === undefined ? {} : { itemId: changes.itemId }), presentation: { ...slot.presentation, ...(changes.presentation ?? {}) }, }); bump(slot.timelineId); return Promise.resolve(); }, updateTimeline(timelineId: string, changes: TimelineChanges): Promise { const timeline = timelines.get(timelineId); if (!timeline) { return Promise.resolve(); } timelines.set(timelineId, { ...timeline, ...(changes.name === undefined ? {} : { name: changes.name }), ...(changes.description === undefined ? {} : { description: changes.description }), ...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }), ...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }), revision: timeline.revision + 1, updatedAt: now(), }); return Promise.resolve(); }, }; }