Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente 24e325726c refactor: improve object handling performance 2026-03-14 07:34:02 +01:00
10 changed files with 46 additions and 48 deletions
+1 -3
View File
@@ -139,9 +139,7 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined),
};
export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
export const useTimer = createSelector((state: RuntimeStore) => state.timer);
export const useClock = createSelector((state: RuntimeStore) => state.clock);
+6 -1
View File
@@ -223,9 +223,14 @@ class SocketServer implements IAdapter {
// message is any serializable value
public sendAsJson<T extends MessageTag>(tag: T, payload: Pick<WsPacketToClient & { tag: T }, 'payload'>['payload']) {
const wss = this.wss;
if (!wss || wss.clients.size === 0) {
return;
}
try {
const stringifiedMessage = JSON.stringify({ tag, payload });
this.wss?.clients.forEach((client) => {
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(stringifiedMessage);
}
@@ -126,15 +126,4 @@ describe('sanitiseCustomFields()', () => {
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('filters keys that collide with Object prototype properties', () => {
const customFields: CustomFields = {
toString: { label: 'toString', type: 'text', colour: 'red' },
normalField: { label: 'normalField', type: 'text', colour: 'green' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual({
normalField: { label: 'normalField', type: 'text', colour: 'green' },
});
});
});
@@ -1,5 +1,5 @@
import { CustomField, CustomFields, DatabaseModel } from 'ontime-types';
import { checkRegex, customFieldLabelToKey, isObjectPrototypeKey } from 'ontime-utils';
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
@@ -45,7 +45,6 @@ export function sanitiseCustomFields(data: object): CustomFields {
'type' in data &&
(data.type === 'text' || data.type === 'image') &&
checkRegex.isAlphanumericWithSpace(data.label) &&
!isObjectPrototypeKey(key) &&
key === customFieldLabelToKey(data.label)
);
}
@@ -1,5 +1,5 @@
import { body, param } from 'express-validator';
import { checkRegex, customFieldLabelToKey, isObjectPrototypeKey } from 'ontime-utils';
import { checkRegex } from 'ontime-utils';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
@@ -9,7 +9,7 @@ export const validateCustomField = [
.trim()
.notEmpty()
.custom((value) => {
return checkRegex.isAlphanumericWithSpace(value) && !isObjectPrototypeKey(customFieldLabelToKey(value));
return checkRegex.isAlphanumericWithSpace(value);
}),
body('type').isIn(['text', 'image']),
body('colour').isString().trim(),
@@ -24,7 +24,7 @@ export const validateEditCustomField = [
.trim()
.notEmpty()
.custom((value) => {
return checkRegex.isAlphanumericWithSpace(value) && !isObjectPrototypeKey(customFieldLabelToKey(value));
return checkRegex.isAlphanumericWithSpace(value);
}),
body('type').isIn(['text', 'image']),
body('colour').isString().trim(),
@@ -186,7 +186,8 @@ function remove(rundown: Rundown, entry: OntimeEntry) {
// for ontime groups, we need to iterate through the children and delete them
for (let i = 0; i < entry.entries.length; i++) {
const nestedEntryId = entry.entries[i];
deleteEntry(nestedEntryId);
// nested entries are not part of the top-level order
deleteEntry(nestedEntryId, false);
}
} else if (entry.parent) {
// at this point, we are handling entries inside a group, so we need to remove the reference
@@ -205,10 +206,13 @@ function remove(rundown: Rundown, entry: OntimeEntry) {
edit(rundown, { id: parentGroup.id, entries: filteredEvents });
}
}
deleteEntry(entry.id);
function deleteEntry(idToDelete: EntryId) {
rundown.order = deleteById(rundown.order, idToDelete);
function deleteEntry(idToDelete: EntryId, shouldDeleteFromOrder: boolean = true) {
if (shouldDeleteFromOrder) {
rundown.order = deleteById(rundown.order, idToDelete);
}
delete rundown.entries[idToDelete];
}
}
@@ -188,7 +188,7 @@ export function parseRundown(
*/
export function sanitiseCustomFields(customFields: CustomFields, entry: OntimeEvent | OntimeMilestone | OntimeGroup) {
for (const field in entry.custom) {
if (Object.hasOwn(customFields, field)) continue;
if (field in customFields) continue;
delete entry.custom[field];
}
return entry;
@@ -16,7 +16,7 @@ import {
isOntimeEvent,
isOntimeGroup,
} from 'ontime-types';
import { customFieldLabelToKey, getInsertAfterId, isObjectPrototypeKey, resolveInsertParent } from 'ontime-utils';
import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
@@ -140,7 +140,6 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
let batchDidInvalidate = false;
const changedIds: EntryId[] = [];
const patchedEntries: OntimeEntry[] = [];
for (let i = 0; i < ids.length; i++) {
const currentId = ids[i];
const currentEntry = rundown.entries[currentId];
@@ -165,15 +164,20 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
continue;
}
const { entry, didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
const { didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
changedIds.push(currentId);
patchedEntries.push(entry);
if (didInvalidate) {
batchDidInvalidate = true;
}
}
// skip commit and notifications when the patch is no-op.
if (changedIds.length === 0) {
return getCurrentRundown() as Rundown;
}
const { rundown: rundownResult, rundownMetadata, revision } = commit(batchDidInvalidate);
// schedule the side effects
@@ -192,7 +196,14 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
* Deletes a known entry from the current rundown
*/
export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
const currentRundown = getCurrentRundown();
const shouldDelete = entryIds.some((entryId) => Object.hasOwn(currentRundown.entries, entryId));
if (!shouldDelete) {
return currentRundown as Rundown;
}
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const deletedIds: EntryId[] = [];
for (let i = 0; i < entryIds.length; i++) {
const entry = rundown.entries[entryIds[i]];
@@ -200,6 +211,11 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
continue;
}
rundownMutation.remove(rundown, entry);
deletedIds.push(entry.id);
}
if (deletedIds.length === 0) {
return currentRundown as Rundown;
}
const { rundown: rundownResult, rundownMetadata, revision } = commit();
@@ -210,7 +226,7 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundownMetadata, revision, { timer: entryIds, external: true });
notifyChanges(rundownMetadata, revision, { timer: deletedIds, external: true });
});
return rundownResult;
@@ -425,9 +441,6 @@ export async function createCustomField(customField: CustomField): Promise<Custo
if (!key) {
throw new Error('Unable to convert label to a valid key');
}
if (isObjectPrototypeKey(key)) {
throw new Error('Label conflicts with a reserved field name');
}
const { customFields, commit } = createTransaction({ mutableRundown: false, mutableCustomFields: true });
@@ -466,7 +479,7 @@ export async function editCustomField(
mutableCustomFields: true,
});
if (!Object.hasOwn(customFields, key)) {
if (!(key in customFields)) {
throw new Error('Could not find label');
}
@@ -475,9 +488,6 @@ export async function editCustomField(
if (newField.type && existingField.type !== newField.type) {
throw new Error('Change of field type is not allowed');
}
if (newField.label && isObjectPrototypeKey(customFieldLabelToKey(newField.label))) {
throw new Error('Label conflicts with a reserved field name');
}
const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField);
@@ -519,7 +529,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
mutableRundown: true,
mutableCustomFields: true,
});
if (!Object.hasOwn(customFields, key)) {
if (!(key in customFields)) {
return customFields;
}
@@ -577,8 +587,10 @@ type NotifyChangesOptions = {
* Notify services of changes in the rundown
*/
function notifyChanges(rundownMetadata: RundownMetadata, revision: number, options: NotifyChangesOptions) {
const shouldNotifyTimer = options.timer === true || (Array.isArray(options.timer) && options.timer.length > 0);
// notify timer service of changed events
if (options.timer) {
if (shouldNotifyTimer) {
runtimeService.notifyOfChangedEvents(rundownMetadata);
}
+1 -1
View File
@@ -61,7 +61,7 @@ export { checkRegex, regex } from './src/regex-utils/checkRegex.js';
export { isColourHex } from './src/regex-utils/isColourHex.js';
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
export { customFieldLabelToKey, customKeyFromLabel, isObjectPrototypeKey } from './src/customField-utils/customFieldUtils.js';
export { customFieldLabelToKey, customKeyFromLabel } from './src/customField-utils/customFieldUtils.js';
// helpers from externals
export { deepmerge } from './src/externals/deepmerge.js';
@@ -1,7 +1,5 @@
import type { CustomFields } from 'ontime-types';
const objectPrototypeKeys = new Set(Object.getOwnPropertyNames(Object.prototype));
/**
* Transforms an alphanumeric label with spaces into a valid key
*/
@@ -9,13 +7,6 @@ export function customFieldLabelToKey(label: string): string {
return label.trim().replaceAll(' ', '_');
}
/**
* Detects keys that collide with Object prototype properties or methods
*/
export function isObjectPrototypeKey(key: string): boolean {
return objectPrototypeKeys.has(key);
}
/**
* Finds an object key in the CustomFields object that matches the given label
*/