diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index 460031a9d..ef42edafe 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -34,6 +34,7 @@ import { ProcessedRundownMetadata, makeRundownMetadata } from './rundown.parser. import type { RundownMetadata } from './rundown.types.js'; import { applyPatchToEntry, + cloneRundown, cloneSimpleRundownEntry, deleteById, doesInvalidateMetadata, @@ -106,7 +107,7 @@ export function createTransaction(options: TransactionOptions): Transaction { const targetId = options.rundownId ?? cachedRundown.id; const isLoaded = targetId === cachedRundown.id; const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown); - const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown; + const rundown = options.mutableRundown ? cloneRundown(sourceRundown) : sourceRundown; const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields; /** @@ -707,7 +708,7 @@ export const customFieldMutation = { * Expose function to add an initial rundown to the system */ export function init(initialRundown: Readonly, initialCustomFields: Readonly) { - const rundown = structuredClone(initialRundown); + const rundown = cloneRundown(initialRundown); const customFields = structuredClone(initialCustomFields); const processedData = processRundown(rundown, customFields, { mutate: true }); diff --git a/apps/server/src/api-data/rundown/rundown.parser.ts b/apps/server/src/api-data/rundown/rundown.parser.ts index e269490e9..14c1721aa 100644 --- a/apps/server/src/api-data/rundown/rundown.parser.ts +++ b/apps/server/src/api-data/rundown/rundown.parser.ts @@ -33,7 +33,7 @@ import { import { makeNewRundown } from '../../models/dataModel.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js'; import { RundownMetadata } from './rundown.types.js'; -import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js'; +import { calculateDayOffset, cleanupCustomFields, cloneEntryData } from './rundown.utils.js'; /** * Parse a rundowns object along with the project custom fields @@ -234,7 +234,7 @@ export function makeRundownMetadata(customFields: CustomFields, options?: { muta }; function process(entry: T, childOfGroup: EntryId | null): T { - return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup); + return processEntry(rundownMeta, customFields, mutate ? entry : cloneEntryData(entry), childOfGroup); } function getMetadata(): ProcessedRundownMetadata { diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index b4ceb8f16..4a91cf2bd 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -39,6 +39,7 @@ import { import { parseRundown } from './rundown.parser.js'; import type { RundownMetadata } from './rundown.types.js'; import { + cloneRundown, generateEvent, getFirstInsertId, getIntegerAndFraction, @@ -626,7 +627,7 @@ export async function editCustomField( // ... reassign references in the background rundowns for (const rundownId of Object.keys(projectRundowns)) { if (rundownId !== rundown.id) { - const backgroundRundown = structuredClone(projectRundowns[rundownId]); + const backgroundRundown = cloneRundown(projectRundowns[rundownId]); customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey); await updateBackgroundRundown(rundownId, backgroundRundown); } @@ -666,7 +667,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr // remove references in the background rundowns for (const rundownId of Object.keys(projectRundowns)) { if (rundownId !== rundown.id) { - const backgroundRundown = structuredClone(projectRundowns[rundownId]); + const backgroundRundown = cloneRundown(projectRundowns[rundownId]); customFieldMutation.removeUsages(backgroundRundown, key); await updateBackgroundRundown(rundownId, backgroundRundown); } @@ -846,7 +847,7 @@ export async function duplicateExistingRundown(id: string) { const dataProvider = getDataProvider(); const rundown = dataProvider.getRundown(id); - const duplicatedRundown: Rundown = structuredClone(rundown); + const duplicatedRundown: Rundown = cloneRundown(rundown); duplicatedRundown.id = generateId(); duplicatedRundown.title = `Copy of ${rundown.title}`; duplicatedRundown.revision = 0; diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index d1d0407db..b22e0309b 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -329,7 +329,7 @@ export function mergeRundownPreservingFields( const structure = isOntimeGroup(incomingEntry) ? { entries: incomingEntry.entries } : { parent: incomingEntry.parent }; - entries[id] = structuredClone({ ...merged, ...structure }); + entries[id] = cloneEntryData({ ...merged, ...structure }); } return { @@ -499,6 +499,46 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont throw new Error(`Unsupported entry type for cloning: ${entry}`); } +/** + * Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc). + * Drop-in replacement for `structuredClone(entry)`: the generic structured-clone + * algorithm does far more work than the plain object spreads an entry this shallow needs. + */ +export function cloneEntryData(entry: T): T { + switch (entry.type) { + case SupportedEntry.Event: + return { ...entry, custom: { ...entry.custom }, triggers: entry.triggers?.slice() ?? [] } as T; + case SupportedEntry.Group: + return { ...entry, custom: { ...entry.custom }, entries: entry.entries?.slice() ?? [] } as T; + case SupportedEntry.Milestone: + return { ...entry, custom: { ...entry.custom } } as T; + case SupportedEntry.Delay: + return { ...entry } as T; + } +} + +/** + * Fast, shape-aware clone of a whole rundown. + * Drop-in replacement for `structuredClone(rundown)`: every entry (and its nested + * `custom` / `triggers` / `entries` containers) gets its own copy, so callers can mutate + * the result freely without touching the source - same contract as structuredClone, + * at a fraction of the cost since we skip the generic serialization algorithm. + */ +export function cloneRundown(rundown: Readonly): Rundown { + const entries: RundownEntries = {}; + for (const id in rundown.entries) { + entries[id] = cloneEntryData(rundown.entries[id]); + } + return { + id: rundown.id, + title: rundown.title, + revision: rundown.revision, + order: rundown.order.slice(), + flatOrder: rundown.flatOrder.slice(), + entries, + }; +} + /** * Utility for calculating if the current events should have a day offset * @param current the current event under test diff --git a/apps/server/src/api-data/sheets/sheets.service.ts b/apps/server/src/api-data/sheets/sheets.service.ts index e6d7a68de..2cec76b55 100644 --- a/apps/server/src/api-data/sheets/sheets.service.ts +++ b/apps/server/src/api-data/sheets/sheets.service.ts @@ -464,9 +464,8 @@ export async function upload(sheetId: string, options: ImportMap) { sheetOrder.forEach((entryId, index) => { const isGroupEnd = entryId.startsWith('group-end-'); const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId; - const entry = isGroupEnd - ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) - : structuredClone(rundown.entries[id]); + // cellRequestFromEvent only reads the entry to build a cell request, no clone is needed + const entry = isGroupEnd ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) : rundown.entries[id]; updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata)); }); } catch (e) { diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 44cc2aff6..35d781d64 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -12,6 +12,7 @@ import { ViewSettings, } from 'ontime-types'; +import { cloneRundown } from '../../api-data/rundown/rundown.utils.js'; import { isTest } from '../../setup/environment.js'; import { shouldCrashDev } from '../../utils/development.js'; import { isPath } from '../../utils/fileManagement.js'; @@ -102,7 +103,7 @@ function getCustomFields(): Readonly { } async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise { - db.data.rundowns[rundownKey] = structuredClone(newData); + db.data.rundowns[rundownKey] = cloneRundown(newData); await persist(); return db.data.rundowns; } diff --git a/apps/server/src/classes/data-provider/DataProvider.utils.ts b/apps/server/src/classes/data-provider/DataProvider.utils.ts index 6a2fbb756..c57bec8b8 100644 --- a/apps/server/src/classes/data-provider/DataProvider.utils.ts +++ b/apps/server/src/classes/data-provider/DataProvider.utils.ts @@ -4,12 +4,17 @@ import { DatabaseModel } from 'ontime-types'; * Merges a partial ontime project into a given ontime project */ export function safeMerge(existing: DatabaseModel, newData: Partial): DatabaseModel { - const deepExisting = structuredClone(existing); - const deepNewData = structuredClone(newData); + // rundowns are merged separately below by reference (only the top-level map is copied, + // same as the other properties here) - deep-cloning them here would be wasted work, + // since a project's rundowns are by far the largest part of this object + const { rundowns: existingRundowns, ...existingRest } = existing; + const { rundowns: newRundowns = {}, ...newDataRest } = newData; + + const deepExisting = structuredClone(existingRest); + const deepNewData = structuredClone(newDataRest); // destructure each property to simplify merging not provided ie: ...{} has no effect const { - rundowns = {}, project = {}, settings = {}, viewSettings = {}, @@ -19,7 +24,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial