Files
ontime/apps/server/src/services/rundown-service/rundownCache.ts
T
2024-03-28 13:35:36 +01:00

490 lines
13 KiB
TypeScript

import {
CustomField,
CustomFieldLabel,
CustomFields,
isOntimeDelay,
isOntimeEvent,
MaybeNumber,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
} from 'ontime-types';
import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
import { getTotalDuration } from '../timerUtils.js';
import { apply } from './delayUtils.js';
import { handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
type EventID = string;
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
let persistedRundown: OntimeRundown = [];
let persistedCustomFields: CustomFields = {};
/** Utility function gets to expose data */
export const getPersistedRundown = (): OntimeRundown => persistedRundown;
export const getCustomFields = (): CustomFields => persistedCustomFields;
let rundown: NormalisedRundown = {};
let order: EventID[] = [];
let revision = 0;
let isStale = true;
let totalDelay = 0;
let totalDuration = 0;
let firstStart: MaybeNumber = null;
let lastEnd: MaybeNumber = null;
let links: Record<EventID, EventID> = {};
/**
* Object that contains renamings to custom fields
* Used to rename the custom fields in the events
* @example
* {
* oldLabel: newLabel
* lighting: lx
* }
*/
const customFieldChangelog = {};
/**
* Keep track of which custom fields are used.
* This will be handy for when we delete custom fields
*/
let assignedCustomFields: Record<CustomFieldLabel, EventID[]> = {};
export async function init(initialRundown: Readonly<OntimeRundown>, customFields: Readonly<CustomFields>) {
persistedRundown = structuredClone(initialRundown) as OntimeRundown;
persistedCustomFields = structuredClone(customFields);
generate();
await DataProvider.setRundown(persistedRundown);
}
/**
* Utility initialises cache
* @param rundown
*/
export function generate(
initialRundown: OntimeRundown = persistedRundown,
customFields: CustomFields = persistedCustomFields,
) {
// we decided to re-write this dataset for every change
// instead of maintaining logic to update it
assignedCustomFields = {};
rundown = {};
order = [];
links = {};
firstStart = null;
lastEnd = null;
let accumulatedDelay = 0;
let daySpan = 0;
let previousEnd: number;
for (let i = 0; i < initialRundown.length; i++) {
const currentEvent = initialRundown[i];
const updatedEvent = { ...currentEvent };
if (isOntimeEvent(updatedEvent)) {
// 1. handle links
handleLink(i, initialRundown, updatedEvent, links);
// 2. handle custom fields
handleCustomField(customFields, customFieldChangelog, updatedEvent, assignedCustomFields);
// update the persisted event
initialRundown[i] = updatedEvent;
// update rundown duration
if (firstStart === null) {
firstStart = updatedEvent.timeStart;
}
lastEnd = updatedEvent.timeEnd;
// check if we go over midnight, account for eventual gaps
const gapOverMidnight = previousEnd > updatedEvent.timeStart;
const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
if (gapOverMidnight || durationOverMidnight) {
daySpan++;
}
}
// calculate delays
// !!! this must happen after handling the links
if (isOntimeDelay(updatedEvent)) {
accumulatedDelay += updatedEvent.duration;
} else if (isOntimeEvent(updatedEvent)) {
const eventStart = updatedEvent.timeStart;
// we only affect positive delays (time forwards)
if (accumulatedDelay > 0 && previousEnd) {
const gap = Math.max(eventStart - previousEnd, 0);
accumulatedDelay = Math.max(accumulatedDelay - gap, 0);
}
updatedEvent.delay = accumulatedDelay;
previousEnd = updatedEvent.timeEnd;
}
order.push(updatedEvent.id);
rundown[updatedEvent.id] = { ...updatedEvent };
}
isStale = false;
totalDelay = accumulatedDelay;
totalDuration = getTotalDuration(firstStart, lastEnd, daySpan);
return { rundown, order, links, totalDelay, totalDuration, assignedCustomProperties: assignedCustomFields };
}
/** Returns an ID guaranteed to be unique */
export function getUniqueId(): string {
if (isStale) {
generate();
}
let id = '';
do {
id = generateId();
} while (Object.hasOwn(rundown, id));
return id;
}
/** Returns index of an event with a given id */
export function getIndexOf(eventId: string) {
if (isStale) {
generate();
}
return order.indexOf(eventId);
}
type RundownCache = {
rundown: NormalisedRundown;
order: string[];
revision: number;
totalDelay: number;
totalDuration: number;
};
/**
* Returns cached data
* @returns {RundownCache}
*/
export function get(): Readonly<RundownCache> {
if (isStale) {
console.time('rundownCache__init');
generate();
console.timeEnd('rundownCache__init');
}
return {
rundown,
order,
revision,
totalDelay,
totalDuration,
};
}
/**
* Returns calculated metadata from rundown
*/
export function getMetadata() {
if (isStale) {
console.time('rundownCache__init');
generate();
console.timeEnd('rundownCache__init');
}
return {
firstStart,
lastEnd,
totalDelay,
totalDuration,
};
}
type CommonParams = { persistedRundown: OntimeRundown };
type MutationParams<T> = T & CommonParams;
type MutatingReturn = {
newRundown: OntimeRundown;
newEvent?: OntimeRundownEntry;
};
type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn;
/**
* Decorators injects data into mutation
* @param mutation
* @returns
*/
export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
async function scopedMutation(params: T) {
/**
* Marking the data set as stale
* doing it before calling the mutation, gives the function a chance
* to prevent recalculation by setting stale = false
*/
isStale = true;
const { newEvent, newRundown } = mutation({ ...params, persistedRundown });
revision = revision + 1;
persistedRundown = newRundown;
// schedule a non priority cache update
setImmediate(() => {
console.time('rundownCache__init');
get();
console.timeEnd('rundownCache__init');
});
// defer writing to the database
setImmediate(() => {
DataProvider.setRundown(persistedRundown);
});
return { newEvent };
}
return scopedMutation;
}
type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>;
export function add({ persistedRundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
const newEvent: OntimeRundownEntry = { ...event };
const newRundown = insertAtIndex(atIndex, newEvent, persistedRundown);
return { newRundown, newEvent };
}
type RemoveArgs = MutationParams<{ eventId: string }>;
export function remove({ persistedRundown, eventId }: RemoveArgs): MutatingReturn {
const atIndex = persistedRundown.findIndex((event) => event.id === eventId);
const newRundown = deleteAtIndex(atIndex, persistedRundown);
return { newRundown };
}
export function removeAll(): { newRundown: OntimeRundown } {
return { newRundown: [] };
}
/**
* Utility function for patching events
* @param eventFromRundown
* @param patch
* @returns
*/
function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRundownEntry>): OntimeRundownEntry {
if (isOntimeEvent(eventFromRundown)) {
const newEvent = createPatch(eventFromRundown, patch as OntimeEvent);
newEvent.revision++;
return newEvent;
}
// TODO: exhaustive check
return { ...eventFromRundown, ...patch } as OntimeRundownEntry;
}
type EditArgs = MutationParams<{ eventId: string; patch: Partial<OntimeRundownEntry> }>;
export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<MutatingReturn> {
const indexAt = persistedRundown.findIndex((event) => event.id === eventId);
if (indexAt < 0) {
throw new Error('Event not found');
}
if (patch?.type && persistedRundown[indexAt].type !== patch.type) {
throw new Error('Invalid event type');
}
const eventInMemory = persistedRundown[indexAt];
if (!hasChanges(eventInMemory, patch)) {
isStale = false;
return;
}
const newEvent = makeEvent(eventInMemory, patch);
const newRundown = [...persistedRundown];
newRundown[indexAt] = newEvent;
const makeStale = isDataStale(patch);
if (!makeStale) {
rundown[newEvent.id] = newEvent;
}
isStale = makeStale;
return { newRundown, newEvent };
}
type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>;
export function batchEdit({ persistedRundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
const ids = new Set(eventIds);
const newRundown = [];
for (let i = 0; i < persistedRundown.length; i++) {
if (ids.has(persistedRundown[i].id)) {
if (patch?.type && persistedRundown[i].type !== patch.type) {
continue;
}
const newEvent = makeEvent(persistedRundown[i], patch);
newRundown.push(newEvent);
} else {
newRundown.push(persistedRundown[i]);
}
}
return { newRundown };
}
type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>;
export function reorder({ persistedRundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
const event = persistedRundown[from];
if (!event || eventId !== event.id) {
throw new Error('Event not found');
}
const newRundown = reorderArray(persistedRundown, from, to);
for (let i = from; i <= to; i++) {
const event = newRundown.at(i);
if (isOntimeEvent(event)) {
event.revision += 1;
}
}
return { newRundown, newEvent: newRundown.at(from) };
}
type ApplyDelayArgs = MutationParams<{ eventId: string }>;
export function applyDelay({ persistedRundown, eventId }: ApplyDelayArgs): MutatingReturn {
const newRundown = apply(eventId, persistedRundown);
return { newRundown };
}
type SwapArgs = MutationParams<{ fromId: string; toId: string }>;
export function swap({ persistedRundown, fromId, toId }: SwapArgs): MutatingReturn {
const indexA = persistedRundown.findIndex((event) => event.id === fromId);
const eventA = persistedRundown.at(indexA);
const indexB = persistedRundown.findIndex((event) => event.id === toId);
const eventB = persistedRundown.at(indexB);
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
throw new Error('Swap only available for OntimeEvents');
}
const { newA, newB } = swapEventData(eventA, eventB);
const newRundown = [...persistedRundown];
newRundown[indexA] = newA;
(newRundown[indexA] as OntimeEvent).revision += 1;
newRundown[indexB] = newB;
(newRundown[indexB] as OntimeEvent).revision += 1;
return { newRundown };
}
/**
* Invalidates service cache if a custom field is used
* @param label
*/
function invalidateIfUsed(label: CustomFieldLabel) {
if (label in assignedCustomFields) {
isStale = true;
}
// if the field was in use, we mark the cache as stale
if (label in assignedCustomFields) {
isStale = true;
}
// ... and schedule a cache update
// schedule a non priority cache update
setImmediate(() => {
console.time('rundownCache__init');
generate();
console.timeEnd('rundownCache__init');
});
}
/**
* Scheduløes a non priority custom field persist
* @param persistedCustomFields
*/
function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) {
setImmediate(() => {
DataProvider.setCustomFields(persistedCustomFields);
});
}
/**
* Sanitises and creates a custom field in the database
* @param field
* @returns
*/
export const createCustomField = async (field: CustomField) => {
const { label, type, colour } = field;
// check if label already exists
const alreadyExists = Object.hasOwn(persistedCustomFields, label);
if (alreadyExists) {
throw new Error('Label already exists');
}
// update object and persist
persistedCustomFields[label] = { label, type, colour };
scheduleCustomFieldPersist(persistedCustomFields);
return persistedCustomFields;
};
/**
* Edits an existing custom field in the database
* @param label
* @param newField
* @returns
*/
export const editCustomField = async (label: string, newField: Partial<CustomField>) => {
if (!(label in persistedCustomFields)) {
throw new Error('Could not find label');
}
const existingField = persistedCustomFields[label];
if (existingField.type !== newField.type) {
throw new Error('Change of field type is not allowed');
}
persistedCustomFields[newField.label] = { ...existingField, ...newField };
if (existingField.label !== newField.label) {
delete persistedCustomFields[existingField.label];
customFieldChangelog[label] = newField.label;
}
scheduleCustomFieldPersist(persistedCustomFields);
invalidateIfUsed(label);
return persistedCustomFields;
};
/**
* Deletes a custom field from the database
* @param label
*/
export const removeCustomField = async (label: string) => {
if (label in persistedCustomFields) {
delete persistedCustomFields[label];
}
scheduleCustomFieldPersist(persistedCustomFields);
invalidateIfUsed(label);
return persistedCustomFields;
};