diff --git a/.changeset/8992-user-actions-collapse-and-docblock.md b/.changeset/8992-user-actions-collapse-and-docblock.md new file mode 100644 index 0000000000..2eca453727 --- /dev/null +++ b/.changeset/8992-user-actions-collapse-and-docblock.md @@ -0,0 +1,78 @@ +--- +'@object-ui/types': minor +--- + +Collapse the now-redundant `UserActionsSchema` extension, and correct a docblock that +told authors an undeclared `userActions` key is silently dropped when it is refused by +name (objectui#8992). + +⚠️ **Breaking, in the producer direction, which is why this is `minor` and not `patch`** +(objectui's own breaking changes ship as `minor` with the break spelled out — AGENTS.md, +"changeset 里不要声明 `major`"). On the published `@object-ui/types` face, +`userActions.group` / `.hideFields` / `.rowColor` move from `z.ZodOptional[z.ZodBoolean]` +to `z.ZodDefault[z.ZodBoolean]` in the emitted `.d.ts`, so `z.output` for those three +goes from `boolean | undefined` to `boolean` — the key becomes REQUIRED on the output +type. Measured with `tsc`: a value typed as the old output is **not** assignable to the +new one (the three read as missing); the reverse direction is fine. **Readers of parsed +output are unaffected; code that CONSTRUCTS an output-typed `userActions` value must add +the three keys or widen its annotation.** ⛔ Nothing about what parses changes — see the +equivalence measurement below. Precedent for the same operation on the same file: +`ListColumnSchema`'s local `.extend()` collapsed into a plain by-reference re-export and +shipped under 17.1.0 **Minor Changes**. + +`objectql.zod.ts`'s `UserActionsSchema` read +`stripImportedDefaults(Spec).extend({ group, hideFields, rowColor })`, an extension that +existed only because `@objectstack/spec` did not declare those three keys while +`normalizeListViewSchema` folded objectui's legacy `showGroup` / `showHideFields` / +`showColor` onto them. The protocol adopted all three in 17.3.0 (objectui#5435's +ruling), so the extension is now a second local copy of a protocol declaration — the +shape two faces start drifting from — and it collapses into the plain by-reference +re-export its own note always said it would become. + +⭐ The urgent half is the docblock. It stated that `UserActionsConfigSchema` "is NOT +`.strict()`, so ... an author writing `userActions: { group: false }` had it silently +stripped — valid on parse, no effect at render". Measured against the published +artifacts of 17.0.0, 17.2.0, 17.3.0 and the resolved 17.4.0, every one of them REFUSES +an undeclared key and NAMES it (`unrecognized_keys`, one issue). A comment promising +silent tolerance in front of a loud-rejection runtime points an author — human or AI — +at a config that will fail the save gate while telling them that outcome is impossible. +`__tests__/user-actions-mirror-8992.test.ts` now pins the refusal, with firing controls +in both directions, so the sentence cannot rot back. + +The accept set does not move: extended and collapsed were parsed side by side over a +33-document corpus (every declared key in both polarities, the full block, undeclared +keys, wrong types, non-objects) with an identical result — same success, same parsed +output, same refusal codes, keys and messages — and a sentinel proving the comparison +can see a difference when one exists. + +⚠️ TWO THINGS ON THE PUBLISHED SURFACE MOVE, both confined to those three keys, and +both measured by rebuilding `packages/types/dist` on each side of the change: + +1. They end up carrying no `.describe()` metadata. ⛔ Not because the protocol leaves + them undescribed — it describes all three (`group`: "Allow users to change record + grouping from the toolbar. …", and likewise `hideFields` / `rowColor` in the 17.3.0 + and 17.4.0 tarballs). The cause is objectui's own import boundary: + `stripImportedDefaults` unwraps each `ZodDefault` with `.removeDefault()` and + re-optionalises the inner node, and the description sits on the OUTER node it + discards. Measured on this object: all ten defaulted keys read + `description = undefined` after the strip, on both sides of this change, while + `buttons` — the one member that never carried a default — keeps its description + through it. So the three simply stop being an exception: before, the local extension + supplied descriptions the other ten defaulted keys did not have. Nothing in this + repository reads them. +2. In the emitted `objectql.zod.d.ts` the three move from `z.ZodOptional[z.ZodBoolean]` + to `z.ZodDefault[z.ZodBoolean]` — the spec's own declaration, as the compiler sees it + before the runtime strip. This is the import boundary's DELIBERATE and ruled property + — `stripImportedDefaults` is typed `T` in, `T` out, because stripping is "a property + of the PARSE, not of the declaration" (decision batch #90) — and it is what SEVEN of + the other eight keys on this same object have declared all along (`sort`, `search`, + `filter`, `refresh`, `rowHeight`, `addRecordForm`, `editInline`; `buttons` is + `z.ZodOptional[z.ZodArray[z.ZodString]]` and never declared a default). The extension + was making three keys the odd ones out of an object whose eleven members behave + identically at runtime; the collapse makes the declaration uniform. ⛔ It does not + change what parses: an omitted key is still absent from the parsed output, measured, + on all eleven. + +The emitted declaration also carries `z.core.$strict` on BOTH sides of this change, +which is the compiler restating in objectui's own published artifact what the corrected +docblock now says in prose. diff --git a/packages/types/src/__tests__/user-actions-mirror-8992.test.ts b/packages/types/src/__tests__/user-actions-mirror-8992.test.ts new file mode 100644 index 0000000000..c29d891734 --- /dev/null +++ b/packages/types/src/__tests__/user-actions-mirror-8992.test.ts @@ -0,0 +1,151 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8992 — `userActions` refuses an undeclared key BY NAME, and the + * mirror carries exactly the protocol's key set. + * + * Two facts, one file, because they are the two halves of the same repair. + * + * 1. ⭐ THE REFUSAL. `objectql.zod.ts`'s `UserActionsSchema` docblock used to + * tell its reader that `UserActionsConfigSchema` "is NOT `.strict()`, so ... + * an author writing `userActions: { group: false }` had it silently stripped + * — valid on parse, no effect at render". Measured against the published + * artifacts of 17.0.0, 17.2.0, 17.3.0 and the resolved 17.4.0, every one of + * them REFUSES an undeclared key and NAMES it. A comment promising silent + * tolerance in front of a loud-rejection runtime is the worst direction for + * a comment to be wrong in: an author — human or AI — who trusts it writes a + * config that fails the save gate, having been told that outcome is + * impossible. Prose cannot hold that fact down; this file does. + * + * 2. THE COLLAPSE. The same card removed the local + * `.extend({ group, hideFields, rowColor })`, which existed only because the + * protocol did not declare those three. It does since 17.3.0 + * (objectui#5435's ruling, adopted upstream), so what is asserted here is + * that the mirror's key set IS the spec's — DERIVED from + * `UserActionsConfigSchema` at assert time, never restated. A restated list + * would pass for exactly as long as it happened to agree, which is the drift + * the collapse exists to prevent. + * + * ⚠️ NOT VACUOUS BY CONSTRUCTION. "Refused" is worthless as an assertion unless + * the harness is shown to also report ACCEPTED, and "the key sets are equal" is + * worthless unless the comparison is shown to be able to see a difference. Both + * firing controls are below, and every population is asserted non-empty before + * it is used. + * + * ⚠️ These three keys are VERSION-BORNE since the collapse: `@object-ui/types` + * declares `@objectstack/spec: ^17.3.0` and that floor is what carries them + * (17.2.0 declares 8 keys, 17.3.0 declares 11). If this file ever reddens on + * `THE_THREE`, the reading is that the resolved spec is below the declared + * floor — ⛔ not that the mirror regressed. + */ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { UserActionsConfigSchema as SpecUserActionsConfigSchema } from '@objectstack/spec/ui'; +import { UserActionsSchema, ListViewSchema } from '../zod/objectql.zod.js'; + +/** The three toggles objectui's legacy `show*` fold emits (objectui#5435). */ +const THE_THREE = ['group', 'hideFields', 'rowColor'] as const; + +/** A key no version of the protocol has ever declared on this object. */ +const NEVER_DECLARED = 'zzNeverDeclaredByAnySpec'; + +type Parsed = { success: boolean; error?: { issues?: readonly unknown[] } }; + +/** Unrecognized-key names a zod result refuses, flattened. */ +const refusedKeys = (r: Parsed): string[] => + ((r.error?.issues ?? []) as { code?: string; keys?: string[] }[]) + .filter((i) => i.code === 'unrecognized_keys') + .flatMap((i) => i.keys ?? []); + +const issueCount = (r: Parsed): number => (r.error?.issues ?? []).length; + +const keysOf = (schema: { shape: Record }): string[] => Object.keys(schema.shape).sort(); + +describe('objectui#8992 — the mirror carries the protocol key set, by reference', () => { + it('the key sets are equal — derived from the spec, not restated here', () => { + const spec = keysOf(SpecUserActionsConfigSchema as unknown as { shape: Record }); + expect(spec.length, 'a census over an empty key set passes for the wrong reason').toBeGreaterThan(0); + expect(keysOf(UserActionsSchema as unknown as { shape: Record })).toEqual(spec); + }); + + it('FIRING CONTROL — the key-set comparison can see a difference', () => { + const narrowed = UserActionsSchema.omit({ group: true }); + expect(keysOf(narrowed as unknown as { shape: Record })).not.toEqual( + keysOf(UserActionsSchema as unknown as { shape: Record }), + ); + }); + + it('carries the three the fold emits — the whole premise of the collapse', () => { + const keys = keysOf(UserActionsSchema as unknown as { shape: Record }); + for (const key of THE_THREE) { + expect(keys, `${key} is missing — read the resolved @objectstack/spec, not this mirror`).toContain(key); + } + }); + + it('authors no default — an empty block stays empty (the objectui#8317 boundary)', () => { + const r = UserActionsSchema.safeParse({}); + expect(r.success).toBe(true); + expect(r.success && r.data).toEqual({}); + }); + + it('accepts each of the three, both polarities', () => { + for (const key of THE_THREE) { + for (const value of [true, false]) { + const r = UserActionsSchema.safeParse({ [key]: value }); + expect(r.success, `${key}: ${value} was refused`).toBe(true); + expect(r.success && r.data).toEqual({ [key]: value }); + } + } + }); +}); + +describe('objectui#8992 — an undeclared key is REFUSED BY NAME, never dropped', () => { + it('the refusal names the key, and there is exactly one issue', () => { + const r = UserActionsSchema.safeParse({ [NEVER_DECLARED]: true }); + expect(r.success, 'silently accepted — the corrected docblock is wrong again').toBe(false); + expect(refusedKeys(r)).toEqual([NEVER_DECLARED]); + expect(issueCount(r)).toBe(1); + }); + + it('⛔ it is NOT stripped — the refused key never comes back as parsed output', () => { + const r = UserActionsSchema.safeParse({ group: false, [NEVER_DECLARED]: true }); + expect(r.success).toBe(false); + expect(refusedKeys(r)).toEqual([NEVER_DECLARED]); + }); + + it("objectui's own legacy toolbar spelling is refused here too — `showGroup` is not `group`", () => { + const r = UserActionsSchema.safeParse({ showGroup: true }); + expect(r.success).toBe(false); + expect(refusedKeys(r)).toEqual(['showGroup']); + }); + + it('FIRING CONTROL — the same harness reports ACCEPTED when the key IS declared', () => { + const widened = UserActionsSchema.extend({ [NEVER_DECLARED]: z.boolean().optional() }); + const r = widened.safeParse({ [NEVER_DECLARED]: true }); + expect(r.success, 'the harness refuses everything — the refusal above proves nothing').toBe(true); + expect(refusedKeys(r)).toEqual([]); + }); + + it('the refusal survives to the published face — `ListViewSchema.userActions`', () => { + const good = ListViewSchema.safeParse({ + type: 'list-view', + objectName: 'accounts', + userActions: { group: false, hideFields: true, rowColor: true }, + }); + expect(good.success, 'the three are not authorable through the published list view').toBe(true); + + const bad = ListViewSchema.safeParse({ + type: 'list-view', + objectName: 'accounts', + userActions: { [NEVER_DECLARED]: true }, + }); + expect(bad.success).toBe(false); + expect(refusedKeys(bad)).toEqual([NEVER_DECLARED]); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 60d238eb4f..a10f6f80e1 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3021,7 +3021,7 @@ const EXCLUSIONS: Readonly> = { 'objectql.zod.ts#PaginationConfigSchema': "spec-owned BY REFERENCE — the local `.extend(…)` adds renderer props that no TS declaration in this package restates", 'objectql.zod.ts#UserActionsSchema': - "spec-owned BY REFERENCE — the local `.extend(…)` adds renderer props that no TS declaration in this package restates", + "spec-owned BY REFERENCE — a plain `stripImportedDefaults(Spec…)` re-export since objectui#8992 collapsed its `.extend(…)` (the protocol declares `group` / `hideFields` / `rowColor` itself from 17.3.0), and the TS name for it, `UserActionsConfig`, is re-exported FROM `@objectstack/spec/ui` by `../index.ts` rather than restated here — so there is no second definition to drift from", 'objectql.zod.ts#ListViewSchema': "the DECLARATION is derived FROM this mirror — `ListViewSchema = ListViewInferred & ListViewRuntimeProps`, and `ListViewInferred = z.input` (`../objectql.ts`). Asserting parity here would be true no matter what either side said: a phantom assertion, not a check.", 'objectql.zod.ts#ObjectQLComponentSchema': diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 2ae2b6947d..67f2626302 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -590,29 +590,43 @@ const TimelineConfig = stripImportedDefaults(SpecTimelineConfigSchema).partial() const ViewKindEnum = SpecListViewSchema.shape.type.removeDefault(); /** - * User Actions — the spec's `UserActionsConfigSchema` plus the three toolbar - * affordances it does not model yet (#2890 scope A step 3). + * User Actions — `@objectstack/spec/ui`'s `UserActionsConfigSchema`, mirrored + * BY REFERENCE (objectui#8992). * * The spec documents this object as "which interactive actions are available to * users in the view toolbar — each boolean toggles the corresponding toolbar - * element on/off", and already carries `rowHeight` (objectui's old - * `showDensity`). Grouping, column visibility and row coloring are the same kind - * of toggle — the spec models all three as CONFIGURATION (`grouping`, - * `hiddenFields`, `rowColor`) but has no "may the user change it" switch for - * any of them, so an author cannot express a complete toolbar policy. These - * three are named after the config key they gate, following the precedent - * `rowHeight` set. + * element on/off". Grouping, column visibility and row coloring are the same + * kind of toggle as `rowHeight` (objectui's old `showDensity`), each named + * after the config key it gates (`grouping`, `hiddenFields`, `rowColor`). * - * This `.extend()` is temporary: it collapses into a plain re-export once the - * keys land upstream. Note `UserActionsConfigSchema` is NOT `.strict()`, so - * before this extension an author writing `userActions: { group: false }` had - * it silently stripped — valid on parse, no effect at render. + * This read `stripImportedDefaults(Spec).extend({ group, hideFields, rowColor })` + * for as long as the protocol declared none of the three while + * `normalizeListViewSchema` folded objectui's legacy `showGroup` / + * `showHideFields` / `showColor` onto them. The protocol declares all three + * now — the maintainer ruled option A on objectui#5435 (2026-08-22) and the + * spec adopted them in 17.3.0 — so the extension collapses, exactly as its own + * note said it would. A redundant local extension is how two faces start to + * drift. + * + * ⛔ AN UNDECLARED KEY IS REFUSED HERE, BY NAME (`unrecognized_keys`, one + * issue, the key named) — it is NOT dropped. The note this replaces claimed + * the opposite: "`UserActionsConfigSchema` is NOT `.strict()`, so ... an author + * writing `userActions: { group: false }` had it silently stripped — valid on + * parse, no effect at render". That was false at every published 17.x — + * measured by parsing a one-undeclared-key document against the published + * artifacts of 17.0.0, 17.2.0, 17.3.0 and the resolved 17.4.0, each of which + * refuses and names the key. Silent-tolerance prose in front of a + * loud-rejection runtime is the worst direction for a comment to be wrong in: + * it tells an author — human or AI — that a config which will FAIL the save + * gate is harmless. `__tests__/user-actions-mirror-8992.test.ts` pins the + * refusal so this paragraph cannot rot back into the one it replaced. + * + * ⚠️ The three keys are VERSION-BORNE from here on. `@object-ui/types` declares + * `@objectstack/spec: ^17.3.0`, and that floor is load-bearing for them: 17.2.0 + * declares 8 keys, 17.3.0 declares 11. The extension used to carry the three + * locally whatever version resolved; it no longer does. */ -export const UserActionsSchema = stripImportedDefaults(SpecUserActionsConfigSchema).extend({ - group: z.boolean().optional().describe('Allow users to group records'), - hideFields: z.boolean().optional().describe('Allow users to show/hide columns'), - rowColor: z.boolean().optional().describe('Allow users to color rows by a field value'), -}); +export const UserActionsSchema = stripImportedDefaults(SpecUserActionsConfigSchema); export const ListViewSchema = BaseSchema // Spec-owned fields by reference. `specFieldsExcept` reads the spec object's