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