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
78 changes: 78 additions & 0 deletions .changeset/8992-user-actions-collapse-and-docblock.md
Original file line number Diff line number Diff line change
@@ -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.
151 changes: 151 additions & 0 deletions packages/types/src/__tests__/user-actions-mirror-8992.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown> }): 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<string, unknown> });
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<string, unknown> })).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<string, unknown> })).not.toEqual(
keysOf(UserActionsSchema as unknown as { shape: Record<string, unknown> }),
);
});

it('carries the three the fold emits — the whole premise of the collapse', () => {
const keys = keysOf(UserActionsSchema as unknown as { shape: Record<string, unknown> });
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]);
});
});
2 changes: 1 addition & 1 deletion packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3021,7 +3021,7 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
'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<typeof ListViewSchema>` (`../objectql.ts`). Asserting parity here would be true no matter what either side said: a phantom assertion, not a check.",
'objectql.zod.ts#ObjectQLComponentSchema':
Expand Down
50 changes: 32 additions & 18 deletions packages/types/src/zod/objectql.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading