From 45487985be4265f31de021d0cc9663cbddf32fc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 19:59:56 +0000 Subject: [PATCH] Make cloneEntryData fail to compile when an entry shape changes cloneEntryData relies on a spread plus a hand-written list of the nested containers to copy. Nothing tied that list to the real types, so adding a reference-typed field to an entry - or a new entry type - would silently produce a clone that aliases the new field back to the source, which is the exact bug the custom clone exists to avoid. Two compile-time guards, both verified by temporarily mutating the shared types: - ClonedReferenceFields declares, per entry type, the reference-typed fields the switch copies. UnclonedReferenceFields diffs that against the fields the types actually have, computed via ReferenceKeys. Adding `attachments: string[]` to OntimeEvent now fails with `Type '"attachments"' does not satisfy the constraint 'never'`, naming the offending field. Adding a primitive field stays silent, since the spread already copies it by value and no action is needed. - A default branch in the switch asserts the entry is never. Adding a SupportedEntry member fails with `Type 'OntimeMarker' is not assignable to type 'never'`, and separately at the ClonedReferenceFields index, which is no longer total. Branded primitives such as Day (number & Brand<'day'>) correctly classify as primitives, so they are not flagged. Typecheck, lint, format and the full suite (695 tests) pass unchanged. --- .../src/api-data/rundown/rundown.utils.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index b22e0309b..dd7f4ffd1 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -499,6 +499,52 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont throw new Error(`Unsupported entry type for cloning: ${entry}`); } +type Primitive = string | number | boolean | bigint | symbol | null | undefined; + +/** + * The keys of `T` holding a reference (object or array) rather than a primitive: exactly the + * fields a shallow spread aliases instead of copying, and so exactly the fields + * `cloneEntryData` has to give a fresh copy to. + * Branded primitives (eg. `Day`) resolve as primitives, which is what we want - they are + * plain numbers at runtime. + */ +type ReferenceKeys = { [K in keyof T]-?: T[K] extends Primitive ? never : K }[keyof T]; + +/** The member of the `OntimeEntry` union carrying a given `type` tag */ +type EntryOfType = Extract; + +/** + * The reference-typed fields that `cloneEntryData` below gives a fresh copy to, per entry type. + * This is the one place to update when an entry gains or loses such a field - the shape is + * checked against the real types by `UnclonedReferenceFields`. + */ +type ClonedReferenceFields = { + [SupportedEntry.Event]: 'custom' | 'triggers'; + [SupportedEntry.Group]: 'custom' | 'entries'; + [SupportedEntry.Milestone]: 'custom'; + [SupportedEntry.Delay]: never; +}; + +/** + * Every reference-typed field `cloneEntryData` would alias instead of copy. Stays `never` + * while the clone is complete, so `AssertEntryClonesEveryReference` fails the build when: + * - an entry type gains a reference-typed field -> the field name resolves here, and is + * named in the error, until it is copied in the switch and listed above + * - `SupportedEntry` gains a member -> indexing `ClonedReferenceFields` fails here + * A new primitive field needs no action: the spread already copies it by value. + */ +type UnclonedReferenceFields = { + [K in SupportedEntry]: Exclude>, ClonedReferenceFields[K]>; +}[SupportedEntry]; + +type AssertNever = T; + +/** + * Compile-time guard only, with no runtime meaning - see `UnclonedReferenceFields`. + * Exported because `noUnusedLocals` rejects an unreferenced local type. + */ +export type AssertEntryClonesEveryReference = AssertNever; + /** * Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc). * Drop-in replacement for `structuredClone(entry)`: the generic structured-clone @@ -514,6 +560,11 @@ export function cloneEntryData(entry: T): T { return { ...entry, custom: { ...entry.custom } } as T; case SupportedEntry.Delay: return { ...entry } as T; + default: { + // exhaustiveness guard: a new member of `SupportedEntry` is named in the error here + const unhandled: never = entry; + throw new Error(`Unsupported entry type for cloning: ${(unhandled as OntimeEntry).type}`); + } } }