mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-25 17:09:09 +00:00
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.
This commit is contained in:
@@ -499,6 +499,52 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont
|
|||||||
throw new Error(`Unsupported entry type for cloning: ${entry}`);
|
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<T> = { [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<K extends SupportedEntry> = Extract<OntimeEntry, { type: K }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ReferenceKeys<EntryOfType<K>>, ClonedReferenceFields[K]>;
|
||||||
|
}[SupportedEntry];
|
||||||
|
|
||||||
|
type AssertNever<T extends never> = T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile-time guard only, with no runtime meaning - see `UnclonedReferenceFields`.
|
||||||
|
* Exported because `noUnusedLocals` rejects an unreferenced local type.
|
||||||
|
*/
|
||||||
|
export type AssertEntryClonesEveryReference = AssertNever<UnclonedReferenceFields>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc).
|
* Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc).
|
||||||
* Drop-in replacement for `structuredClone(entry)`: the generic structured-clone
|
* Drop-in replacement for `structuredClone(entry)`: the generic structured-clone
|
||||||
@@ -514,6 +560,11 @@ export function cloneEntryData<T extends OntimeEntry>(entry: T): T {
|
|||||||
return { ...entry, custom: { ...entry.custom } } as T;
|
return { ...entry, custom: { ...entry.custom } } as T;
|
||||||
case SupportedEntry.Delay:
|
case SupportedEntry.Delay:
|
||||||
return { ...entry } as T;
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user