Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/action-param-excess-keys-compile.md
Original file line number Diff line number Diff line change
@@ -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<excess-key, never>` 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.
74 changes: 74 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});

// ============================================================================
Expand Down
38 changes: 36 additions & 2 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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> = T &
Record<Exclude<keyof T, keyof z.input<typeof ObjectSchemaBase>>, never>;
Record<Exclude<keyof T, keyof z.input<typeof ObjectSchemaBase>>, never> &
NoExcessNestedActionParams<T>;

/**
* [#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> =
T extends { actions: infer As extends readonly unknown[] }
? { actions: { [I in keyof As]: NoExcessActionParams<As[I]> } }
: unknown;

/** Maps one action literal: constrain each of its `params` entries, if any. */
type NoExcessActionParams<A> =
A extends { params: infer Ps extends readonly unknown[] }
? A & { params: { [I in keyof Ps]: NoExcessActionParamKeys<Ps[I]> } }
: A;

/** One action-param literal: every key outside `ActionParam` becomes `never`. */
type NoExcessActionParamKeys<P> = P &
Record<Exclude<keyof P, keyof ActionParam>, never>;

/** Object names already warned about a generic `password` field (dedup per name). */
const warnedPasswordObjects = new Set<string>();
Expand Down
Loading