@object-ui/core 17.1.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,531 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 17.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bca45cc: Declare the 18 spec-owned action keys `ActionDef` had been absorbing silently.
8
+
9
+ `ActionDef` ends with `[key: string]: any`, so it accepted any key of any type —
10
+ a typo (`targt`) and a retired spec key (`execute`) both type-checked, then the
11
+ runner silently bound no handler (the #2169 "Mark Done does nothing" shape).
12
+ Step 1 (objectstack#4075) made that audible with a dev-mode warning. This is
13
+ step 2: the keys the warning identified as legitimate are now real fields.
14
+
15
+ - **18 keys promoted to explicit optional fields** — `ai`, `aria`, `bodyExtra`,
16
+ `bodyShape`, `bulkEnabled`, `component`, `icon`, `locations`, `mode`,
17
+ `objectName`, `order`, `recordIdField`, `recordIdParam`, `requiredPermissions`,
18
+ `requiresFeature`, `shortcut`, `variant`, `visible`. Every type is **derived**
19
+ from `@objectstack/spec`'s `ActionInput` (`SpecActionInput['locations']`, …),
20
+ never hand-copied: a hand-written duplicate of a spec shape is a second
21
+ contract that drifts, which is the failure this issue is about. Wrong-typed
22
+ values are now compile errors — `order: 'first'`, `variant: 'chartreuse'`,
23
+ `locations: ['nope']` — where before they were absorbed silently.
24
+ - **Derived from `z.input`, not `z.infer`.** `ActionSchema` is a `ZodPipe` whose
25
+ transform narrows `visible` from `string | { dialect, source }` to the
26
+ envelope alone. This runner consumes authored/stored rows, which are
27
+ rehydrated unparsed, so it sees the input shape; deriving from the inferred
28
+ `Action` would have rejected the raw-string predicate `ActionEngine`
29
+ explicitly supports.
30
+ - **Three `as any` casts deleted** in `ActionEngine` — `visible` and
31
+ `requiredPermissions` at the location filter, `locations` at registration.
32
+ They existed only because the fields were undeclared.
33
+ - **Four objectui-dialect keys marked `@deprecated`** with the spec spelling to
34
+ use instead — `actionType` (→ `type`), `api` and `endpoint` (→ `target`;
35
+ `executeAPI` already resolves `api || endpoint || target`), and `navigate`
36
+ (→ flat `target` / `openIn`). Only these four: the remaining dialect keys are
37
+ runner mechanics (chaining, toasts, post-execution reload/close) with no spec
38
+ counterpart, and pointing them at a spelling that does not exist would be
39
+ worse than leaving them declared.
40
+
41
+ **Breaking edge, deliberate.** `shortcut` and `bulkEnabled` were retired by
42
+ `@objectstack/spec` 17 as `retiredKey()` tombstones (`z.never()`), so authoring
43
+ either is already a hard parse rejection. Deriving their types rather than
44
+ hand-writing them turns that runtime rejection into a **compile error**: code
45
+ that assigned `shortcut: 'ctrl+k'` to an `ActionDef` compiled before and does
46
+ not now. Such metadata was already refused by the platform — this only moves the
47
+ failure to where it can be fixed. A host may still pass either explicitly via
48
+ `ActionEngine.registerAction(action, { shortcut, bulkEnabled })`; only authored
49
+ metadata stopped carrying them. `bulkEnabled`'s replacement is the list view's
50
+ `bulkActions` / `bulkActionDefs`; `shortcut` has none.
51
+
52
+ The index signature **stays** — removing it is step 3, and the inverted pin
53
+ asserting it is still present remains the issue's own completion check.
54
+
55
+ - 4bf612c: Aggregate single-call mode for bulk actions: `execution: 'aggregate'` (objectui#3139).
56
+
57
+ A `bulkActionDefs` entry with `operation: 'custom'` used to have exactly one
58
+ dispatch shape: one action-runner call per selected record (`_rowRecord`
59
+ attached). "Select N rows → ONE call that receives every selected id" — the
60
+ zip-of-QR-codes / merged-PDF / batch-print shape — could not be expressed, so
61
+ downstream projects fell back to per-row `window.open` storms or gave up.
62
+
63
+ `BulkActionDef` now carries `execution?: 'perRecord' | 'aggregate'` (default
64
+ `'perRecord'`, existing views untouched). An aggregate def dispatches its
65
+ action exactly once for the whole selection with `params._selectedIds:
66
+ string[]` injected and the full records published as
67
+ `context.selectedRecords`. The authored form usually just names a declared
68
+ object action — `{ name, operation: 'custom', execution: 'aggregate' }` —
69
+ and `resolveBulkActions` attaches the declaration. Results are
70
+ all-or-nothing: a failure is attributed to every id with the real error and
71
+ per-row Retry is hidden (re-running the action is the retry; a total failure
72
+ keeps the selection). `batchSize` does not apply; `maxRecords` still gates.
73
+
74
+ The executor rides the existing `executeBulkBatch` bulk-first decision tree —
75
+ the aggregate call is its `bulkCall`, and the per-row "fallback" only
76
+ re-throws the captured error for attribution, never fans out N dispatches
77
+ against an endpoint written for one `_selectedIds` call.
78
+
79
+ Also: url/api target interpolation now exposes `${ctx.selection.ids}` (comma
80
+ -joined) and `${ctx.selection.count}` from the grid's checkbox selection, so
81
+ a plain `list_toolbar` action can carry the selection without bulk plumbing;
82
+ the console's server-action handler recognizes `_selectedIds` and skips the
83
+ single-record multi-select guard for aggregate dispatches.
84
+
85
+ - 335041c: Stop declaring 13 `@object-ui/core` symbols under names `@objectstack/spec` owns
86
+ (objectui#3158, objectstack#4115 batch 4).
87
+
88
+ **Breaking for importers of `@object-ui/core`** — seven exported names changed,
89
+ because the spec exports the same name for a _different_ thing:
90
+
91
+ | was | now | what the spec's same-named export actually is |
92
+ | :----------------------- | :-------------------------------- | :--------------------------------------------------------------------------- |
93
+ | `ChartSeries` | `ChartSeriesBinding` | the authored dataset-binding descriptor (a measure `name`, no `data`) |
94
+ | `ActionHandler` | `ActionRunnerHandler` | the SERVER-side objectql handler, `(ctx) => unknown` |
95
+ | `PluginDefinition` | `RegistryPluginDefinition` | the platform PACKAGE manifest (`id`/`slug`/`staticPath`/install hooks) |
96
+ | `ValidationError` | `SchemaNodeValidationError` | plugin-manifest validation, keyed by `field`, no severity |
97
+ | `ValidationResult` | `SchemaNodeValidationResult` | ditto, with both arrays optional |
98
+ | `defineView` | `defineSystemView` | the VIEW-DOCUMENT factory: parses a `ViewSchema`, returns a validated `View` |
99
+ | `resolveCrudAffordances` | `resolveEffectiveCrudAffordances` | the object-level affordance matrix, with no notion of server API operations |
100
+
101
+ The other six keep their names and are now **imported from the spec** instead of
102
+ re-declared: `StyleMap`, `ResponsiveStyles` (ADR-0065), `RowHeight`,
103
+ `CONTEXT_TOKENS`, `CrudAffordances`, `RowCrudPredicates`.
104
+
105
+ **The copies were live misdescriptions, not just duplicates.** Three said so in
106
+ their own comments:
107
+
108
+ - `CONTEXT_TOKENS` carried a note that the duplication was "temporary until the
109
+ next coordinated release… because the installed `@objectstack/spec` predates
110
+ that export". The installed spec (17.0.0-rc.0) exports it, and the copy was
111
+ byte-identical — so it passed every value comparison and every behavioural
112
+ test for the whole interval in which its stated reason was false.
113
+ - `RowHeight` advertised itself as "the spec's `RowHeightSchema` vocabulary"
114
+ while being a hand-written union. It happened to be correct; nothing would
115
+ have caught the day it stopped being.
116
+ - `managedBy.ts` described itself as a "UI-side mirror of the framework's
117
+ `resolveCrudAffordances()`" and carried its own `DEFAULTS` table — a
118
+ line-for-line copy of the spec's `CRUD_AFFORDANCE_DEFAULTS`, plus a copy of
119
+ its override parser.
120
+
121
+ `resolveEffectiveCrudAffordances` now **delegates** the bucket/`userActions` half
122
+ to the spec's `resolveCrudAffordances()`, so the bucket table has exactly one
123
+ definition on the platform. What stays objectui's is the part the spec has no
124
+ notion of: intersecting that matrix with the server-resolved effective API
125
+ operation set (#3391), so the UI never offers a button the server would 405 —
126
+ and the name now says that instead of claiming to be the spec's function.
127
+
128
+ Deriving `RowCrudPredicates` also **tightens** it: the local copy typed
129
+ `visibleWhen`/`disabledWhen` as `unknown`, where the spec types them as
130
+ `Expression | ExpressionInput`. That was imprecision, not a deliberate dialect.
131
+
132
+ - b414983: fix(dashboard): a date globalFilter's preset-name default becomes a range, not an equality
133
+
134
+ Setup → System Overview rendered EVERY KPI tile as 0 while its period selector
135
+ read "All time" (objectstack#4475). Every request was `200 OK`, the widgets
136
+ rendered normally, and nothing in the UI signalled a failure — zeros read as
137
+ "nothing has happened yet" rather than as an error, which is why this survived
138
+ to an RC.
139
+
140
+ Both symptoms are one missing normalization. `resolveDashboardFilterDefs` lifts
141
+ the built-in `dateRange` declaration's preset NAME to `{ preset }`, but passed a
142
+ `globalFilters` entry's `defaultValue` through raw. `@objectstack/spec`'s
143
+ `GlobalFilterSchema.defaultValue` is `string | number | boolean`, so a bare
144
+ preset name is the ONLY spelling an author can write — and nothing ever mapped
145
+ it. System Overview declares
146
+ `{ field: 'created_at', type: 'date', defaultValue: 'last_7_days' }`, so:
147
+
148
+ - `buildFilterCondition` fell through to its "a bare string date means equality
149
+ on that day" branch and the widget sent
150
+ `runtimeFilter: { created_at: 'last_7_days' }`. The backend compiled
151
+ `SELECT COUNT(*) AS "user_count" FROM "sys_user" WHERE created_at = $1`
152
+ — verified against a live server, byte-for-byte the SQL in the issue. The
153
+ actual `sys_user` count is 4; that equality matches no row.
154
+ - `DateRangeFilter` derives its selected item from `value.preset` / `.from` /
155
+ `.to`, all `undefined` on a bare string, so the control fell through to its
156
+ ALL sentinel and displayed "All time" while sending that equality. The tiles
157
+ therefore looked deliberately unfiltered and merely empty.
158
+
159
+ `normalizeDateDefault` now applies the same lift the sibling `dateRange`
160
+ declaration already receives, for `date`/`dateRange` filters whose default names
161
+ a preset this module actually knows. This is not consumer-side leniency: it is
162
+ one normalization function completing the same conversion for the sibling
163
+ declaration, and the spec admits no other spelling for an author to fix at the
164
+ producer. A genuine ISO date string still means equality on that day (the
165
+ documented behaviour), and numbers, booleans and unrecognised strings are left
166
+ exactly as declared.
167
+
168
+ No backend change is needed: given a real range the dataset path already lowers
169
+ it correctly (`WHERE (created_at >= $1 AND created_at < $2)` → 4). The
170
+ framework's dashboard metadata needs none either — it is spec-compliant as
171
+ written, and editing it would only hide the defect.
172
+
173
+ Levelled `minor` rather than `patch` because the change is visible in rendered
174
+ dashboards rather than internal: any dashboard declaring a date-typed
175
+ `globalFilters` default now emits a different query shape, its numbers change
176
+ (from 0 to real values), and its filter control's displayed label changes with
177
+ them. Anything asserting on the previously-emitted condition will see it move.
178
+
179
+ Known residual, filed separately rather than widened into here: a `date` filter
180
+ whose value is neither a known preset nor a parseable ISO date still degrades
181
+ silently to an equality that matches nothing, producing the same
182
+ healthy-looking zero. Preset names are covered by this change; a misspelled
183
+ custom value is not.
184
+
185
+ - 256f8cc: fix(dashboard): an unrecognised date filter value is skipped and named, not compared
186
+
187
+ The residual the preset-name fix (objectui#3150 / objectstack#4475) left behind,
188
+ and the more deceptive half of it: a `date`/`dateRange` filter value that is
189
+ neither a known preset name nor a parseable date used to fall through to the
190
+ "a bare string date means equality on that day" branch. A misspelled default —
191
+ `defaultValue: 'last_7_dayz'` — therefore reached the widget query as
192
+ `runtimeFilter: { created_at: 'last_7_dayz' }`, which the backend faithfully
193
+ compiled to `WHERE created_at = $1`. `200 OK`, widget renders, count is 0 —
194
+ indistinguishable from "this range genuinely has no data". No 4xx, no console
195
+ warning, no UI signal. objectstack#4475 took a full RC cycle to catch for
196
+ exactly this reason: **0 looks like a legitimate answer**.
197
+
198
+ `buildFilterCondition` now holds a date value to three spellings, and only
199
+ three:
200
+
201
+ 1. a known preset name → range bounds (unchanged, objectui#3150);
202
+ 2. an ISO date (`2026-01-15`, `2026-01-15T08:30:00Z`) or a date-macro token
203
+ (`{today}`, `{7_days_ago}`) → equality on that day (the documented
204
+ behaviour, unchanged);
205
+ 3. **anything else → the filter is skipped and `console.warn` names the filter,
206
+ the offending value, and the accepted spellings.**
207
+
208
+ The `{ preset: '<unknown>' }` object form gets the same voice. It already
209
+ dropped the filter — silently — because the preset lookup missed and no
210
+ `from`/`to` remained; that drop is now announced. When explicit bounds ride
211
+ along with an unknown preset the bounds are still honoured, and the warning says
212
+ which of the two won.
213
+
214
+ Rule 3 is deliberately the same strictness `buildWidgetScopedFilter` already
215
+ applies to a _default binding on a field the object does not have_ — skip and
216
+ warn, with the same rationale spelled out there: never emit a query the backend
217
+ can only empty-match. Field _names_ had that guard; field _values_ did not.
218
+
219
+ The macro-token check asks `resolveDateMacros` itself whether it recognises the
220
+ string, rather than restating its token grammar in a second place. One
221
+ vocabulary, no dialect to drift — and a token that resolver does not know
222
+ (`{last_7_dayz}`) is precisely the typo this guard exists to catch.
223
+
224
+ Levelled `minor`, matching objectui#3150, because the emitted query shape
225
+ changes: a dashboard carrying a misspelled date value stops sending a
226
+ never-matching equality and instead sends no constraint for that filter (its
227
+ numbers go from 0 to unfiltered) while the console says why. Anything asserting
228
+ on the previously-emitted equality will see it disappear.
229
+
230
+ Note the direction of the relaxation is chosen, not incidental: skipping widens
231
+ the result set, so the number visibly changes and the warning explains it —
232
+ whereas the old behaviour narrowed it to zero, which is the one outcome an
233
+ author cannot tell from a correct answer. Author-time rejection (validating
234
+ `GlobalFilterSchema.defaultValue` at publish, in `@objectstack/spec`) is the
235
+ stricter complement and belongs on the platform side; it is filed separately.
236
+
237
+ - d3584c6: Bring the whole `@objectstack` family to `17.0.0-rc.1`, so the dependency graph resolves a
238
+ single copy of `@objectstack/spec`.
239
+
240
+ #3178 bumped **only** `@objectstack/spec` to `17.0.0-rc.1`. The rest of the family —
241
+ `client`, `core`, `formula`, `lint` (and `sdui-parser`, reached through `lint`) — stayed on
242
+ `17.0.0-rc.0`, and each of them depends on spec at an **exact** version rather than a
243
+ caret:
244
+
245
+ ```
246
+ @objectstack/client@17.0.0-rc.0 -> spec "17.0.0-rc.0"
247
+ @objectstack/core@17.0.0-rc.0 -> spec "17.0.0-rc.0"
248
+ @objectstack/formula@17.0.0-rc.0 -> spec "17.0.0-rc.0"
249
+ @objectstack/lint@17.0.0-rc.0 -> spec "17.0.0-rc.0"
250
+ ```
251
+
252
+ So `main` carried **two** spec copies: objectui's own code read `17.0.0-rc.1` while every
253
+ `@objectstack/*` package read `17.0.0-rc.0` from its own nested `node_modules`. That breaks
254
+ the single-contract invariant this repo's guards are built on, and it breaks them
255
+ _silently_ — the affected checks depend on identity, not on version strings:
256
+
257
+ - `spec-subschema-parity.test.ts` distinguishes a genuine re-export from a fork by
258
+ **reference identity** of the zod schema object. Two spec copies make every schema a
259
+ distinct object, so a real re-export starts reading as a fork (or a fork slips through,
260
+ depending on which copy each side resolved).
261
+ - `scripts/check-spec-symbol-derivation.mjs` and `spec-symbol-parity.test.ts` use
262
+ `createRequire` to resolve spec's `.d.ts` and run it through the TS checker. With two
263
+ copies installed, _which_ declaration file the checker sees is a function of resolution
264
+ order rather than of intent.
265
+
266
+ The declared ranges were already `^17.0.0-rc.0`, which technically admits rc.1 — the pin
267
+ lived in the lockfile. Raising the remaining ranges to `^17.0.0-rc.1` makes the floor
268
+ explicit and forbids a future install from silently sliding back onto a family member that
269
+ drags rc.0 along with it. The rc.1 family members pin spec at `17.0.0-rc.1` exactly, so the
270
+ graph now converges on one copy by construction, not by luck.
271
+
272
+ No product behaviour changes here. `check:spec-symbols` reconciliation was already
273
+ completed by #3178 and stays green under the unified graph; this changeset is `minor`
274
+ per the repo's fixed-group version policy.
275
+
276
+ - 444457c: feat!: follow the framework's `managedBy: 'system'` → `'system-data'` retirement (objectstack#3355)
277
+
278
+ **FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** The framework
279
+ retired the residual `system` bucket in protocol 17; this is the UI half of that
280
+ change, landing with it so the closed `ManagedByBucket` union stays a mirror
281
+ rather than a fork.
282
+
283
+ ADR-0103 split the overloaded `system` bucket additively in v16 — the
284
+ engine-owned objects moved to the explicit `engine-owned`, the admin/user-writable
285
+ ones stayed on `system` — which left that value named after the half that had
286
+ already moved out. `system-data` names what it actually holds: the SCHEMA is the
287
+ platform's, the DATA is the admin's or the user's.
288
+
289
+ **The derivation this deletes is the point.** Because v16's `system` doubled as
290
+ both the engine-owned default and the writable set, three UI surfaces had to
291
+ RECOVER the distinction from `userActions` at render time:
292
+
293
+ - `isSystemWritable()` probed `userActions` for any opted-in write. It is now
294
+ `managedBy === 'system-data'` — the bucket answers directly.
295
+ - `ManagedByBadge` derived a synthetic `'system-writable'` variant key. The
296
+ variant map is now 1:1 with the bucket union, so a new bucket is a compile
297
+ error to miss instead of a silent fallthrough. The `systemWritable` /
298
+ `system` i18n keys are **unchanged**, so no locale bundle moves.
299
+ - `resolveManagedByEmptyState()` asked the resolved `create` affordance whether a
300
+ `system` list should read "entries appear automatically" or show the New
301
+ button. `system-data` now falls through to the generic empty state by
302
+ definition; `engine-owned` keeps the automatic-entries copy.
303
+
304
+ **Breaking (UI API):** `ManagedByBadge`'s `userActions` prop and the exported
305
+ `ManagedByUserActions` interface are **removed**. The bucket alone selects the
306
+ variant now, so the prop had become metadata nothing read — the exact defect the
307
+ framework change exists to remove; shipping it as an accepted-but-ignored prop
308
+ would have reproduced it one layer up. Drop the prop from call sites; no other
309
+ change is needed.
310
+
311
+ `MANAGED_BY_BUCKETS` and `ManagedByBucket` no longer contain `'system'`.
312
+
313
+ - 850033c: Stop offering the retired `action.shortcut` / `action.bulkEnabled` keys.
314
+
315
+ `@objectstack/spec` 17 retired both as `retiredKey()` tombstones: authoring
316
+ either one is a hard PARSE REJECTION, so a draft carrying it cannot be saved
317
+ at all. The designer still offered controls for both — a "Bulk — apply to
318
+ multiple selected rows" checkbox and a "Shortcut" text field — which meant the
319
+ Studio action inspector let an author build a draft the platform would then
320
+ refuse, with the rejection arriving later and nowhere near the checkbox.
321
+
322
+ - **Action inspector**: both controls removed. The keys stay hidden from the
323
+ fallback form (the server's live schema still advertises them, so dropping
324
+ them from the hidden list would put the inputs straight back) — now under a
325
+ `RETIRED_FIELDS` list that says why, so nobody "restores the missing
326
+ control". `bulkEnabled`'s replacement is the list view's `bulkActions` /
327
+ `bulkActionDefs`; `shortcut` has none.
328
+ - **Action preview**: the `shortcut` and `bulk` pills are gone — they could
329
+ only ever render for metadata the platform now refuses.
330
+ - **`ActionEngine.registerActions`**: no longer harvests the two retired keys
331
+ from authored metadata, which made two dead registration options look
332
+ load-bearing. Both are still accepted on the single-action
333
+ `registerAction(action, options)` overload, where a HOST passes them
334
+ explicitly.
335
+
336
+ - 009e25d: Report / chart / query symbols stop wearing `@objectstack/spec`'s names
337
+ (objectui#3155, objectstack#4115).
338
+
339
+ **Breaking for TypeScript imports** — six exported names change. Each was a
340
+ different concept than the spec export it collided with, so an author reading
341
+ the objectui declaration as "the spec's" was reading a false claim:
342
+
343
+ | was | now | why they were never the same thing |
344
+ | :------------------ | :----------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- |
345
+ | `ChartSeries` | `ChartDataSeries` | ours is a display name plus literal `data: number[]`; the spec's is a dataset-bound series descriptor (`type`/`stack`/`yAxis`/`variant`) with no data at all |
346
+ | `ChartSeriesSchema` | `ChartDataSeriesSchema` | zod twin of the above |
347
+ | `QueryAST` | `SqlQueryAST` | ours is a compiled SQL syntax tree (`select`/`from`/`join`/`group_by`); the spec's is the ObjectQL request descriptor (`object`/`fields`/`where`/`expand`) |
348
+ | `QuerySchema` | `DriverQueryConfig` | ours is the high-level config `QueryASTBuilder` compiles; the spec exports that name as a zod schema value |
349
+ | `DriverInterface` | `SqlDriverInterface` | ours is objectui's SQL-oriented client abstraction (`query(sql, params)`); the spec's is the platform runtime driver contract |
350
+ | `DatasourceSchema` | `DatasourceRegistration` | ours is the in-memory record `DatasourceManager` holds — its `driver` is a live instance; the spec's is the authored metadata document, where `driver` is a name |
351
+
352
+ Three more are now DERIVED from the spec instead of hand-restated, which fixes
353
+ live silent-stripping defects, since a `z.object()` drops unknown keys:
354
+
355
+ - **`DashboardWidgetSchema`** declared 10 of the spec's 22 keys, so
356
+ `objectui validate` deleted the other 12 without a word — `chartConfig`,
357
+ `colorVariant`, `filter`, `responsive`, `aria`,
358
+ `actionUrl`/`actionType`/`actionIcon`, `compareTo`, `suppressWarnings` and the
359
+ `requiresObject` / `requiresService` capability gates the dashboard renderer
360
+ honours at runtime. The TS interface had declared most of them all along, so a
361
+ widget could type-check and still lose half its configuration on validation.
362
+ Pinned divergences kept: `id` stays optional, `type` stays widened for the
363
+ objectui-only `list` / `custom` families, and the legacy `component` envelope
364
+ stays.
365
+ - **`GlobalFilterSchema`** took `scope` as a free-form string (any typo
366
+ validated); it now uses the spec's `widget | dashboard` vocabulary. The three
367
+ objectui widenings that back a real runtime normalizer are kept and pinned:
368
+ the bare-string `options` shorthand, the normalized `{ preset }` date default,
369
+ and an optional `optionsFrom.labelField`.
370
+ - **`AppContextSelectorSchema`** was a full restatement; spec keys and their
371
+ defaults now flow in by reference, with `label` widened for objectui's i18n
372
+ label envelope — which `AppContextSelectors` already renders.
373
+
374
+ `ListViewSchema`'s zod node now names the spec in its own initializer rather
375
+ than one hop away through a local const, so its long-standing derivation is
376
+ visible where it is declared.
377
+
378
+ Drift guard: `packages/types/src/__tests__/report-chart-query-spec-parity.test.ts`.
379
+
380
+ - 726b89c: `@object-ui/types` stops declaring sixteen symbols under names `@objectstack/spec` owns (objectui#3156, objectstack#4115).
381
+
382
+ Seven are now **derived** from the spec, nine are **renamed** to the local
383
+ dialect they always were. Both halves remove the same hazard: a local
384
+ declaration under a spec export's name reads as the spec's own definition to
385
+ the next reader, so a copy that is merely _correct today_ is a planted premise
386
+ tomorrow.
387
+
388
+ **Derived** — the spec now supplies the keys, by reference:
389
+
390
+ | symbol | derivation |
391
+ | :----------------------- | :-------------------------------------------------------------------------------- |
392
+ | `ActionParam` | `z.input<typeof ActionParamSchema>`, `type` widened to the local legacy spellings |
393
+ | `CreateExportJobRequest` | `Omit<CreateExportJobInput, 'object'>` (`object` is the method argument) |
394
+ | `CreateExportJobResult` | re-export from `@objectstack/spec/contracts` |
395
+ | `ImportRowResult` | re-export from `@objectstack/spec/api` |
396
+ | `NavigationArea` | spec keys, with `navigation` / `visible` pinned locally |
397
+ | `NavigationAreaSchema` | `specFieldsExcept(NavigationAreaSchema.shape, …)` |
398
+ | `Theme` | re-export of the spec's `ThemeInput` (the authoring shape) |
399
+ | `ExportJobFormat` | re-export of the spec's `ExportFormat` |
400
+
401
+ Four of these close real gaps rather than tidy names. `ActionParam` never
402
+ declared `reference` — the key `resolveActionParams()` actually reads for an
403
+ inline lookup target — nor `defaultFromRow`, which the metadata designer's own
404
+ inspector writes; it also narrowed `visible` to a bare string although the
405
+ resolver has always accepted the `{ dialect, source }` envelope too.
406
+ `CreateExportJobResult.createdAt` and `ImportRowResult.action` were optional
407
+ here and required by the server, leaving every consumer a branch that could
408
+ never run. And `NavigationArea`'s `id` now carries the spec's own length rule
409
+ instead of accepting any string.
410
+
411
+ **Renamed** — same word, different concept:
412
+
413
+ | was | now | why |
414
+ | :----------------- | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------- |
415
+ | `FileMetadata` | `UploadedFileMetadata` | field-VALUE payload (`url`, `original_name`), not the storage file record |
416
+ | `GestureType` | `TouchGestureType` | direction-fused (`swipe-left`), not the spec's type+direction pair |
417
+ | `GestureConfig` | `TouchGestureConfig` | gesture→`action` binding, not per-gesture tuning |
418
+ | `OfflineConfig` | `PWAOfflineConfig` | service-worker route caching, not the offline data/sync model |
419
+ | `PageRegion` | `PageNodeRegion` | region of the renderer page NODE, holding `SchemaNode`s |
420
+ | `PageRegionSchema` | `PageNodeRegionSchema` | zod twin of the above |
421
+ | `ResponsiveConfig` | `MobileResponsiveConfig` | mobile box config, not the spec's SDUI grid contract |
422
+ | `WidgetManifest` | `RuntimeWidgetManifest` | SDUI component manifest, not the field-widget plugin manifest |
423
+ | `WidgetSource` | `RuntimeWidgetSource` | `module`/`inline`/`registry` loader union — and its `inline` carries a resolved component where the spec's carries source code |
424
+
425
+ **Migration**: the old names are gone, not deprecated — an alias would preserve
426
+ exactly the ambiguity being removed. Import the new name; nothing about the
427
+ shapes changed. `@object-ui/types` already re-exports the spec's own
428
+ `SpecResponsiveConfig`, and `@object-ui/react`'s `useOffline` config remains the
429
+ spec-shaped `OfflineConfig`, so both concepts stay reachable under
430
+ distinguishable names.
431
+
432
+ Each rename carries a bidirectional tripwire
433
+ (`packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts`): it fails if
434
+ the spec ever claims the new name, and also if the spec retires the old one —
435
+ at which point the natural name can be taken back rather than the workaround
436
+ outliving its reason.
437
+
438
+ ### Patch Changes
439
+
440
+ - d9668a7: Honor the server's declared percent scale, so a ratio of exactly 1 renders as 100.0% (#3136)
441
+
442
+ A dataset measure declared `format: '0.0%'` rendered every ratio below 1
443
+ correctly and got the single most consequential one wrong: a rate of exactly
444
+ `1` printed as **`1.0%`**. On an SLA / pass-rate dashboard that turns
445
+ "everything met the SLA" into "1% met the SLA", on both surfaces the issue
446
+ names — the KPI card and the dataset-bound table (they share `formatMeasure`).
447
+
448
+ The cause was never a bad multiplier; it was a missing fact. `formatMeasure`
449
+ scaled by magnitude — `percentDisplayValue` multiplies by 100 only strictly
450
+ inside `(-1, 1)` — because the column arrived with a `%` format string and
451
+ nothing saying what scale its numbers were on. That guess is undecidable at
452
+ exactly 1, which is both a full-compliance ratio ("100%") and one percentage
453
+ point ("1%"), and it resolved to the reading almost nobody means.
454
+
455
+ The server now answers the question instead (framework: `percentScaleOf` +
456
+ `AnalyticsResult.fields[].percentScale`, the sibling of the ADR-0053 currency
457
+ chain): a `derived: { op: 'ratio' }` measure is a `fraction` by definition, and
458
+ a measure over a `percent` field inherits that field's scale. `formatMeasure`
459
+ takes the declared scale as a fourth argument and, when present, scales by it —
460
+ `fraction` ×100, `whole` verbatim — instead of inspecting the value. Every
461
+ dataset-bound call site passes the column's `percentScale`: the dashboard
462
+ metric/table/pivot cells, the report renderer's cells, totals and KPI, and the
463
+ dataset preview.
464
+
465
+ `percentDisplayValue` is untouched and still the fallback for a column that
466
+ arrives without the annotation (an older server, or a non-dataset percent cell
467
+ in a list view), so nothing that renders correctly today changes.
468
+
469
+ - a8ad6c0: A required boolean must be savable in its UNCHECKED state — `false` and `0` are values.
470
+
471
+ Reported against an AI-built task tracker whose 任务 object has a required
472
+ `是否完成` boolean: the create form showed the switch OFF, answered "是否完成不能
473
+ 为空", and saved instantly once the switch was turned ON. The app could only ever
474
+ create ALREADY-DONE tasks — the one state the control shows by default was the
475
+ one value it refused to save (cloud#972).
476
+
477
+ Two defects stacked, and either alone is enough to break it:
478
+
479
+ **The `required` verdict read truthiness, not presence.** `@objectstack/spec`
480
+ FieldSchema.required (ADR-0113) is "an insert must provide a NON-NULL value",
481
+ and objectql's record validator implements exactly that. react-hook-form's
482
+ built-in rule instead fails whenever `isBoolean(value) && !value` — its
483
+ accept-the-terms checkbox heritage — silently redefining every required boolean
484
+ as "must be TRUE", including a select whose chosen option value is `false`. It
485
+ also disagreed the other way, letting a whitespace-only string through for the
486
+ server to reject with a 400. The form renderer no longer hands RHF its own
487
+ `required`: the check is now a `validate` entry keyed `required` (so the error
488
+ still surfaces as `type: 'required'`, which the conditional-required cleanup
489
+ keys on) backed by a new shared `isMissingForRequired` in `@object-ui/core`, a
490
+ deliberate mirror of objectql `record-validator.isMissing` — `undefined`,
491
+ `null`, blank-after-trim string, empty array. Deleting the inherited rule also
492
+ stops a `required` that rode in on `validation` from outliving a `requiredWhen`
493
+ that resolved to FALSE.
494
+
495
+ **A boolean field held `undefined` while displaying "off".** A two-state control
496
+ has no third state, but a field with no entry in `defaultValues` rendered an OFF
497
+ switch backed by nothing: the create payload omitted the column (it lands null,
498
+ which reads as unchecked but isn't) and the presence check above would still
499
+ refuse it. The form renderer now folds `false` into `defaultValues` for every
500
+ boolean-widget field the caller left unset — in `defaultValues` itself, not
501
+ per-Controller, because that object is also the dirty-check baseline and what
502
+ the defaults-reset window replays. Every surface gets it, including the
503
+ modal/drawer create dialogs that start from a bare `{}`. An authored default
504
+ (or a loaded record, `null` included) still wins.
505
+
506
+ `WizardForm`'s cross-step gate had its own copy of the empty-value predicate; it
507
+ now imports the shared one so it cannot drift from the per-field verdict. And
508
+ the field-demo renderer read `schema.defaultValue || schema.value`, throwing
509
+ away an authored default of `false` / `0` / `''` — same falsy-as-empty class,
510
+ now `??`.
511
+
512
+ Verified end to end on a local stack against the exact metadata shape
513
+ `apply_blueprint` materializes (`{ type: 'boolean', required: true }`, no
514
+ default): a 是否完成 = 否 task with 工时 = 0 now creates and persists as
515
+ `{ hours: 0, is_done: false }`, turning the switch on still stores `true`, and a
516
+ blank required text is still refused.
517
+
518
+ - Updated dependencies [4ae0ac4]
519
+ - Updated dependencies [696e3c1]
520
+ - Updated dependencies [4bf612c]
521
+ - Updated dependencies [cb82705]
522
+ - Updated dependencies [f572849]
523
+ - Updated dependencies [444457c]
524
+ - Updated dependencies [022e4c3]
525
+ - Updated dependencies [009e25d]
526
+ - Updated dependencies [726b89c]
527
+ - @object-ui/types@17.2.0
528
+
3
529
  ## 17.1.0
4
530
 
5
531
  ### Minor Changes
@@ -78,7 +78,17 @@ export declare class ActionEngine {
78
78
  bulkEnabled?: boolean;
79
79
  priority?: number;
80
80
  }): void;
81
- /** Register multiple actions from an ActionSchema array */
81
+ /**
82
+ * Register multiple actions from an ActionSchema array.
83
+ *
84
+ * Only `locations` is harvested from the metadata. `shortcut` and
85
+ * `bulkEnabled` used to be read here too, but spec 17 retired both as
86
+ * `retiredKey()` tombstones — a declaration carrying either one no longer
87
+ * parses, so harvesting them read a key that cannot exist and made two dead
88
+ * options look load-bearing. Both remain accepted on the single-action
89
+ * `registerAction(action, options)` overload, where a HOST passes them
90
+ * explicitly; they are simply no longer sourced from authored metadata.
91
+ */
82
92
  registerActions(actions: ActionDef[]): void;
83
93
  /** Unregister an action by name */
84
94
  unregisterAction(name: string): void;
@@ -110,13 +110,24 @@ export class ActionEngine {
110
110
  this.normalizedShortcutMap.set(normalizeShortcut(options.shortcut), name);
111
111
  }
112
112
  }
113
- /** Register multiple actions from an ActionSchema array */
113
+ /**
114
+ * Register multiple actions from an ActionSchema array.
115
+ *
116
+ * Only `locations` is harvested from the metadata. `shortcut` and
117
+ * `bulkEnabled` used to be read here too, but spec 17 retired both as
118
+ * `retiredKey()` tombstones — a declaration carrying either one no longer
119
+ * parses, so harvesting them read a key that cannot exist and made two dead
120
+ * options look load-bearing. Both remain accepted on the single-action
121
+ * `registerAction(action, options)` overload, where a HOST passes them
122
+ * explicitly; they are simply no longer sourced from authored metadata.
123
+ */
114
124
  registerActions(actions) {
115
125
  for (const action of actions) {
116
126
  this.registerAction(action, {
127
+ // No cast: `locations` is a declared `ActionDef` field as of
128
+ // objectstack#4075 step 2. The `as any` it replaces existed only
129
+ // because the key reached this reader through the index signature.
117
130
  locations: action.locations,
118
- shortcut: action.shortcut,
119
- bulkEnabled: action.bulkEnabled,
120
131
  });
121
132
  }
122
133
  }