diff --git a/.changeset/action-param-excess-keys-compile.md b/.changeset/action-param-excess-keys-compile.md new file mode 100644 index 0000000000..da8ec49f5c --- /dev/null +++ b/.changeset/action-param-excess-keys-compile.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): `ObjectSchema.create()` rejects unknown keys on nested action-param literals at compile time (#12615) + +`create()` infers its generic from the argument, so TypeScript's +excess-property (freshness) checking never fires at any depth; the existing +`NoExcessObjectKeys` map compensated only at the top level. A typo'd key on an +`actions[].params[]` literal (measured: `carryOverX` on +`sys-permission-set.object.ts`) therefore passed `tsc` clean and was caught +only by `ActionParamSchema`'s strict parse at module load. + +The same `Record` map is now mirrored over each element of +each action's `params` array, so the typo becomes a located `tsc` error at the +authoring site (`error TS2322 … 'true' is not assignable to 'never'` pointing +at the unknown key). + +Compile-layer signal only — shipped as `patch` because no working code +changes meaning: the strict parse at module load stays the enforcement of +record, nothing changes in what parses or when, and every literal the new +constraint refuses was already refused (later, at import) by that parse. diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index d247cc2592..6a59171602 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1350,6 +1350,80 @@ describe('ObjectSchema.create()', () => { expect(obj.validations).toEqual([]); }); }); + + // [#12615] The #1535 compile-layer rejection, one level DOWN. `create()` + // infers `T` from its argument, so tsc's excess-property (freshness) check + // never fires at ANY depth; the top-level `Record<…, never>` map compensated + // only for top-level keys, and a typo'd key on a nested action-param literal + // (measured 2026-08-26: `carryOverX` on `sys-permission-set.object.ts`) + // passed the package typecheck CLEAN — caught only by `ActionParamSchema`'s + // strict parse at module load. `NoExcessNestedActionParams` mirrors the map + // over `actions[].params[]`; these pins hold both layers: + // - the `@ts-expect-error` line is the COMPILE pin — if the nested map is + // removed, the directive goes unused and `check:test-typecheck` fails; + // - the `.toThrow` is the RUNTIME pin — the strict parse stays the + // enforcement of record, unchanged, for non-literal (dynamic) configs. + describe('excess-key rejection on nested action-param literals (#12615)', () => { + it('a typo\'d param key is refused at compile time AND still refused by the load-time strict parse', () => { + expect(() => ObjectSchema.create({ + name: 'demo', + fields: { status: { type: 'text' } }, + actions: [ + { + name: 'clone_demo', + label: 'Clone', + mode: 'custom', + locations: ['list_item'], + type: 'api', + method: 'POST', + target: '/api/v1/data/demo', + params: [ + { + field: 'status', + defaultFromRow: true, + carryOver: true, + // @ts-expect-error — `carryOverX` is not an ActionParam key (the #12615 compile pin) + carryOverX: true, + }, + ], + }, + ], + })).toThrow(/carryOverX/); + }); + + it('positive control: every canonical param spelling still compiles and parses untouched', () => { + const obj = ObjectSchema.create({ + name: 'demo', + fields: { status: { type: 'text' } }, + actions: [ + { + // An action with NO params — the nested map must leave it alone. + name: 'activate_demo', + label: 'Activate', + mode: 'custom', + locations: ['list_item'], + type: 'api', + method: 'PATCH', + target: '/api/v1/data/demo/{id}', + }, + { + name: 'clone_demo', + label: 'Clone', + mode: 'custom', + locations: ['list_item'], + type: 'api', + method: 'POST', + target: '/api/v1/data/demo', + params: [ + { name: 'label', label: 'New Name', type: 'text', required: true, helpText: 'display name' }, + { field: 'status', defaultFromRow: true, carryOver: true }, + ], + }, + ], + }); + expect(obj.actions).toHaveLength(2); + }); + }); }); // ============================================================================ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index cb00c1b9f1..29ff73b851 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { FieldSchema } from './field.zod'; import { ValidationRuleSchema } from './validation.zod'; -import { ActionSchema } from '../ui/action.zod'; +import { ActionSchema, type ActionParam } from '../ui/action.zod'; import { ObjectListViewSchema } from '../ui/view.zod'; /** @@ -2358,7 +2358,41 @@ function unknownKeyError(objectName: unknown, unknownKeys: string[], knownKeys: * silent strip into a `tsc` error at the authoring site as well as at build. */ type NoExcessObjectKeys = T & - Record>, never>; + Record>, never> & + NoExcessNestedActionParams; + +/** + * [#12615] Extends the compile-time excess-key rejection one level DOWN, to the + * action-param literals nested inside `actions[].params[]`. + * + * Why the top-level trick alone does not reach them: `create()` infers `T` + * from the argument itself, so the argument always matches `T` exactly and + * TypeScript's excess-property (freshness) checking never fires — at any + * depth. {@link NoExcessObjectKeys} compensates at the top level by mapping + * every non-schema key to `never`; nested literals had no such map, so a + * typo'd param key (measured 2026-08-26: `carryOverX` on + * `sys-permission-set.object.ts`) sailed through `tsc` and was caught only by + * `ActionParamSchema`'s strict parse at module load. This mirrors the same + * map over each element of each action's `params` array, turning the typo + * into a located compile error at the authoring site. + * + * Compile-layer signal only — the strict parse at module load remains the + * enforcement of record; nothing here changes what parses or when. + */ +type NoExcessNestedActionParams = + T extends { actions: infer As extends readonly unknown[] } + ? { actions: { [I in keyof As]: NoExcessActionParams } } + : unknown; + +/** Maps one action literal: constrain each of its `params` entries, if any. */ +type NoExcessActionParams = + A extends { params: infer Ps extends readonly unknown[] } + ? A & { params: { [I in keyof Ps]: NoExcessActionParamKeys } } + : A; + +/** One action-param literal: every key outside `ActionParam` becomes `never`. */ +type NoExcessActionParamKeys

= P & + Record, never>; /** Object names already warned about a generic `password` field (dedup per name). */ const warnedPasswordObjects = new Set();