feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+238
View File
@@ -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();
},
};
}