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
+249
View File
@@ -0,0 +1,249 @@
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<string, StoredChannelGroup>;
readonly channels: Map<string, StoredChannel>;
readonly playback: Map<string, StoredPlaybackState>;
readonly selections: Map<string, StoredSelection>;
}
export function createMemoryPlaybackStore(
options: { readonly now?: () => Date } = {},
): MemoryPlaybackStore {
const now = options.now ?? (() => new Date());
const groups = new Map<string, StoredChannelGroup>();
const channels = new Map<string, StoredChannel>();
const playback = new Map<string, StoredPlaybackState>();
const selections = new Map<string, StoredSelection>();
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<void> {
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<void> {
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<void> {
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<StoredChannelGroupDetail | undefined> {
return Promise.resolve(detail(groupId));
},
findLatestSelectionForScope(
groupId: string,
selectionScope: string,
): Promise<StoredSelection | undefined> {
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<StoredSelection | undefined> {
return Promise.resolve(selections.get(selectionId));
},
groups,
insertSelection(selection: NewStoredSelection): Promise<void> {
selections.set(selection.id, { ...selection, resolvedAt: now(), revision: 1 });
return Promise.resolve();
},
listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]> {
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<readonly StoredSelection[]> {
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<void> {
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<void> {
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<number> {
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<void> {
const state = playback.get(groupId);
if (!state) {
return Promise.resolve();
}
const next: Record<string, unknown> = { ...state };
for (const key of Object.keys(changes)) {
next[key] = (changes as Record<string, unknown>)[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<void> {
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();
},
};
}