feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||
import { asc, eq, or, sql } from "drizzle-orm";
|
||||
import { playbackStates, providerSources, timelineSlots, timelines } from "../../schema/tables.js";
|
||||
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 "./types.js";
|
||||
|
||||
/**
|
||||
* The timeline half of Signage's production store.
|
||||
*
|
||||
* Two invariants live here rather than in the services above, because they are only safe if they are
|
||||
* transactional:
|
||||
*
|
||||
* - **Slot positions stay unique and contiguous from zero.** Every operation that can change the
|
||||
* order rewrites the whole order inside one transaction, so a gap can never be observed — not even
|
||||
* briefly, by a reconciliation running at the same moment.
|
||||
* - **A timeline's revision increments exactly once per semantic change**, in the same transaction
|
||||
* as the change, so a client that compares revisions can never see a changed timeline at an
|
||||
* unchanged revision.
|
||||
*/
|
||||
|
||||
type TimelineRow = typeof timelines.$inferSelect;
|
||||
type SlotRow = typeof timelineSlots.$inferSelect;
|
||||
type SourceRow = typeof providerSources.$inferSelect;
|
||||
|
||||
function toTimeline(row: TimelineRow): StoredTimeline {
|
||||
return {
|
||||
createdAt: row.createdAt,
|
||||
description: row.description,
|
||||
eligibleFrom: row.eligibleFrom,
|
||||
eligibleUntil: row.eligibleUntil,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
revision: row.revision,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toSource(row: SourceRow): StoredProviderSource {
|
||||
return {
|
||||
createdAt: row.createdAt,
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
providerId: row.providerId,
|
||||
selectionScope: row.selectionScope,
|
||||
sourceId: row.sourceId,
|
||||
timelineId: row.timelineId,
|
||||
};
|
||||
}
|
||||
|
||||
function toSlot(row: SlotRow): StoredSlot {
|
||||
return {
|
||||
durationOverrideMs: row.durationOverrideMs,
|
||||
eligibleFrom: row.eligibleFrom,
|
||||
eligibleUntil: row.eligibleUntil,
|
||||
enabled: row.enabled,
|
||||
id: row.id,
|
||||
itemId: row.itemId,
|
||||
kind: row.kind === "pinned-item" ? "pinned-item" : "provider-source",
|
||||
position: row.position,
|
||||
presentation: {
|
||||
backgroundColor: row.backgroundColor,
|
||||
backgroundDarkened: row.backgroundDarkened,
|
||||
backgroundMode:
|
||||
row.backgroundMode === "blurred" ||
|
||||
row.backgroundMode === "solid" ||
|
||||
row.backgroundMode === "black"
|
||||
? row.backgroundMode
|
||||
: null,
|
||||
blurPixels: row.blurPixels,
|
||||
foregroundShadow: row.foregroundShadow,
|
||||
},
|
||||
providerId: row.providerId,
|
||||
sourceId: row.sourceId,
|
||||
timelineId: row.timelineId,
|
||||
};
|
||||
}
|
||||
|
||||
function slotValues(slot: NewStoredSlot, position: number) {
|
||||
return {
|
||||
backgroundColor: slot.presentation.backgroundColor,
|
||||
backgroundDarkened: slot.presentation.backgroundDarkened,
|
||||
backgroundMode: slot.presentation.backgroundMode,
|
||||
blurPixels: slot.presentation.blurPixels,
|
||||
durationOverrideMs: slot.durationOverrideMs,
|
||||
eligibleFrom: slot.eligibleFrom,
|
||||
eligibleUntil: slot.eligibleUntil,
|
||||
enabled: slot.enabled,
|
||||
foregroundShadow: slot.presentation.foregroundShadow,
|
||||
id: slot.id,
|
||||
itemId: slot.itemId,
|
||||
kind: slot.kind,
|
||||
position,
|
||||
providerId: slot.providerId,
|
||||
sourceId: slot.sourceId,
|
||||
timelineId: slot.timelineId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTimelineStore(database: ModuleDatabase): SignageTimelineStore {
|
||||
/**
|
||||
* Rewrites `position` for one timeline from an explicit order.
|
||||
*
|
||||
* Positions are written in two passes, through a temporary negative range, because the unique
|
||||
* `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while
|
||||
* another still holds 1 would collide. Negative positions are outside every real value, so the
|
||||
* intermediate state cannot be mistaken for an order.
|
||||
*/
|
||||
async function renumber(
|
||||
transaction: ModuleDatabase,
|
||||
timelineId: string,
|
||||
orderedSlotIds: readonly string[],
|
||||
): Promise<void> {
|
||||
for (const [index, slotId] of orderedSlotIds.entries()) {
|
||||
await transaction
|
||||
.update(timelineSlots)
|
||||
.set({ position: -1 - index })
|
||||
.where(eq(timelineSlots.id, slotId));
|
||||
}
|
||||
for (const [index, slotId] of orderedSlotIds.entries()) {
|
||||
await transaction
|
||||
.update(timelineSlots)
|
||||
.set({ position: index, updatedAt: new Date() })
|
||||
.where(eq(timelineSlots.id, slotId));
|
||||
}
|
||||
await bumpRevision(transaction, timelineId);
|
||||
}
|
||||
|
||||
async function bumpRevision(transaction: ModuleDatabase, timelineId: string): Promise<void> {
|
||||
await transaction
|
||||
.update(timelines)
|
||||
.set({ revision: sql`${timelines.revision} + 1`, updatedAt: new Date() })
|
||||
.where(eq(timelines.id, timelineId));
|
||||
}
|
||||
|
||||
async function orderedSlotIdsFor(
|
||||
transaction: ModuleDatabase,
|
||||
timelineId: string,
|
||||
): Promise<string[]> {
|
||||
const rows = await transaction
|
||||
.select({ id: timelineSlots.id, position: timelineSlots.position })
|
||||
.from(timelineSlots)
|
||||
.where(eq(timelineSlots.timelineId, timelineId))
|
||||
.orderBy(asc(timelineSlots.position));
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async function timelineIdForSlot(
|
||||
transaction: ModuleDatabase,
|
||||
slotId: string,
|
||||
): Promise<string | undefined> {
|
||||
const rows = await transaction
|
||||
.select({ timelineId: timelineSlots.timelineId })
|
||||
.from(timelineSlots)
|
||||
.where(eq(timelineSlots.id, slotId));
|
||||
return rows[0]?.timelineId;
|
||||
}
|
||||
|
||||
return {
|
||||
async addSlot(slot: NewStoredSlot, position?: number): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const existing = await orderedSlotIdsFor(transaction, slot.timelineId);
|
||||
const index =
|
||||
position === undefined
|
||||
? existing.length
|
||||
: Math.max(0, Math.min(position, existing.length));
|
||||
await transaction.insert(timelineSlots).values(slotValues(slot, -1_000_000));
|
||||
const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)];
|
||||
await renumber(transaction, slot.timelineId, next);
|
||||
});
|
||||
},
|
||||
|
||||
async addSource(source: NewStoredProviderSource): Promise<void> {
|
||||
await database.insert(providerSources).values({
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
providerId: source.providerId,
|
||||
selectionScope: source.selectionScope,
|
||||
sourceId: source.sourceId,
|
||||
timelineId: source.timelineId,
|
||||
});
|
||||
},
|
||||
|
||||
async createTimeline(input: {
|
||||
readonly timeline: NewStoredTimeline;
|
||||
readonly sources: readonly NewStoredProviderSource[];
|
||||
readonly slots: readonly NewStoredSlot[];
|
||||
}): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction.insert(timelines).values({
|
||||
description: input.timeline.description,
|
||||
eligibleFrom: input.timeline.eligibleFrom,
|
||||
eligibleUntil: input.timeline.eligibleUntil,
|
||||
id: input.timeline.id,
|
||||
name: input.timeline.name,
|
||||
});
|
||||
if (input.sources.length > 0) {
|
||||
await transaction.insert(providerSources).values(
|
||||
input.sources.map((source) => ({
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
providerId: source.providerId,
|
||||
selectionScope: source.selectionScope,
|
||||
sourceId: source.sourceId,
|
||||
timelineId: source.timelineId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (input.slots.length > 0) {
|
||||
await transaction
|
||||
.insert(timelineSlots)
|
||||
.values(input.slots.map((slot, index) => slotValues(slot, index)));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async deleteTimeline(timelineId: string): Promise<void> {
|
||||
// Sources and slots cascade with the timeline; resolved selections do not, because history is
|
||||
// a record of what a screen actually showed.
|
||||
await database.delete(timelines).where(eq(timelines.id, timelineId));
|
||||
},
|
||||
|
||||
async findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined> {
|
||||
const timelineRows = await database
|
||||
.select()
|
||||
.from(timelines)
|
||||
.where(eq(timelines.id, timelineId));
|
||||
const row = timelineRows[0];
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
const [sourceRows, slotRows] = await Promise.all([
|
||||
database
|
||||
.select()
|
||||
.from(providerSources)
|
||||
.where(eq(providerSources.timelineId, timelineId))
|
||||
.orderBy(asc(providerSources.createdAt)),
|
||||
database
|
||||
.select()
|
||||
.from(timelineSlots)
|
||||
.where(eq(timelineSlots.timelineId, timelineId))
|
||||
.orderBy(asc(timelineSlots.position)),
|
||||
]);
|
||||
return {
|
||||
slots: slotRows.map(toSlot),
|
||||
sources: sourceRows.map(toSource),
|
||||
timeline: toTimeline(row),
|
||||
};
|
||||
},
|
||||
|
||||
async listTimelines(): Promise<readonly StoredTimeline[]> {
|
||||
const rows = await database.select().from(timelines).orderBy(asc(timelines.name));
|
||||
return rows.map(toTimeline);
|
||||
},
|
||||
|
||||
async removeSlot(slotId: string): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const timelineId = await timelineIdForSlot(transaction, slotId);
|
||||
if (timelineId === undefined) {
|
||||
return;
|
||||
}
|
||||
await transaction.delete(timelineSlots).where(eq(timelineSlots.id, slotId));
|
||||
await renumber(transaction, timelineId, await orderedSlotIdsFor(transaction, timelineId));
|
||||
});
|
||||
},
|
||||
|
||||
async removeSource(sourceId: string): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const rows = await transaction
|
||||
.select({ timelineId: providerSources.timelineId })
|
||||
.from(providerSources)
|
||||
.where(eq(providerSources.id, sourceId));
|
||||
const timelineId = rows[0]?.timelineId;
|
||||
if (timelineId === undefined) {
|
||||
return;
|
||||
}
|
||||
// Slots referencing the source cascade with it; the remainder is renumbered so the pattern
|
||||
// an operator reads never has a hole in it.
|
||||
await transaction.delete(providerSources).where(eq(providerSources.id, sourceId));
|
||||
await renumber(transaction, timelineId, await orderedSlotIdsFor(transaction, timelineId));
|
||||
});
|
||||
},
|
||||
|
||||
async reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const current = await orderedSlotIdsFor(transaction, timelineId);
|
||||
const given = new Set(orderedSlotIds);
|
||||
// Exhaustive and exact: anything else could lose a slot, duplicate one, or leave a gap.
|
||||
if (given.size !== orderedSlotIds.length || given.size !== current.length) {
|
||||
throw new SignageStoreConflict("timeline.slotOrder");
|
||||
}
|
||||
for (const slotId of current) {
|
||||
if (!given.has(slotId)) {
|
||||
throw new SignageStoreConflict("timeline.slotOrder");
|
||||
}
|
||||
}
|
||||
await renumber(transaction, timelineId, orderedSlotIds);
|
||||
});
|
||||
},
|
||||
|
||||
async timelineUsage(timelineId: string): Promise<StoredTimelineUsage> {
|
||||
const rows = await database
|
||||
.select({
|
||||
activeTimelineId: playbackStates.activeTimelineId,
|
||||
pendingTimelineId: playbackStates.pendingTimelineId,
|
||||
scheduledTimelineId: playbackStates.scheduledTimelineId,
|
||||
})
|
||||
.from(playbackStates)
|
||||
.where(
|
||||
or(
|
||||
eq(playbackStates.activeTimelineId, timelineId),
|
||||
eq(playbackStates.pendingTimelineId, timelineId),
|
||||
eq(playbackStates.scheduledTimelineId, timelineId),
|
||||
),
|
||||
);
|
||||
return {
|
||||
activeGroups: rows.filter((row) => row.activeTimelineId === timelineId).length,
|
||||
pendingGroups: rows.filter((row) => row.pendingTimelineId === timelineId).length,
|
||||
scheduledGroups: rows.filter((row) => row.scheduledTimelineId === timelineId).length,
|
||||
};
|
||||
},
|
||||
|
||||
async updateSlot(slotId: string, changes: SlotChanges): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
const timelineId = await timelineIdForSlot(transaction, slotId);
|
||||
if (timelineId === undefined) {
|
||||
return;
|
||||
}
|
||||
const values: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (changes.enabled !== undefined) {
|
||||
values.enabled = changes.enabled;
|
||||
}
|
||||
if (changes.durationOverrideMs !== undefined) {
|
||||
values.durationOverrideMs = changes.durationOverrideMs;
|
||||
}
|
||||
if (changes.eligibleFrom !== undefined) {
|
||||
values.eligibleFrom = changes.eligibleFrom;
|
||||
}
|
||||
if (changes.eligibleUntil !== undefined) {
|
||||
values.eligibleUntil = changes.eligibleUntil;
|
||||
}
|
||||
if (changes.itemId !== undefined) {
|
||||
values.itemId = changes.itemId;
|
||||
}
|
||||
const presentation = changes.presentation;
|
||||
if (presentation) {
|
||||
if (presentation.backgroundMode !== undefined) {
|
||||
values.backgroundMode = presentation.backgroundMode;
|
||||
}
|
||||
if (presentation.backgroundColor !== undefined) {
|
||||
values.backgroundColor = presentation.backgroundColor;
|
||||
}
|
||||
if (presentation.backgroundDarkened !== undefined) {
|
||||
values.backgroundDarkened = presentation.backgroundDarkened;
|
||||
}
|
||||
if (presentation.blurPixels !== undefined) {
|
||||
values.blurPixels = presentation.blurPixels;
|
||||
}
|
||||
if (presentation.foregroundShadow !== undefined) {
|
||||
values.foregroundShadow = presentation.foregroundShadow;
|
||||
}
|
||||
}
|
||||
await transaction.update(timelineSlots).set(values).where(eq(timelineSlots.id, slotId));
|
||||
await bumpRevision(transaction, timelineId);
|
||||
});
|
||||
},
|
||||
|
||||
async updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void> {
|
||||
const values: Record<string, unknown> = {
|
||||
revision: sql`${timelines.revision} + 1`,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (changes.name !== undefined) {
|
||||
values.name = changes.name;
|
||||
}
|
||||
if (changes.description !== undefined) {
|
||||
values.description = changes.description;
|
||||
}
|
||||
if (changes.eligibleFrom !== undefined) {
|
||||
values.eligibleFrom = changes.eligibleFrom;
|
||||
}
|
||||
if (changes.eligibleUntil !== undefined) {
|
||||
values.eligibleUntil = changes.eligibleUntil;
|
||||
}
|
||||
await database.update(timelines).set(values).where(eq(timelines.id, timelineId));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user