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:
Claude
2026-08-23 19:59:56 +00:00
parent c6c25cebcc
commit 45487985be
@@ -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<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).
* 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;
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}`);
}
}
}