convert to and from database type (#1410)

* create db event type

* dont use it yet

* convert to and from database type

* type needed in one place in client

* use flatMap to filter out unknow stuff

* leave revision in save file

* move to utils

* make test

* destructur entry

* simple test

* throw in rundownToDatabaseRundown

* clerify comment

* Refactor: avoid shadowing the rundown variable in rundownCache (#1409)


---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* rename to DAO

* add simple edit test

* only the generate function clears the state state

* strict guard for custom fields

* fix small strict issues

* ensure deep copy

* safely set isStale

* todo is done

* small type error

* these functions can work on DAO entries

* only reat rundown as DAO on initial load and the cast immediately and esure all fields are present

* eslint comment

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* fix spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* fix spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* comment not needed

* setIsStale

* remove DAO

* clean up commits

* remove unneeded async/await

* revert test

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
This commit is contained in:
Alex Christoffer Rasmussen
2025-01-05 14:35:58 +01:00
committed by GitHub
parent 1b62ed5f2d
commit 533f9798e8
12 changed files with 195 additions and 130 deletions
@@ -2,6 +2,7 @@ import {
CustomField,
CustomFieldLabel,
CustomFields,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
isPlayableEvent,
@@ -31,14 +32,27 @@ type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
let persistedRundown: OntimeRundown = [];
let persistedCustomFields: CustomFields = {};
/** Utility function gets to expose data */
/**
* Get the cached rundown without triggering regeneration
*/
export const getPersistedRundown = (): OntimeRundown => persistedRundown;
export const getCustomFields = (): CustomFields => persistedCustomFields;
let rundown: NormalisedRundown = {};
let normalisedRundown: NormalisedRundown = {};
let order: EventID[] = [];
let revision = 0;
/**
* all mutating functions will set this value if there is a need for re-generation
* but will only be cleared by the generate function
*/
let isStale = true;
/** Allows safely setting the stale state without accidentally clearing it */
function setIsStale() {
isStale = true;
}
let totalDelay = 0;
let totalDuration = 0;
let firstStart: MaybeNumber = null;
@@ -72,18 +86,22 @@ export async function init(initialRundown: Readonly<OntimeRundown>, customFields
}
/**
* Utility initialises cache
* @param rundown
* Utility generate cache
* @private should not be called outside of `rundownCache.ts`
*/
export function generate(
initialRundown: OntimeRundown = persistedRundown,
customFields: CustomFields = persistedCustomFields,
) {
function clearIsStale() {
isStale = false;
}
// we decided to re-write this dataset for every change
// instead of maintaining logic to update it
assignedCustomFields = {};
rundown = {};
normalisedRundown = {};
order = [];
links = {};
firstStart = null;
@@ -94,11 +112,12 @@ export function generate(
let lastEntry: PlayableEvent | null = null;
for (let i = 0; i < initialRundown.length; i++) {
// TODO: filter properties that should not be persisted (eg: delay)
// we assign a reference to the current entry, this will be mutated in place
const currentEntry = initialRundown[i];
if (isOntimeEvent(currentEntry)) {
currentEntry.delay = 0;
// 1. handle links - mutates updatedEvent
handleLink(i, initialRundown, currentEntry, links);
@@ -145,24 +164,30 @@ export function generate(
lastEntry = currentEntry;
}
}
}
// calculate delays
// !!! this must happen after handling the links
if (isOntimeDelay(currentEntry)) {
} else if (isOntimeDelay(currentEntry)) {
// calculate delays
// !!! this must happen after handling the links
totalDelay += currentEntry.duration;
} else if (isOntimeBlock(currentEntry)) {
// calculate block - nothing yet
} else {
// unknown - type skip it
// this is needed to get the type guard working when we assign the entry to the rundown
continue;
}
// add id to order
order.push(currentEntry.id);
// add entry to rundown
rundown[currentEntry.id] = currentEntry;
normalisedRundown[currentEntry.id] = currentEntry;
}
lastEnd = lastEntry?.timeEnd ?? null;
isStale = false;
clearIsStale();
customFieldChangelog.clear();
return { rundown, order, links, totalDelay, totalDuration, assignedCustomFields };
//The return value is used for testing
return { rundown: normalisedRundown, order, links, totalDelay, totalDuration, assignedCustomFields };
}
/** Returns an ID guaranteed to be unique */
@@ -173,7 +198,7 @@ export function getUniqueId(): string {
let id = '';
do {
id = generateId();
} while (Object.hasOwn(rundown, id));
} while (Object.hasOwn(normalisedRundown, id));
return id;
}
@@ -194,15 +219,15 @@ type RundownCache = {
};
/**
* Returns cached data
* @returns {RundownCache}
* Returns the full rundown cache.
* Will triggering regeneration if data is stale.
*/
export function get(): Readonly<RundownCache> {
if (isStale) {
generate();
}
return {
rundown,
rundown: normalisedRundown,
order,
revision,
totalDelay,
@@ -212,6 +237,7 @@ export function get(): Readonly<RundownCache> {
/**
* Returns calculated metadata from rundown
* Will triggering regeneration if data is stale.
*/
export function getMetadata() {
if (isStale) {
@@ -227,7 +253,7 @@ export function getMetadata() {
};
}
type CommonParams = { persistedRundown: OntimeRundown };
type CommonParams = { rundown: OntimeRundown };
type MutationParams<T> = T & CommonParams;
type MutatingReturn = {
newRundown: OntimeRundown;
@@ -238,23 +264,14 @@ type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingRetur
/**
* Decorators injects data into mutation
* @param mutation
* @returns
* ensures order of operations when performing mutations
*/
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, didMutate } = mutation({ ...params, persistedRundown });
function scopedMutation(params: T) {
const { newEvent, newRundown, didMutate } = mutation({ ...params, rundown: persistedRundown });
// early return without calling side effects
if (!didMutate) {
isStale = false;
return { newEvent, newRundown, didMutate };
}
@@ -278,31 +295,34 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
}
type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>;
export function add({ persistedRundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
/**
* Add entry to rundown
*/
export function add({ rundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
const newEvent: OntimeRundownEntry = { ...event };
const newRundown = insertAtIndex(atIndex, newEvent, persistedRundown);
const newRundown = insertAtIndex(atIndex, newEvent, rundown);
setIsStale();
return { newRundown, newEvent, didMutate: true };
}
type RemoveArgs = MutationParams<{ eventIds: string[] }>;
export function remove({ persistedRundown, eventIds }: RemoveArgs): MutatingReturn {
const newRundown = persistedRundown.filter((event) => !eventIds.includes(event.id));
return { newRundown, didMutate: persistedRundown.length !== newRundown.length };
/**
* Remove entry to rundown
*/
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
const newRundown = rundown.filter((event) => !eventIds.includes(event.id));
const didMutate = rundown.length !== newRundown.length;
if (didMutate) setIsStale();
return { newRundown, didMutate };
}
export function removeAll(): MutatingReturn {
setIsStale();
return { newRundown: [], didMutate: true };
}
/**
* Utility function for patching events
* @param eventFromRundown
* @param patch
* @returns
* Utility function for patching an existing event with new data
*/
function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRundownEntry>): OntimeRundownEntry {
if (isOntimeEvent(eventFromRundown)) {
@@ -315,120 +335,133 @@ function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRu
}
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);
/**
* Apply patch to an entry with given id
*/
export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingReturn> {
const indexAt = rundown.findIndex((event) => event.id === eventId);
if (indexAt < 0) {
throw new Error('Event not found');
}
if (patch?.type && persistedRundown[indexAt].type !== patch.type) {
if (patch?.type && rundown[indexAt].type !== patch.type) {
throw new Error('Invalid event type');
}
const eventInMemory = persistedRundown[indexAt];
const eventInMemory = rundown[indexAt];
if (!hasChanges(eventInMemory, patch)) {
isStale = false;
return { newRundown: persistedRundown, newEvent: eventInMemory, didMutate: false };
return { newRundown: rundown, newEvent: eventInMemory, didMutate: false };
}
const newEvent = makeEvent(eventInMemory, patch);
const newRundown = [...persistedRundown];
const newRundown = [...rundown];
newRundown[indexAt] = newEvent;
// check whether the data warrants recalculation of cache
const makeStale = isDataStale(patch);
if (!makeStale) {
rundown[newEvent.id] = newEvent;
if (makeStale) {
setIsStale();
} else {
normalisedRundown[newEvent.id] = newEvent;
}
isStale = makeStale;
return { newRundown, newEvent, didMutate: true };
}
type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>;
export function batchEdit({ persistedRundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
/**
* Apply patch to multiple entries
*/
export function batchEdit({ rundown, 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) {
for (let i = 0; i < rundown.length; i++) {
if (ids.has(rundown[i].id)) {
if (patch?.type && rundown[i].type !== patch.type) {
continue;
}
const newEvent = makeEvent(persistedRundown[i], patch);
const newEvent = makeEvent(rundown[i], patch);
newRundown.push(newEvent);
} else {
newRundown.push(persistedRundown[i]);
newRundown.push(rundown[i]);
}
}
setIsStale();
return { newRundown, didMutate: true };
}
type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>;
export function reorder({ persistedRundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
const event = persistedRundown[from];
/**
* Redorder two entries
*/
export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
const event = rundown[from];
if (!event || eventId !== event.id) {
throw new Error('Event not found');
}
const newRundown = reorderArray(persistedRundown, from, to);
const newRundown = reorderArray(rundown, from, to);
for (let i = from; i <= to; i++) {
const event = newRundown.at(i);
if (isOntimeEvent(event)) {
event.revision += 1;
}
}
setIsStale();
return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true };
}
type ApplyDelayArgs = MutationParams<{ eventId: string }>;
export function applyDelay({ persistedRundown, eventId }: ApplyDelayArgs): MutatingReturn {
const newRundown = apply(eventId, persistedRundown);
/**
* Apply a delay
*/
export function applyDelay({ rundown, eventId }: ApplyDelayArgs): MutatingReturn {
const newRundown = apply(eventId, rundown);
setIsStale();
return { newRundown, didMutate: true };
}
type SwapArgs = MutationParams<{ fromId: string; toId: string }>;
/**
* Swap two entries
*/
export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn {
const indexA = rundown.findIndex((event) => event.id === fromId);
const eventA = rundown.at(indexA);
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);
const indexB = rundown.findIndex((event) => event.id === toId);
const eventB = rundown.at(indexB);
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
throw new Error('Swap only available for OntimeEvents');
}
const { newA, newB } = swapEventData(eventA, eventB);
const newRundown = [...persistedRundown];
const newRundown = [...rundown];
newRundown[indexA] = newA;
(newRundown[indexA] as OntimeEvent).revision += 1;
newRundown[indexB] = newB;
(newRundown[indexB] as OntimeEvent).revision += 1;
setIsStale();
return { newRundown, didMutate: true };
}
/**
* Invalidates service cache if a custom field is used
* @param label
* Utility for invalidating service cache if a custom field is used
*/
function invalidateIfUsed(label: CustomFieldLabel) {
if (label in assignedCustomFields) {
isStale = true;
setIsStale();
}
// if the field was in use, we mark the cache as stale
if (label in assignedCustomFields) {
isStale = true;
setIsStale();
}
// ... and schedule a cache update
// schedule a non priority cache update
@@ -439,10 +472,9 @@ function invalidateIfUsed(label: CustomFieldLabel) {
}
/**
* Schedules a non priority custom field persist
* @param persistedCustomFields
* Utility for scheduling a non priority custom field persist
*/
function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) {
function scheduleCustomFieldPersist() {
setImmediate(async () => {
await getDataProvider().setCustomFields(persistedCustomFields);
});
@@ -450,12 +482,15 @@ function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) {
/**
* Sanitises and creates a custom field in the database
* @param field
* @returns
*/
export const createCustomField = async (field: CustomField) => {
export function createCustomField(field: CustomField): CustomFields {
const { label, type, colour } = field;
const key = customFieldLabelToKey(label);
if (key === null) {
throw new Error('Unable to convert label to a valid key');
}
// check if label already exists
const alreadyExists = Object.hasOwn(persistedCustomFields, key);
@@ -466,18 +501,15 @@ export const createCustomField = async (field: CustomField) => {
// update object and persist
persistedCustomFields[key] = { label, type, colour };
scheduleCustomFieldPersist(persistedCustomFields);
scheduleCustomFieldPersist();
return persistedCustomFields;
};
}
/**
* Edits an existing custom field in the database
* @param key
* @param newField
* @returns
*/
export const editCustomField = async (key: string, newField: Partial<CustomField>) => {
export function editCustomField(key: string, newField: Partial<CustomField>): CustomFields {
if (!(key in persistedCustomFields)) {
throw new Error('Could not find label');
}
@@ -487,7 +519,14 @@ export const editCustomField = async (key: string, newField: Partial<CustomField
throw new Error('Change of field type is not allowed');
}
if (newField.label === undefined) {
throw new Error('Missing label');
}
const newKey = customFieldLabelToKey(newField.label);
if (newKey === null) {
throw new Error('Unable to convert label to a valid key');
}
persistedCustomFields[newKey] = { ...existingField, ...newField };
if (key !== newKey) {
@@ -495,23 +534,22 @@ export const editCustomField = async (key: string, newField: Partial<CustomField
customFieldChangelog.set(key, newKey);
}
scheduleCustomFieldPersist(persistedCustomFields);
scheduleCustomFieldPersist();
invalidateIfUsed(key);
return persistedCustomFields;
};
}
/**
* Deletes a custom field from the database
* @param label
*/
export const removeCustomField = async (label: string) => {
export function removeCustomField(label: string): CustomFields {
if (label in persistedCustomFields) {
delete persistedCustomFields[label];
}
scheduleCustomFieldPersist(persistedCustomFields);
scheduleCustomFieldPersist();
invalidateIfUsed(label);
return persistedCustomFields;
};
}