From c6c25cebcc89f4b9b22b97933f0d2de37890b5c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 18:19:18 +0000 Subject: [PATCH] Make createTransaction's rundown deep-readonly when not mutable Overload createTransaction() on the literal mutableRundown option: with mutableRundown: true it returns rundown: Rundown as before; otherwise it returns rundown: DeepReadonly (ts-essentials, already a convention in this codebase for read-only snapshots). Previously a non-mutable transaction's rundown was typed as plain Rundown even though it's the live cachedRundown reference itself (or a background rundown read straight from disk) - nothing stopped a future mutation function from writing into it outside the commit() flow, since Readonly (used elsewhere for the same purpose) only blocks top-level reassignment, not nested writes like array.push() or entry.field = x. Scoped to rundown.dao.ts only: every existing call site in rundown.service.ts passes a literal mutableRundown: true, so this changes no call-site types. The one mutableRundown: false site doesn't destructure rundown at all. Verified with a throwaway probe file (removed) that mutating a non-mutable transaction's rundown is now a compile error, and that a mutable one still compiles as before. Typecheck, lint and full test suite (695 tests) pass unchanged. --- .../src/api-data/rundown/rundown.dao.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index ef42edafe..b0f6828e4 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -27,6 +27,7 @@ import { isPlayableEvent, } from 'ontime-types'; import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils'; +import type { DeepReadonly } from 'ts-essentials'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { consoleError } from '../../utils/console.js'; @@ -80,9 +81,16 @@ export const getRundownMetadata = (): Readonly => rundownMetada export const getProjectCustomFields = (): Readonly => projectCustomFields; export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId]; -type Transaction = { +/** + * @param R the type callers see for `rundown` - a plain, mutable `Rundown` when the + * transaction was opened with `mutableRundown: true`, otherwise a `DeepReadonly` + * so that accidentally mutating an entry (or an order array) on a non-mutable transaction + * - which would silently corrupt the live cache without going through commit() - is a + * compile-time error instead of a runtime bug. + */ +type Transaction = { customFields: CustomFields; - rundown: Rundown; + rundown: R; commit: (shouldProcess?: boolean) => Promise<{ rundown: Readonly; @@ -103,7 +111,13 @@ type TransactionOptions = { rundownId?: string; }; -export function createTransaction(options: TransactionOptions): Transaction { +export function createTransaction(options: TransactionOptions & { mutableRundown: true }): Transaction; +export function createTransaction( + options: TransactionOptions & { mutableRundown?: false }, +): Transaction>; +export function createTransaction( + options: TransactionOptions, +): Transaction | Transaction> { const targetId = options.rundownId ?? cachedRundown.id; const isLoaded = targetId === cachedRundown.id; const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);