@object-ui/core 17.2.0 → 17.3.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +29 -0
  3. package/dist/actions/ActionEngine.d.ts +11 -6
  4. package/dist/actions/ActionEngine.js +26 -19
  5. package/dist/actions/ActionRunner.d.ts +16 -3
  6. package/dist/actions/ActionRunner.js +2 -1
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +1 -1
  10. package/dist/actions/actionKeys.js +11 -0
  11. package/dist/actions/actionResponse.d.ts +42 -0
  12. package/dist/actions/actionResponse.js +104 -0
  13. package/dist/actions/index.d.ts +3 -0
  14. package/dist/actions/index.js +3 -0
  15. package/dist/actions/serverActionHandler.d.ts +143 -0
  16. package/dist/actions/serverActionHandler.js +137 -0
  17. package/dist/evaluator/ExpressionEvaluator.js +4 -2
  18. package/dist/evaluator/fieldRules.d.ts +26 -2
  19. package/dist/evaluator/fieldRules.js +63 -8
  20. package/dist/evaluator/index.d.ts +1 -0
  21. package/dist/evaluator/index.js +1 -0
  22. package/dist/evaluator/listConditional.d.ts +13 -1
  23. package/dist/evaluator/listConditional.js +0 -0
  24. package/dist/evaluator/optionRules.js +5 -1
  25. package/dist/evaluator/predicateInput.d.ts +84 -0
  26. package/dist/evaluator/predicateInput.js +72 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +12 -0
  29. package/dist/protocols/index.d.ts +0 -1
  30. package/dist/protocols/index.js +8 -1
  31. package/dist/utils/dataset-format.d.ts +6 -1
  32. package/dist/utils/dataset-pivot.d.ts +57 -0
  33. package/dist/utils/dataset-pivot.js +57 -0
  34. package/dist/utils/filter-converter.d.ts +39 -11
  35. package/dist/utils/filter-converter.js +87 -7
  36. package/dist/utils/predicate-fields.d.ts +96 -0
  37. package/dist/utils/predicate-fields.js +163 -0
  38. package/dist/utils/predicate-record.d.ts +32 -0
  39. package/dist/utils/predicate-record.js +120 -0
  40. package/dist/utils/record-title.js +0 -0
  41. package/package.json +4 -4
  42. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  43. package/dist/protocols/NotificationProtocol.js +0 -89
package/CHANGELOG.md CHANGED
@@ -1,5 +1,217 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 17.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5781fb1: `@object-ui/core` now ships the server-action dispatcher factory —
8
+ `createServerActionHandler({ fetch, baseUrl, resolveObject, ... })` — so any
9
+ consumer of the runner (standalone renderers, SDUI hosts, embedded usage) can
10
+ run `action.body` script actions by registering the produced handler, instead
11
+ of dead-ending on the built-in `executeScript`'s "must be executed server-side"
12
+ error with no supported way to make it run (objectui#2904, the follow-up
13
+ objectui#2896 deferred).
14
+
15
+ The factory is deliberately opinion-free about the three things core has no
16
+ business deciding — auth (`fetch` is an injected authenticated wrapper), origin
17
+ (`baseUrl` string or thunk; no bundler env convention), and fallback object
18
+ scope (`resolveObject`) — and owns everything protocol-shaped, once:
19
+
20
+ - name-based action identity (ADR-0110 D1 — `target` is a binding expression,
21
+ never an identity);
22
+ - the record-id resolution dance, also exported as
23
+ `resolveServerActionRecordId` (`_rowRecord`, `recordIdField`, toolbar
24
+ selection fallback with its single/zero-select guards, aggregate
25
+ `_selectedIds` bypass), replaceable wholesale via `resolveRecordId` for
26
+ hosts with their own policy (record pages);
27
+ - a re-entrancy guard per action+record;
28
+ - the `/actions` response-envelope rule: `interpretActionResponse`,
29
+ `readActionPayload` and `actionErrorDetail` moved from `@object-ui/app-shell`
30
+ internals into core and are now public exports.
31
+
32
+ `@object-ui/app-shell`'s two hand-rolled copies of this POST —
33
+ `useConsoleActionRuntime.serverActionHandler` and `RecordDetailView`'s — are
34
+ collapsed into one console wrapper (`createConsoleServerActionHandler`) that
35
+ layers the browser-only choreography (popup pre-open dance, zero-roundtrip
36
+ `newTabUrl` fast path, `redirectUrl` convention) over the core factory. The
37
+ copies had already drifted twice (objectstack#3913 — envelope; framework#3935 —
38
+ identity, fixed in one copy only): RecordDetailView now also dispatches by
39
+ declarative `name` instead of `target || name`, and no longer leaks the
40
+ client-side `_rowRecord` stash to the server.
41
+
42
+ - f833d3a: Retire `validation` from the action-param contract — it was declared on both
43
+ halves, read by neither, and rejected outright by the server (objectui#3201).
44
+
45
+ FROM: `validation?: string` was declared on the AUTHORING type
46
+ (`@object-ui/types`' `ActionParam`) and on the RESOLVED type (`@object-ui/core`'s
47
+ `ActionParamDef`). TO: it is declared on neither.
48
+
49
+ **Breaking for anyone who declared it — but it never did anything.** This is
50
+ marked `minor`, not `major`, per the repo's version-alignment policy (objectui's
51
+ major tracks `@objectstack`'s, so objectui's own breaking changes ship as `minor`
52
+ with the breaking semantics spelled out here).
53
+
54
+ **Migration: delete it.** If you authored `validation: '...'` on an action param,
55
+ it never took effect, and publishing that metadata to the server is a hard parse
56
+ failure — so any metadata that reached production either never carried the key or
57
+ never parsed. Removing it changes no runtime behaviour; it only moves the error
58
+ from "silent no-op, then rejected at publish" to a `tsc` error at the keystroke.
59
+
60
+ Why it could not work as authored:
61
+
62
+ - `ActionParamSchema` in `@objectstack/spec/ui` is `.strict()` and does not list
63
+ `validation`, so an authored key is a PARSE REJECTION on the server:
64
+ `Unrecognized key(s) on this action param: \`validation\``. Meanwhile `tsc`
65
+ against the public type accepted it — the type vouched for a key the platform
66
+ itself refuses.
67
+ - Nothing read it on the resolved side either: it was never a key of
68
+ `resolveActionParams()`'s `RawActionParam`, the runtime field metadata a
69
+ field-backed param inherits from carries no `validation` to source one from,
70
+ and `paramToField()` never mapped it — so it could not reach the field widgets,
71
+ whose rules `buildValidationRules()` builds from `required` / `minLength` /
72
+ `maxLength` / `pattern`.
73
+
74
+ Removed rather than implemented, on ADR-0049 enforce-or-remove. Giving it meaning
75
+ would mean first deciding what an "expression" is here (CEL? a formula? a regex?)
76
+ and adding it to `@objectstack/spec`, which is where such a capability has to
77
+ start — not accreted renderer-side around a key the contract does not have.
78
+
79
+ This also retires the last named exception in objectui#3174's drift guard
80
+ (`packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts`), which carried
81
+ `validation` as the one key `ActionParam` added on top of the spec's set. The
82
+ rule it pins — **the authoring type declares exactly the spec's authorable
83
+ keys** — is now literal: the guard asserts the local-only key set is empty, so
84
+ any future addition fails the build instead of being waved through.
85
+
86
+ - d22ae31: Track `@objectstack/spec` 17.0.0-rc.2 (objectui#3235, #3208, #3287, #3264).
87
+
88
+ The pin moves from `^17.0.0-rc.1` to `^17.0.0-rc.2` across the workspace, and
89
+ the sibling `@objectstack/*` packages (`client` / `core` / `formula` / `lint`)
90
+ move with it — they pin `@objectstack/spec` **exactly**, so leaving them behind
91
+ kept a second copy of the spec in the tree and would have had `@objectstack/lint`
92
+ validating against rc.1 schemas that still accept keys rc.2 retires.
93
+
94
+ Breaking semantics, in FROM → TO form:
95
+
96
+ - **`app.homePageId` is retired — an app's landing page is now its first
97
+ navigation item.** An app that pinned a landing page with `homePageId` will
98
+ open on the first reachable navigation entry (by `order`) instead; the root
99
+ landing still follows `isDefault`. To restore a specific landing page, reorder
100
+ `navigation` so the intended entry comes first. Stored metadata is migrated by
101
+ `os migrate meta --from 16`. The key is a hard error now, not a stripped one:
102
+ the spec ships a tombstone that names the migration.
103
+ Upstream retired it because of its SHAPE, not its usage — it was an ID
104
+ cross-reference with no referential integrity, so a `homePageId` that pointed
105
+ at nothing silently fell back to the first navigation item anyway
106
+ (objectstack#4667, premise corrected in #4709). If the capability returns, it
107
+ returns as a flag on the navigation item itself, which cannot dangle.
108
+ - **`@object-ui/types`' `HttpMethod` now resolves to the spec's
109
+ `HttpMethodType`.** Shape is verbatim identical — the same 5-value UI subset —
110
+ and `@object-ui/types` still exports it as `HttpMethod`, so no consumer
111
+ changes. The spec renamed its `./ui` export because `HttpMethod` named two
112
+ different types depending on the import path (`./shared` / `./api` carry a
113
+ 7-value enum including `HEAD` / `OPTIONS`); objectui deliberately keeps the
114
+ 5-value one (objectstack#4691).
115
+ - **`AppContextSelector.includeAll` / `placement` are gone.** Neither ever did
116
+ anything in this renderer: context selectors are mandatory-scope, so no "All"
117
+ row was ever rendered, and `placement: 'topbar'` put nothing in the topbar.
118
+ Both carried schema defaults, which is why the liveness lint structurally
119
+ could not flag them — removal was the only channel that reaches an author
120
+ (framework#4509).
121
+ - **`NavigationArea.visible` / `order` / `requiredPermissions` are gone.** An
122
+ area is a layout grouping, not an access boundary. Gating moved down to the
123
+ navigation ITEM, where `visible` and `requiredPermissions` are unchanged and
124
+ still enforced. `AppSchemaRenderer`'s area switcher no longer hides an area, so
125
+ an area whose items are all gated away renders as visible-but-empty rather
126
+ than disappearing.
127
+ - **`@object-ui/core` no longer exports `NotificationProtocol`**
128
+ (`resolveNotificationConfig`, `specNotificationToToast`, `mapSeverityToVariant`,
129
+ `mapPosition`, `ToastNotification`). It bridged `@objectstack/spec/ui`'s
130
+ `Notification` / `NotificationConfig`, which objectstack#4610 deleted with no
131
+ successor. Use `resolveNotificationConfig` from `@object-ui/react`
132
+ (`NotificationContext`), which owns the live `NotificationSystemConfig` and is
133
+ what every notification surface already read. Note that the spec's _other_
134
+ `Notification` — `@objectstack/spec/api` — is the REST inbox row, a different
135
+ contract, and is deliberately NOT aliased in as a replacement.
136
+ - **The `email_template` client-side validator now uses
137
+ `EmailTemplateDefinitionSchema`.** It was pointing at the removed
138
+ `EmailTemplateSchema`, so authored templates were being checked against the
139
+ wrong contract: the live one is keyed `name` + `locale` (not `id`) and splits
140
+ the body into `bodyHtml` / `bodyText` (not `body` + `bodyType`)
141
+ (objectstack#4616 / #4807).
142
+
143
+ Fixes that are not breaking, but were only found because rc.2 stopped being
144
+ lenient — each had been passing vacuously:
145
+
146
+ - **`view` drafts are actually validated now.** The client validator named the
147
+ aggregated container schema while this admin authors first-class `ViewItem`s,
148
+ and the container used to strip `viewKind` / `config` in silence — so no view
149
+ draft ever had one of its own keys checked. It now validates each shape
150
+ against its own schema (objectui#3312).
151
+ - **The console's worked examples were wrong**, and being stripped rather than
152
+ refused: `view.list.object` (the container root already declares it),
153
+ `job.concurrency` / `job.timeoutMs` (no such keys; the spelling is `timeout`,
154
+ already in ms), `email_template.from` / `.to` (a template is not a send —
155
+ the sender override is `fromOverride`, an object), and
156
+ `datasource.capabilities` / `.healthCheck` (objectstack#4583 removed the
157
+ former; the latter was never a datasource key). These are the drafts an
158
+ author — or a model generating metadata — copies.
159
+ - Action key inventory re-derived: `ActionSchema` gained the package-lock
160
+ envelope (`_lock*` / `_package*` / `_provenance`), so a packaged action no
161
+ longer reports them as unknown keys.
162
+ - The schema-diff panel labels the new `default_mismatch` finding.
163
+ - Test fixtures pinning the retired `managedBy: 'system'` bucket now use
164
+ `engine-owned`. Protocol 17 split that value (objectstack#3355), so it
165
+ resolved to the default-writable fallback and a batch of "stays locked"
166
+ assertions had quietly stopped asserting anything.
167
+
168
+ ### Patch Changes
169
+
170
+ - 18cd432: `ActionEngine.getActionsForLocation` now evaluates a `{ dialect: 'cel', source }` action `visible` predicate on the canonical `@objectstack/formula` engine instead of the legacy JS evaluator. The method used to unwrap the envelope into a `${source}` string before calling `evaluateCondition`, which only routes to the CEL engine while its argument is still an envelope — so the engine silently demoted every CEL predicate to the legacy path while every action renderer (`action-button`, `action-menu`, `action-bar`, … via `toPredicateInput`) ran the same predicate on CEL (#2661). Because `ExpressionInputSchema` normalizes even a bare authored string into a CEL envelope, that was the common case, and the two engines disagree: `null < null` faults in CEL (fail-closed hide) but is `false` in JS, and CEL-only builtins such as `today()` do not exist on the JS path at all — so one `visible:` predicate could hide a button in the renderer while the engine kept it (or the reverse). Normalization now goes through a shared `toPredicateInput` helper exported from `@object-ui/core`, pinned to the renderer-side twin by a parity suite. Bare strings, non-`cel` dialects, empty `source`, and boolean predicates all behave exactly as before (#3314).
171
+ - d915c47: Relation fields (`lookup` / `master_detail` / `user` / `tree`) are now usable in action and conditional-formatting predicates: they bind as the stored foreign key on every surface, and the fields a predicate reads are included in the query projection (#3501).
172
+
173
+ Before this, one predicate over one relation field had four different fates, decided by things its author does not control. `$expand` **replaces** the id in place with the whole related record, and a view expands exactly the relations it shows as COLUMNS — so `record.owner == "U1"` was **true** where the column was absent, **false** where it was displayed, and a **fault** where the field was neither displayed nor projected (a list's `$select` was built from its columns alone, and CEL treats an absent key as a fault, not as null). A fault is fail-CLOSED on the row kebab and the selection bar and fail-OPEN on the lenient paths, so the same authoring mistake hid the button from everyone on one surface and showed it to everyone on the next, with nothing on screen to point at either. The server, meanwhile, only ever sees the id — so client and server could not agree, which is the one thing ADR-0036 / ADR-0058 exist to guarantee.
174
+
175
+ Two changes close it. `toPredicateRecord` (new, `@object-ui/core`) collapses expanded relation values back to their ids when a record is bound for evaluation — driven by the object's own field types, not by sniffing for an `id` key, so a `json` field that happens to carry one is untouched. It is threaded through `evalRowPredicate` / `resolveConditionalFormatting` (via a new `fields` option), `useRowPredicate`, `partitionBulkRows`, and both `page:header` evaluators, with the object schema supplied by `ObjectGrid` / `ListView` / `ObjectKanban` / the record context. Kanban card formatting is threaded the same way, so a rule cannot match on the grid view of a list and silently never match on its board. Display is unaffected — a detail-page title still renders the related record's name, and the schema-only `kanban-ui` entry point (which has no object schema to offer) keeps using the payload verbatim. `collectPredicateFieldRefs` / `listViewPredicates` (new) harvest the `record.x` / `data.x` references out of a view's conditional formatting, row-action defs, bulk-action defs, promoted object actions and `userActions` overrides, and add them to `$select` — intersected with the object's declared fields plus the platform columns every object carries (`isProjectableField`), because an unknown key is not ignored by every backend. No `$expand` is added: a predicate wants the foreign key, which is what an unexpanded relation already is.
176
+
177
+ - 509104a: Fix matrix report cells showing another bucket's numbers when dimension values run together.
178
+
179
+ The cross-tab in `DatasetReportRenderer` built its bucket ids by joining dimension values with the EMPTY string, so adjacent values had no boundary at all: `"x"` + `"yz"` and `"xy"` + `"z"` were the same bucket on both axes, and the later row silently overwrote the earlier one. Its cell key then joined the two bucket ids with a plain space, while dimension values contain spaces constantly ("New York", "In Progress"), so `"New"` × `"York Q1"` and `"New York"` × `"Q1"` also met in one key. A merged bucket showed a different row's measure, the overwritten row's value was unreachable, the per-row and per-column subtotals matched the wrong header, and drill-through followed the same wrong index into another record's list — none of it with an error.
180
+
181
+ Bucket ids and cell keys are now encoded with `JSON.stringify`, which carries the boundary in its own quoting rather than in a character the data is assumed never to contain. All four lookups in the renderer (row headers, column headers, row subtotals, column subtotals) share the one encoder, so they agree by construction.
182
+
183
+ The encoders moved to `@object-ui/core` as `pivotBucketId` / `pivotCellKey` and are now shared with the dashboard `DatasetWidget`, which carried the same defect and fixed it separately: two packages each hand-rolling the same key is why one fix left the other broken. The dashboard keeps its existing exports and behaviour.
184
+
185
+ - a4cff5b: Conditional-rule predicates that fail to evaluate are no longer silent
186
+ (objectstack#5149, appeal 2). `evalFieldPredicate` — the canonical funnel for
187
+ `visibleWhen` / `readonlyWhen` / `requiredWhen`, view-level `visibleOn`, legacy
188
+ `condition`, per-option `visibleWhen`, screen-field predicates and list
189
+ conditional formatting — now logs **one `console.warn` per predicate text**
190
+ when evaluation fails (parse error, unbound identifier, engine fault), carrying
191
+ the predicate source, the engine's failure reason, and the field/rule locator
192
+ the call site provides. Renderer call sites thread that locator
193
+ (`visibleWhen of field 'amount'`), so a broken predicate identifies itself in
194
+ the browser console instead of being indistinguishable from an absent one.
195
+
196
+ Verdicts are unchanged: evaluation still fails open to the caller's safe
197
+ default (flipping that default is objectstack#5149 appeal 1, tracked
198
+ separately). Fault-probing callers (`evalRowPredicate`'s fail-closed path,
199
+ `ExpressionEvaluator`'s `throwOnError`) opt out via the new
200
+ `diagnostic.warn: false` and keep their own single diagnostic, so no broken
201
+ predicate ever warns twice.
202
+
203
+ - 2a9513d: `toFilterNode` now lowers a spec `ViewFilterRule[]` into ObjectQL AST nodes instead of returning the array verbatim, so a saved view's stored filter reaches `$filter` as something the server accepts. It previously did not: `ListViewSchema.filter` / `ViewTab.filter` are declared `z.array(ViewFilterRuleSchema)`, and the whole read path — `ObjectView` → `ListViewSchema.filter` → `buildEffectiveFilter` → `mergeFilterNodes` → `toFilterNode` — carried those rule objects untouched into the query. `isFilterAST` is `false` for an array of objects, so the data API answered `400 INVALID_FILTER` and the list rendered no rows at all. Measured against a real backend on the showcase's shipped `showcase_task.in_progress` view: `$filter=[{"field":"status","operator":"equals","value":"in_progress"}]` returned `400`, while the lowered `[["status","equals","in_progress"]]` returned its 2 rows. Every saved view carrying a filter was affected, on both producers that share this sink — `plugin-list`'s `buildEffectiveFilter` (the grid and its export) and `plugin-view`'s `ObjectView` (calendar / kanban / gallery / timeline).
204
+
205
+ Operators are canonicalised through the spec's own `normalizeFilterOperator`, the same exit the write side (`viewFilterFold`) uses, so the two directions cannot drift into two dialects; no second operator table is introduced. An operator the spec does not know is passed through verbatim so the server still refuses it loudly rather than having a misspelling coerced into a valid filter. AST nodes and MongoDB-style object filters are unaffected, mixed arrays (a view's rules concatenated with `?filter[<field>]=<value>` URL triples) fold element-wise with the triples untouched, and a rule with a blank `field` is deliberately left unlowered — `["", op, value]` passes `isFilterAST` and returns an empty list, whereas the unlowered rule keeps the loud `400`.
206
+
207
+ - Updated dependencies [d915c47]
208
+ - Updated dependencies [9e9e9a9]
209
+ - Updated dependencies [23018cc]
210
+ - Updated dependencies [f44d872]
211
+ - Updated dependencies [f833d3a]
212
+ - Updated dependencies [d22ae31]
213
+ - @object-ui/types@17.3.0
214
+
3
215
  ## 17.2.0
4
216
 
5
217
  ### Minor Changes
package/README.md CHANGED
@@ -58,6 +58,35 @@ const userName = scope.get('user.name') // 'John'
58
58
  const isAdmin = scope.evaluate('${user.role === "admin"}') // true
59
59
  ```
60
60
 
61
+ ### Server Action Dispatch (`createServerActionHandler`)
62
+
63
+ `ActionSchema.body` (L1 expression / L2 sandboxed JS) executes **server-side**
64
+ — `POST /api/v1/actions/{object}/{action}` → the runtime sandbox. The client
65
+ dispatches; it never interprets a body. Build the dispatch handler with the
66
+ factory and register it — core stays opinion-free about auth, origin and
67
+ object scope, which are injected:
68
+
69
+ ```typescript
70
+ import { createServerActionHandler } from '@object-ui/core'
71
+
72
+ const script = createServerActionHandler({
73
+ fetch: myAuthenticatedFetch, // your auth wrapper (Bearer/cookies/...)
74
+ baseUrl: 'https://api.example.com', // '' or omitted = same-origin
75
+ resolveObject: () => currentObject, // fallback when the action has no objectName
76
+ onRefresh: () => refetchData(), // called per the action's refreshAfter
77
+ })
78
+
79
+ // Registered handlers beat the built-in executors:
80
+ runner.registerHandler('script', script)
81
+ // (React hosts: <ActionProvider handlers={{ script }} ... />)
82
+ ```
83
+
84
+ The factory owns the protocol so consumers cannot drift on it: name-based
85
+ action identity (ADR-0110), the record-id resolution dance (`_rowRecord`,
86
+ `recordIdField`, selection fallback, aggregate `_selectedIds`), a re-entrancy
87
+ guard, and the `/actions` response-envelope rule (`interpretActionResponse` /
88
+ `readActionPayload`, also exported).
89
+
61
90
  ### System Views (`defineView`)
62
91
 
63
92
  Schemas authored in source code are part of the product contract and must
@@ -108,13 +108,18 @@ export declare class ActionEngine {
108
108
  * action behaves identically whether surfaced via the engine or
109
109
  * consumed standalone.
110
110
  *
111
- * Predicate normalization matches `@object-ui/react`'s `toPredicateInput`:
111
+ * Predicate normalization goes through the shared `toPredicateInput`
112
+ * (`@object-ui/core`'s canonical helper — semantically identical to
113
+ * `@object-ui/react`'s renderer-side twin, pinned by a parity test):
112
114
  * raw strings (`'record.foo == true'`) are wrapped as `${...}` so they
113
- * evaluate as expressions, not as truthy string literals. Spec-style
114
- * `{ dialect, source }` envelopes are unwrapped to their `source`.
115
- * Without this, every per-action renderer (which already calls
116
- * `toPredicateInput`) and the engine path would disagree on the same
117
- * `visible:` predicate.
115
+ * evaluate as expressions, not as truthy string literals, and a spec-style
116
+ * `{ dialect: 'cel', source }` envelope is passed through INTACT so
117
+ * `evaluateCondition` routes it to the canonical `@objectstack/formula`
118
+ * engine (#2661). Unwrapping the envelope here — as this method used to —
119
+ * dropped the dialect and silently demoted every CEL predicate to the
120
+ * legacy JS evaluator, so the same `visible:` reached a different verdict
121
+ * via the engine than via a renderer (#3314): CEL faults on `null < null`
122
+ * and fail-closed hides, JS quietly returns `false`.
118
123
  */
119
124
  getActionsForLocation(location: ActionLocation): ActionDef[];
120
125
  /** Get actions that support bulk operations */
@@ -16,6 +16,7 @@
16
16
  * ActionSchema[] → ActionEngine → ActionRunner → ActionResult
17
17
  */
18
18
  import { ActionRunner } from './ActionRunner';
19
+ import { toPredicateInput } from '../evaluator/predicateInput.js';
19
20
  export { ACTION_LOCATIONS } from '@object-ui/types';
20
21
  /** Normalize a shortcut string for comparison (lowercase, trimmed, sorted parts) */
21
22
  function normalizeShortcut(keys) {
@@ -159,13 +160,18 @@ export class ActionEngine {
159
160
  * action behaves identically whether surfaced via the engine or
160
161
  * consumed standalone.
161
162
  *
162
- * Predicate normalization matches `@object-ui/react`'s `toPredicateInput`:
163
+ * Predicate normalization goes through the shared `toPredicateInput`
164
+ * (`@object-ui/core`'s canonical helper — semantically identical to
165
+ * `@object-ui/react`'s renderer-side twin, pinned by a parity test):
163
166
  * raw strings (`'record.foo == true'`) are wrapped as `${...}` so they
164
- * evaluate as expressions, not as truthy string literals. Spec-style
165
- * `{ dialect, source }` envelopes are unwrapped to their `source`.
166
- * Without this, every per-action renderer (which already calls
167
- * `toPredicateInput`) and the engine path would disagree on the same
168
- * `visible:` predicate.
167
+ * evaluate as expressions, not as truthy string literals, and a spec-style
168
+ * `{ dialect: 'cel', source }` envelope is passed through INTACT so
169
+ * `evaluateCondition` routes it to the canonical `@objectstack/formula`
170
+ * engine (#2661). Unwrapping the envelope here — as this method used to —
171
+ * dropped the dialect and silently demoted every CEL predicate to the
172
+ * legacy JS evaluator, so the same `visible:` reached a different verdict
173
+ * via the engine than via a renderer (#3314): CEL faults on `null < null`
174
+ * and fail-closed hides, JS quietly returns `false`.
169
175
  */
170
176
  getActionsForLocation(location) {
171
177
  const evaluator = this.runner.getEvaluator();
@@ -192,18 +198,17 @@ export class ActionEngine {
192
198
  return true;
193
199
  if (raw === false)
194
200
  return false;
195
- let expr;
196
- if (typeof raw === 'string') {
197
- expr = `\${${raw}}`;
198
- }
199
- else if (typeof raw === 'object' && typeof raw.source === 'string') {
200
- const src = raw.source;
201
- if (!src)
202
- return true;
203
- expr = `\${${src}}`;
204
- }
205
- else {
206
- expr = Boolean(raw);
201
+ // #3314 — one shared normalization, not two. Hand-rolling the envelope
202
+ // unwrap here is what dropped `dialect: 'cel'` and demoted the
203
+ // predicate to the legacy JS engine; `toPredicateInput` keeps the
204
+ // envelope so `evaluateCondition` can route it to `@objectstack/formula`
205
+ // (the engine the server enforces with), exactly as the renderers do.
206
+ const expr = toPredicateInput(raw);
207
+ if (expr === undefined) {
208
+ // Not an evaluable predicate: an envelope with an empty `source`
209
+ // (→ nothing declared → visible), or a stray non-predicate value,
210
+ // which keeps the historical `Boolean(raw)` coercion.
211
+ return typeof raw === 'object' ? true : Boolean(raw);
207
212
  }
208
213
  try {
209
214
  // `throwOnError: true` is required for fail-closed semantics: the
@@ -211,7 +216,9 @@ export class ActionEngine {
211
216
  // returns the raw template string, which `Boolean(string)` then
212
217
  // coerces to `true` — silently leaking actions whose preconditions
213
218
  // failed (e.g. `ctx` undefined). Surface the error so our catch
214
- // below can hide the action.
219
+ // below can hide the action. It is equally load-bearing on the CEL
220
+ // path, where the canonical helper fails SOFT to visible/enabled
221
+ // unless the caller opts in.
215
222
  return evaluator.evaluateCondition(expr, { throwOnError: true });
216
223
  }
217
224
  catch (err) {
@@ -176,8 +176,9 @@ export interface ActionDef {
176
176
  * would diverge silently rather than fail.
177
177
  *
178
178
  * Consumers dispatch bodies by registering a `script` handler that POSTs to
179
- * `/api/v1/actions/{object}/{action}` (see app-shell's
180
- * `useConsoleActionRuntime`); the server runs the body through its sandbox.
179
+ * `/api/v1/actions/{object}/{action}`; the server runs the body through its
180
+ * sandbox. Build that handler with `createServerActionHandler` (#2904) —
181
+ * app-shell's `useConsoleActionRuntime` and `RecordDetailView` both do.
181
182
  */
182
183
  body?: unknown;
183
184
  /** For type: 'url' — where to open `target`. `'new-tab'` forces a new
@@ -353,6 +354,19 @@ export type ResultDialogHandler = (spec: ResultDialogSpec, data: unknown, action
353
354
  /**
354
355
  * ActionParam definition accepted by the runner.
355
356
  * Compatible with @objectstack/spec ActionParam.
357
+ *
358
+ * The RESOLVED shape — what `resolveActionParams()` emits and `ActionParamDialog`
359
+ * reads, after a field-backed param's `{ field }` reference has been inlined.
360
+ * The AUTHORING counterpart is `@object-ui/types`' `ActionParam` (objectui#3174).
361
+ *
362
+ * `validation?: string` used to sit here too, and was removed by objectui#3201:
363
+ * no producer ever wrote it (it was never a key of `resolveActionParams()`'s
364
+ * `RawActionParam`, and the runtime field metadata a field-backed param inherits
365
+ * from carries no `validation` either) and no consumer ever read it —
366
+ * `paramToField()` did not map it, so it never reached the field widgets, whose
367
+ * rules `buildValidationRules()` builds from `required` / `minLength` /
368
+ * `maxLength` / `pattern`. Declaring it on the resolved side only invited the
369
+ * authoring side to declare it back.
356
370
  */
357
371
  export interface ActionParamDef {
358
372
  name: string;
@@ -366,7 +380,6 @@ export interface ActionParamDef {
366
380
  defaultValue?: unknown;
367
381
  helpText?: string;
368
382
  placeholder?: string;
369
- validation?: string;
370
383
  /**
371
384
  * Visibility predicate (CEL) evaluated against the same scope as action
372
385
  * `visible` (`current_user` / `app` / `data` / `features`). When it evaluates
@@ -448,7 +448,8 @@ export class ActionRunner {
448
448
  success: false,
449
449
  error: 'Action body must be executed server-side — this client runner does not interpret ' +
450
450
  '`body` (sandboxed JS needs an isolated VM; expression bodies use the formula engine). ' +
451
- 'Register a `script` handler that POSTs to /api/v1/actions/{object}/{action}.',
451
+ 'Register a `script` handler that POSTs to /api/v1/actions/{object}/{action} — ' +
452
+ 'build one with createServerActionHandler from @object-ui/core.',
452
453
  };
453
454
  }
454
455
  // ActionDef is open-ended (`[key: string]: any`), so hand-authored
@@ -0,0 +1,26 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Resolve a human-readable message out of an ObjectStack error payload.
10
+ *
11
+ * Always returns a STRING. The envelope nests the message as
12
+ * `{ error: { code, message } }`, and passing that object through as
13
+ * `ActionResult.error` reaches `toast.error()` as a React child and crashes the
14
+ * page (React #31). That shape used to be rare on the `/actions` route —
15
+ * failures came back as HTTP 200 with a plain-string `error` — but since
16
+ * objectstack#3913 a failed action answers with a real status and the nested
17
+ * object, so every `/actions` caller has to go through this.
18
+ *
19
+ * Handles, in order: `{error: 'msg'}`, `{error: {message: 'msg'}}`,
20
+ * `{message: 'msg'}`, else the caller's fallback.
21
+ *
22
+ * Moved here from `@object-ui/app-shell` (`utils/actionErrorDetail`) by
23
+ * objectui#2904 so `createServerActionHandler` — the core dispatch every
24
+ * consumer registers — can own the rule.
25
+ */
26
+ export declare function actionErrorDetail(body: unknown, fallback: string): string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Resolve a human-readable message out of an ObjectStack error payload.
10
+ *
11
+ * Always returns a STRING. The envelope nests the message as
12
+ * `{ error: { code, message } }`, and passing that object through as
13
+ * `ActionResult.error` reaches `toast.error()` as a React child and crashes the
14
+ * page (React #31). That shape used to be rare on the `/actions` route —
15
+ * failures came back as HTTP 200 with a plain-string `error` — but since
16
+ * objectstack#3913 a failed action answers with a real status and the nested
17
+ * object, so every `/actions` caller has to go through this.
18
+ *
19
+ * Handles, in order: `{error: 'msg'}`, `{error: {message: 'msg'}}`,
20
+ * `{message: 'msg'}`, else the caller's fallback.
21
+ *
22
+ * Moved here from `@object-ui/app-shell` (`utils/actionErrorDetail`) by
23
+ * objectui#2904 so `createServerActionHandler` — the core dispatch every
24
+ * consumer registers — can own the rule.
25
+ */
26
+ export function actionErrorDetail(body, fallback) {
27
+ const b = body;
28
+ const err = b?.error;
29
+ if (typeof err === 'string' && err.length > 0)
30
+ return err;
31
+ const nested = err?.message;
32
+ if (typeof nested === 'string' && nested.length > 0)
33
+ return nested;
34
+ if (typeof b?.message === 'string' && b.message.length > 0)
35
+ return b.message;
36
+ return fallback;
37
+ }
@@ -91,7 +91,7 @@ export declare const ACTION_DEF_KEYS: readonly ["type", "actionType", "name", "l
91
91
  * test and wrong in shipped code — `@object-ui/types` made the same call for
92
92
  * `ActionComponent`. The pin test does the walk and fails the day this drifts.
93
93
  */
94
- export declare const SPEC_ACTION_KEYS: readonly ["ai", "aria", "body", "bodyExtra", "bodyShape", "bulkEnabled", "component", "confirmText", "disabled", "errorMessage", "execute", "icon", "label", "locations", "method", "mode", "name", "newTabUrl", "objectName", "openIn", "opensInNewTab", "order", "params", "recordIdField", "recordIdParam", "refreshAfter", "requiredPermissions", "requiresFeature", "resultDialog", "shortcut", "successMessage", "target", "type", "undoable", "variant", "visible"];
94
+ export declare const SPEC_ACTION_KEYS: readonly ["_lock", "_lockDocsUrl", "_lockReason", "_lockSource", "_packageId", "_packageVersion", "_provenance", "ai", "aria", "body", "bodyExtra", "bodyShape", "bulkEnabled", "component", "confirmText", "disabled", "errorMessage", "execute", "icon", "label", "locations", "method", "mode", "name", "newTabUrl", "objectName", "openIn", "opensInNewTab", "order", "params", "recordIdField", "recordIdParam", "refreshAfter", "requiredPermissions", "requiresFeature", "resultDialog", "shortcut", "successMessage", "target", "type", "undoable", "variant", "visible"];
95
95
  /**
96
96
  * The `navigation` alias's own spelling of a target, read off the action itself
97
97
  * when no nested `navigate` object is present (`ActionRunner.executeNavigation`).
@@ -150,6 +150,17 @@ export const ACTION_DEF_KEYS = [
150
150
  * `ActionComponent`. The pin test does the walk and fails the day this drifts.
151
151
  */
152
152
  export const SPEC_ACTION_KEYS = [
153
+ // The package-lock / provenance envelope, added to `ActionSchema` in spec
154
+ // 17.0.0-rc.2. Not authorable action vocabulary — it is stamped on a record
155
+ // that came from an installed package — but it IS declared by the schema, so
156
+ // an action carrying it must not be reported as having unknown keys.
157
+ '_lock',
158
+ '_lockDocsUrl',
159
+ '_lockReason',
160
+ '_lockSource',
161
+ '_packageId',
162
+ '_packageVersion',
163
+ '_provenance',
153
164
  'ai',
154
165
  'aria',
155
166
  'body',
@@ -0,0 +1,42 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ export interface ActionResponseOutcome {
9
+ ok: boolean;
10
+ /** Human-readable failure text. Always a string when `ok` is false. */
11
+ error?: string;
12
+ /**
13
+ * The ACTION ENVELOPE (`body.data`) — `{ success, data }`. Kept as-is
14
+ * because `ActionResult.data` has always carried this level and
15
+ * `resultDialog` field paths in the wild may compensate for it; see the
16
+ * note in {@link readActionPayload}.
17
+ */
18
+ envelope?: unknown;
19
+ /** What the handler actually returned (`body.data.data`). */
20
+ payload?: unknown;
21
+ }
22
+ /**
23
+ * The handler's own return value — one level deeper than the action envelope.
24
+ *
25
+ * `body.data` is `{ success, data }`; the handler's value is `body.data.data`.
26
+ * Reading the shallower level is why an action returning `{ redirectUrl }` was
27
+ * silently ignored: `body.data.redirectUrl` is never set by anything, because
28
+ * that level is constructed by the server and only ever holds `success`/`data`.
29
+ */
30
+ export declare function readActionPayload(envelope: unknown): unknown;
31
+ /** The pre-objectstack#3962 inner envelope, detected narrowly. */
32
+ export declare function isLegacyActionEnvelope(v: unknown): boolean;
33
+ /**
34
+ * Classify an `/actions` response. `label` names the action for the fallback
35
+ * message (e.g. `Action "log_call" failed`).
36
+ *
37
+ * `json` is the parsed body, or `null` when it could not be parsed.
38
+ */
39
+ export declare function interpretActionResponse(res: {
40
+ ok: boolean;
41
+ status: number;
42
+ }, json: any, label: string): ActionResponseOutcome;