feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user