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}`); + } } }