239 lines
7.9 KiB
TypeScript
239 lines
7.9 KiB
TypeScript
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<string, StoredTimeline>;
|
|
readonly slots: Map<string, StoredSlot>;
|
|
readonly sources: Map<string, StoredProviderSource>;
|
|
}
|
|
|
|
export function createMemoryTimelineStore(
|
|
options: { readonly now?: () => Date } = {},
|
|
): MemoryTimelineStore {
|
|
const now = options.now ?? (() => new Date());
|
|
const timelines = new Map<string, StoredTimeline>();
|
|
const slots = new Map<string, StoredSlot>();
|
|
const sources = new Map<string, StoredProviderSource>();
|
|
const usage = new Map<string, StoredTimelineUsage>();
|
|
|
|
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<void> {
|
|
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<void> {
|
|
sources.set(source.id, { ...source, createdAt: now() });
|
|
return Promise.resolve();
|
|
},
|
|
|
|
createTimeline(input: {
|
|
readonly timeline: NewStoredTimeline;
|
|
readonly sources: readonly NewStoredProviderSource[];
|
|
readonly slots: readonly NewStoredSlot[];
|
|
}): Promise<void> {
|
|
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<void> {
|
|
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<StoredTimelineDetail | undefined> {
|
|
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<readonly StoredTimeline[]> {
|
|
return Promise.resolve(
|
|
[...timelines.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
|
);
|
|
},
|
|
|
|
removeSlot(slotId: string): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<StoredTimelineUsage> {
|
|
return Promise.resolve(
|
|
usage.get(timelineId) ?? { activeGroups: 0, pendingGroups: 0, scheduledGroups: 0 },
|
|
);
|
|
},
|
|
|
|
timelines,
|
|
|
|
updateSlot(slotId: string, changes: SlotChanges): Promise<void> {
|
|
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<void> {
|
|
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();
|
|
},
|
|
};
|
|
}
|