@object-ui/core 17.3.0 → 17.5.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 (58) hide show
  1. package/CHANGELOG.md +1224 -0
  2. package/README.md +2 -12
  3. package/dist/actions/ActionEngine.d.ts +42 -25
  4. package/dist/actions/ActionEngine.js +66 -52
  5. package/dist/actions/ActionRunner.d.ts +199 -21
  6. package/dist/actions/ActionRunner.js +332 -16
  7. package/dist/actions/actionKeys.d.ts +96 -20
  8. package/dist/actions/actionKeys.js +157 -18
  9. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  10. package/dist/data-scope/ViewDataProvider.js +83 -5
  11. package/dist/data-scope/element-data-source.d.ts +113 -0
  12. package/dist/data-scope/element-data-source.js +216 -0
  13. package/dist/data-scope/index.d.ts +2 -1
  14. package/dist/data-scope/index.js +1 -0
  15. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  16. package/dist/evaluator/declaredPredicate.js +152 -0
  17. package/dist/evaluator/fieldRules.d.ts +16 -0
  18. package/dist/evaluator/fieldRules.js +51 -16
  19. package/dist/evaluator/index.d.ts +1 -0
  20. package/dist/evaluator/index.js +1 -0
  21. package/dist/evaluator/listConditional.d.ts +5 -2
  22. package/dist/evaluator/listConditional.js +58 -16
  23. package/dist/evaluator/predicateInput.d.ts +51 -3
  24. package/dist/evaluator/predicateInput.js +62 -5
  25. package/dist/index.d.ts +4 -1
  26. package/dist/index.js +14 -1
  27. package/dist/protocols/DndProtocol.d.ts +42 -1
  28. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  29. package/dist/protocols/SharingProtocol.d.ts +11 -1
  30. package/dist/registry/Registry.d.ts +24 -0
  31. package/dist/theme/ThemeEngine.d.ts +0 -8
  32. package/dist/theme/ThemeEngine.js +22 -103
  33. package/dist/theme/index.d.ts +1 -1
  34. package/dist/theme/index.js +7 -1
  35. package/dist/types/index.d.ts +42 -8
  36. package/dist/utils/chart-series.d.ts +258 -5
  37. package/dist/utils/chart-series.js +423 -20
  38. package/dist/utils/compare-to.d.ts +40 -13
  39. package/dist/utils/compare-to.js +4 -32
  40. package/dist/utils/dashboard-filters.d.ts +54 -8
  41. package/dist/utils/dashboard-filters.js +261 -12
  42. package/dist/utils/dataset-format.d.ts +73 -14
  43. package/dist/utils/dataset-format.js +130 -9
  44. package/dist/utils/dataset-pivot.d.ts +28 -7
  45. package/dist/utils/dataset-pivot.js +27 -6
  46. package/dist/utils/dom-props.d.ts +137 -0
  47. package/dist/utils/dom-props.js +153 -0
  48. package/dist/utils/normalize-list-view.d.ts +26 -6
  49. package/dist/utils/normalize-list-view.js +30 -8
  50. package/dist/utils/number-display.d.ts +151 -0
  51. package/dist/utils/number-display.js +71 -0
  52. package/dist/utils/resolve-view-id.d.ts +31 -0
  53. package/dist/utils/resolve-view-id.js +45 -0
  54. package/dist/utils/sort-query.d.ts +55 -0
  55. package/dist/utils/sort-query.js +35 -0
  56. package/package.json +5 -5
  57. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  58. package/dist/utils/merge-views-into-objects.js +0 -39
@@ -6,8 +6,9 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
9
+ import { hasDeclaredPredicate } from '../evaluator/declaredPredicate';
9
10
  import { globalUndoManager } from './UndoManager';
10
- import { warnOnUnknownActionKeys } from './actionKeys';
11
+ import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys';
11
12
  /**
12
13
  * Bind the server-CEL-parity `os.user` identity alias into an action context
13
14
  * (#2358 trap 1). `@objectstack/spec` documents the canonical CEL identity
@@ -27,6 +28,145 @@ function withIdentityAlias(context) {
27
28
  return context;
28
29
  return { ...context, os: { ...(os && typeof os === 'object' ? os : {}), user } };
29
30
  }
31
+ /**
32
+ * The query param naming the record a form route opens (`?recordId=<id>`).
33
+ *
34
+ * The console's reserved-param registry (`@object-ui/app-shell`,
35
+ * `src/urlParams.ts` → `RECORD_DRAWER_PARAM`) owns this spelling and
36
+ * `apps/console`'s `FormPage` re-declares it as `FORM_RECORD_ID_PARAM`. It is a
37
+ * literal in all three places because `@object-ui/core` sits BELOW both in the
38
+ * dependency graph and cannot import either; the console's `FormPage.test.ts`
39
+ * pins the spellings equal so they cannot drift apart in silence.
40
+ */
41
+ const FORM_RECORD_ID_PARAM = 'recordId';
42
+ /**
43
+ * The query param carrying the OBJECT the forwarded {@link FORM_RECORD_ID_PARAM}
44
+ * belongs to (objectui#4292).
45
+ *
46
+ * It is an ASSERTION, never an override: the form's object always comes from the
47
+ * FormView metadata, and this param exists solely so the consumer can refuse
48
+ * when the two disagree. (The registry's `formObject` is the other thing — it
49
+ * SELECTS which object the record-form overlay edits. Same `<param>Object`
50
+ * naming, deliberately opposite powers, which is why they are separate names on
51
+ * separate surfaces.) Widening it into a selector would hand any hand-authored
52
+ * URL the ability to point a form at an arbitrary object — the hole this closes,
53
+ * re-opened from the other side.
54
+ */
55
+ const FORM_RECORD_OBJECT_PARAM = 'recordObject';
56
+ /**
57
+ * The object the firing context's record belongs to, or `undefined` when the
58
+ * host did not say (objectui#4292).
59
+ *
60
+ * `context.objectName` is MEASURED to be this repo's one spelling of that fact,
61
+ * not a new convention invented here:
62
+ *
63
+ * - `@object-ui/react`'s `ActionProvider` documents the flat trio `record`,
64
+ * `user`, `objectName` and mirrors it into the canonical `ctx.*` scope;
65
+ * - `useConsoleActionRuntime` seeds `context: { ...(objectName ? { objectName }
66
+ * : {}), … }` — so every console surface that fires a record action
67
+ * (`DeclaredActionsBar`, `ObjectView`, `RecordDetailView`) already publishes
68
+ * it, and `RecordDetailView` passes it explicitly a second time;
69
+ * - `app-shell`'s `recordFormNavigation` resolver already reads exactly this
70
+ * key as its last precedence step for `navigate_create` / `navigate_edit`.
71
+ *
72
+ * `action.objectName` is deliberately NOT consulted. It declares which object an
73
+ * ACTION belongs to, which is a statement about the action's placement, not
74
+ * about the record the id was read from — and trusting metadata about the action
75
+ * to describe the record is precisely the conflation that made this defect
76
+ * possible.
77
+ */
78
+ function readContextObjectName(context) {
79
+ const name = context.objectName;
80
+ return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined;
81
+ }
82
+ /**
83
+ * Whether opening FormView `viewName` from a record of `contextObject` would
84
+ * cross an object boundary (objectui#4292).
85
+ *
86
+ * View identity is `<object>.<key>` (ADR-0017; `viewEnvelope` builds exactly
87
+ * that, `defaultListViewId` compares exactly this way), so the question is
88
+ * answered as a PREFIX match against the object we already hold rather than by
89
+ * parsing an object name out of the view name — the target's object is a fact
90
+ * the metadata owns, and this runner only asks whether it is the one in hand.
91
+ *
92
+ * Answers `false` — "not comparable, carry on" — whenever either end is missing:
93
+ * no `contextObject` (host published none), or an unqualified `viewName` (no
94
+ * dot, so it names no object). Only a qualified name under a DIFFERENT object
95
+ * is a boundary crossing.
96
+ */
97
+ function crossesObjectBoundary(viewName, contextObject) {
98
+ if (!contextObject)
99
+ return false;
100
+ if (!viewName.includes('.'))
101
+ return false;
102
+ return !viewName.startsWith(`${contextObject}.`);
103
+ }
104
+ /*
105
+ * ## The two predicate gates in `execute`, and the one question they ask first
106
+ *
107
+ * `execute` gates on `condition` and on `disabled`, and both need ONE question
108
+ * answered before evaluating — "is a gate DECLARED here, i.e. is there a
109
+ * condition to reach a verdict on?" — for OPPOSITE reasons, which is exactly why
110
+ * the question has to be asked separately from the verdict:
111
+ *
112
+ * - `disabled` (objectui#3848): an EMPTY predicate must not be handed to
113
+ * `evaluateCondition`, whose answer for "no condition" is `true` = BLOCKED.
114
+ * Measured consequence of asking `!= null && !== false` instead:
115
+ * `disabled: ''` returned `{ success: false, error: 'Action is disabled' }`
116
+ * and the handler never ran.
117
+ * - `condition` (objectui#3872): a DECLARED-FALSE predicate must not be
118
+ * skipped by a truthiness test. `if (action.condition)` never asked whether
119
+ * a gate was declared, so `condition: false` — the most explicit "never
120
+ * execute" an author can write — landed on `if (false)`, the evaluator was
121
+ * never consulted, and the action RAN. Measured on `origin/main` @
122
+ * `2937bcf7d`: `condition: false` → `{"success":true}`, handler ran, while
123
+ * the semantically identical `{ dialect: 'cel', source: 'false' }` was
124
+ * refused. Asking "declared?" routes `false` to `evaluateCondition`, which
125
+ * short-circuits booleans (`if (typeof condition === 'boolean') return
126
+ * condition`) and blocks.
127
+ *
128
+ * Both read {@link hasDeclaredPredicate} — the repo's one definition of that
129
+ * question, in `evaluator/declaredPredicate.ts` beside the normalizer it is
130
+ * derived from, with the scope objectui#3850 ruled on (`''` / whitespace-only /
131
+ * empty-`source` envelope / non-predicate junk are NOT declared; a declared
132
+ * `false` IS). This gate arrived here first as a module-private helper with a
133
+ * scope note saying it stayed private until that ruling landed; it has, so the
134
+ * definition moved down a layer and the renderer side reads the same one
135
+ * (`components/renderers/action/visibility-gate.ts` re-exports it as
136
+ * `hasDeclaredVisibilityGate`, `SchemaRenderer`'s `disabled` chain calls it).
137
+ *
138
+ * ## Why the gates normalize to DECIDE but evaluate the RAW value
139
+ *
140
+ * Historically the two were not interchangeable for a string already spelled as
141
+ * a `${…}` template: `toPredicateInput` assumed a bare expression and wrapped
142
+ * unconditionally, so `'${x}'` became `'${${x}}'`, which no longer matched the
143
+ * single-template fast path and did not parse — leaving a constant verdict whose
144
+ * direction was set by the caller's error policy (fail-soft got the unparsed
145
+ * string back, `Boolean(…)` = `true`; fail-closed got a throw). On `disabled`
146
+ * that meant "always blocked", on `condition` the opposite ("always execute").
147
+ * That defect was objectui#3871, fixed at the normalizer: an already-`${…}`
148
+ * string is returned untouched, the normalizer is idempotent, and
149
+ * `evaluateCondition(toPredicateInput(raw))` agrees with
150
+ * `evaluateCondition(raw)` on every shape these gates accept.
151
+ *
152
+ * The gates still read the raw value. Nothing forces the change now that the two
153
+ * agree, and it would be churn on the execution path rather than a fix — but the
154
+ * reason has shifted from "must" to "no reason to", so the equality is pinned
155
+ * next to each gate's suite (`ActionRunner.disabledGate.test.ts` /
156
+ * `ActionRunner.conditionGate.test.ts`, the two cases that replaced the
157
+ * objectui#3871 tripwires) instead of being assumed. Every non-empty shape —
158
+ * boolean, bare CEL, `${…}` template, `{ dialect, source }` envelope — keeps the
159
+ * verdict it reaches today.
160
+ *
161
+ * The `visible`-filter template in `ActionEngine.getActionsForLocation` used to
162
+ * be the one place this scope was NOT shared: its non-predicate branch kept a
163
+ * historical `Boolean(raw)` coercion, so `0` there meant "hidden" — fail-CLOSED
164
+ * on junk, the opposite of what this module committed to for `disabled`
165
+ * (`catch { isDisabled = false }`). That filter now reads
166
+ * {@link hasDeclaredPredicate} too (objectui#3957), so a value that is not a
167
+ * predicate at all decides nothing on either key, at either entry: `0` / `{}` are
168
+ * "no gate" here, in the engine filter, and in the shared definition.
169
+ */
30
170
  export class ActionRunner {
31
171
  constructor(context = {}) {
32
172
  Object.defineProperty(this, "handlers", {
@@ -191,16 +331,45 @@ export class ActionRunner {
191
331
  // binds a handler, and binding no handler silently is the #2169 "Mark Done
192
332
  // does nothing" shape. Dev-only, warn-once, changes nothing (#4075 step 1).
193
333
  warnOnUnknownActionKeys(action);
334
+ // The compat window for the object-form `params` payload (#5777, maintainer
335
+ // ruling 2026-08-06 direction A). Checked HERE, before the param-collection
336
+ // block below rewrites `action.params` into a values map — after that point
337
+ // an action that authored a legitimate ActionParam[] DEFINITION array also
338
+ // carries an object under `params`, and the warning could no longer tell the
339
+ // two apart. Dev-only, warn-once, changes nothing.
340
+ warnOnDeprecatedObjectParams(action);
194
341
  // Resolve the action type
195
342
  const actionType = action.type || action.actionType || action.name || '';
196
- // Conditional execution
197
- if (action.condition) {
343
+ // Conditional execution. Ask "is a `condition` gate DECLARED?" — not "is
344
+ // the raw value truthy?" (objectui#3872). Truthiness cannot answer that
345
+ // question, and on this key it answered it in the OVER-PERMISSIVE
346
+ // direction: `condition: false` (the most explicit "never execute" an
347
+ // author can write, and what a template that switches an action off
348
+ // emits) fell on `if (false)`, so the evaluator was never consulted and
349
+ // the action executed anyway — while the semantically identical
350
+ // `{ dialect: 'cel', source: 'false' }` was refused. `evaluateCondition`
351
+ // short-circuits booleans, so once the gate asks the right question the
352
+ // verdict needs no boolean branch of its own. See `hasDeclaredPredicate`
353
+ // for the shared "declared?" scope (objectui#3850) and for why the verdict
354
+ // still reads the RAW value (`toPredicateInput` used to double-wrap an
355
+ // already-`${…}` string into a constant verdict — objectui#3871, fixed at
356
+ // the normalizer; on THIS key that constant meant "always execute").
357
+ if (hasDeclaredPredicate(action.condition)) {
198
358
  const shouldExecute = this.evaluator.evaluateCondition(action.condition);
199
359
  if (!shouldExecute) {
200
360
  return { success: false, error: 'Action condition not met' };
201
361
  }
202
362
  }
203
- if (action.disabled != null && action.disabled !== false) {
363
+ // Ask "is a `disabled` gate DECLARED?" — not "is the key present and not
364
+ // `false`?" (objectui#3848). The old test let every EMPTY predicate reach
365
+ // `evaluateCondition`, whose answer for "no condition" is `true`, which on
366
+ // this key means BLOCKED: `disabled: ''` / `' '` /
367
+ // `{ dialect: 'cel', source: '' }` all returned `Action is disabled` with
368
+ // the handler never invoked. See `hasDeclaredPredicate` above for why the
369
+ // gate normalizes to decide while the verdict below reads the RAW value
370
+ // (historically load-bearing: `toPredicateInput` double-wrapped an
371
+ // already-`${…}` string — objectui#3871, now fixed, so the two agree).
372
+ if (hasDeclaredPredicate(action.disabled)) {
204
373
  // `disabled` may be a boolean, a CEL string, or the normalized envelope
205
374
  // `{ dialect, source }` (what `objectstack build` emits). The previous
206
375
  // code only evaluated the STRING form and treated any object as truthy,
@@ -452,12 +621,25 @@ export class ActionRunner {
452
621
  'build one with createServerActionHandler from @object-ui/core.',
453
622
  };
454
623
  }
455
- // ActionDef is open-ended (`[key: string]: any`), so hand-authored
456
- // metadata that never passed through the spec parser still compiles with
457
- // the retired key. Carry the same prescription the spec's tombstone does,
458
- // for the same reason: a bare "no script provided" reads as "you forgot a
459
- // field" to an author who did write one.
460
- if (typeof action.execute === 'string') {
624
+ // Read off an UNTYPED view of the action on purpose, and this branch
625
+ // survives step 3 on purpose (objectstack#4075).
626
+ //
627
+ // `ActionDef` no longer declares `execute` — nor any open index signature
628
+ // — so `tsc` now catches the retired key at every site that AUTHORS an
629
+ // action literal in code. That is the half of the problem the type can
630
+ // reach. It is not this half: #3903 established that stored
631
+ // `sys_metadata` rows are rehydrated UNPARSED, so metadata written before
632
+ // spec 17 arrives here as a plain object the compiler never saw, still
633
+ // carrying `execute`. Declaring the key to make this read compile would
634
+ // re-legitimize a tombstone (#3855 removed it; the spec keeps it only to
635
+ // reject it BY NAME), and deleting the branch would send those rows back
636
+ // to a bare "no script provided" — which reads as "you forgot a field" to
637
+ // an author who did write one, the objectstack#2169 shape.
638
+ //
639
+ // So: the cast is the honest spelling of "a key this type deliberately
640
+ // does not have, read from a row this type never validated".
641
+ const retiredExecute = action.execute;
642
+ if (typeof retiredExecute === 'string') {
461
643
  return {
462
644
  success: false,
463
645
  error: '`execute` was removed in @objectstack/spec 17 — rename the key to `target`. ' +
@@ -563,12 +745,24 @@ export class ActionRunner {
563
745
  return { success: true };
564
746
  }
565
747
  // `openIn` is the first-class, declarative switch (ActionSchema.openIn);
566
- // it wins over the legacy `navigate.newTab` / `params.newTab` escape
567
- // hatches and the external-URL heuristic.
748
+ // it wins over the legacy `navigate.newTab` escape hatch and the
749
+ // external-URL heuristic.
750
+ //
751
+ // `params.newTab` used to sit between them and is RETIRED (objectui#4097,
752
+ // executing the objectstack#6828 maintainer ruling of 2026-08-10). It could
753
+ // only ever fire on an OBJECT-form `params`, which the spec has always
754
+ // refused on an action (`params` is `z.array(ActionParamSchema)`), so no
755
+ // validated stack could reach it; and no internal caller synthesizes a
756
+ // navigation directive into that bag — the runtime value bags carry
757
+ // collected dialog values, `_selectedIds` and `_rowRecord`, never `newTab`.
758
+ // Removing it also closes the collision hazard where a params dialog
759
+ // declaring a field literally named `newTab` would have had the user's own
760
+ // input silently steer navigation. The spec's refusal message names the
761
+ // sanctioned spelling: `openIn: 'new-tab'`.
568
762
  const openIn = source.openIn ?? action.openIn;
569
763
  const newTab = openIn === 'new-tab' ? true
570
764
  : openIn === 'self' ? false
571
- : (source.newTab ?? action.params?.newTab ?? isExternal);
765
+ : (source.newTab ?? isExternal);
572
766
  // History semantics, the one thing the `navigation` shape carries that
573
767
  // `ActionSchema` has no field for. Omitted from the handler call when
574
768
  // unset so hosts see the same options object they always did.
@@ -675,6 +869,37 @@ export class ActionRunner {
675
869
  * through to `executeActionSchema` and silently no-opped (the "Log Time does
676
870
  * nothing" report). The current record id is forwarded as `?recordId=` for
677
871
  * hosts that support it; the form route ignores unknown query params.
872
+ *
873
+ * ## An id never travels without its object (objectui#4292)
874
+ *
875
+ * `?recordId=` names an id and nothing else, so the consumer has to pick an
876
+ * object to resolve it against, and the only one it can pick is the FormView's
877
+ * own target object (`/forms/:name` does exactly that since objectui#4278).
878
+ * That is right whenever the action fired from a record OF that object, and
879
+ * nothing here used to check that it did: `target: 'showcase_task.edit'` on an
880
+ * Account record header is publishable metadata, and with per-table integer
881
+ * keys `GET /data/showcase_task/42` cheerfully answers for Account 42. Before
882
+ * #4278 the damage stopped at a duplicate insert; now that the route honours
883
+ * the param, the same mis-scoped action PATCHes a different object's record
884
+ * with no error anywhere. So the id is forwarded only when the two objects
885
+ * MATCH, and the object identity rides along as the consumer's second half of
886
+ * the check ({@link FORM_RECORD_OBJECT_PARAM}).
887
+ *
888
+ * The three outcomes, and why the third preserves rather than refuses:
889
+ *
890
+ * - **match** — forward `?recordId=&recordObject=`. The #4278 edit path,
891
+ * plus the identity the consumer re-checks against its own view metadata;
892
+ * - **mismatch** — forward NEITHER, i.e. the bare `/forms/:name`. A "log
893
+ * time" action fired from a Task at another object's create view is not a
894
+ * broken edit, it is a CREATE that happens to be launched from a record —
895
+ * which is exactly what this route did before #4278 taught it to read the
896
+ * param. Refusing here would break a shape that works;
897
+ * - **not comparable** — either end unknown ⇒ forward as before. A host that
898
+ * publishes no `objectName`, or an unqualified target name, gives this
899
+ * runner nothing to compare; tightening on absent information would turn
900
+ * working edit flows into creates on every such host. The guard only ever
901
+ * fires on information it actually has, and the consumer half applies the
902
+ * same rule to a missing `recordObject`.
678
903
  */
679
904
  async executeForm(action) {
680
905
  const name = this.evaluator.evaluate(action.target);
@@ -684,7 +909,13 @@ export class ActionRunner {
684
909
  const recordId = (this.context.record && this.context.record.id) ??
685
910
  (this.context.data && this.context.data.id);
686
911
  const base = `/forms/${name}`;
687
- const to = recordId != null ? `${base}?recordId=${encodeURIComponent(String(recordId))}` : base;
912
+ const contextObject = readContextObjectName(this.context);
913
+ const to = recordId == null || crossesObjectBoundary(name, contextObject)
914
+ ? base
915
+ : `${base}?${FORM_RECORD_ID_PARAM}=${encodeURIComponent(String(recordId))}` +
916
+ (contextObject
917
+ ? `&${FORM_RECORD_OBJECT_PARAM}=${encodeURIComponent(contextObject)}`
918
+ : '');
688
919
  if (this.navigationHandler) {
689
920
  this.navigationHandler(to, { external: false });
690
921
  return { success: true };
@@ -709,6 +940,60 @@ export class ActionRunner {
709
940
  }
710
941
  return this.navigateTo(action, rawUrl, nav);
711
942
  }
943
+ /**
944
+ * Assemble the request body for the runner's OWN `executeAPI` — the path taken
945
+ * when no host registered an `api` handler.
946
+ *
947
+ * `bodyExtra` is merged LAST, which is the spec's documented semantics for the
948
+ * key ("Static request-body fields for a `type:'api'` action, merged last
949
+ * (overrides user params)") and what the console's `apiHandler` already does on
950
+ * both of its branches. Before objectstack#6837 this path read `action.params`
951
+ * alone and never looked at `bodyExtra` at all, so an action whose payload lives
952
+ * entirely in `bodyExtra` — the shape the ADR-0087 conversion produces from an
953
+ * old object-form `params` page — POSTed an empty body here.
954
+ *
955
+ * `params` contributes only in its DEPRECATED non-array form (the compat window
956
+ * #5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
957
+ * parameter DEFINITION list, not a payload: it reaches this method unconsumed
958
+ * only when no `paramCollectionHandler` is mounted, and POSTing the definitions
959
+ * as the request body was never a payload any endpoint wanted. It now falls
960
+ * through to the context record like an absent `params` does.
961
+ *
962
+ * `bodyShape` then decides how that payload is WRAPPED, and the wrap covers the
963
+ * collected payload ONLY — `bodyExtra` stays at the top level. That is the
964
+ * spec's own wording for the key ("`{ wrap: 'data' }` nests the user-collected
965
+ * params under that key, while `recordIdParam` and other top-level keys stay
966
+ * flat") and what both console read-sites already do, so the key means one
967
+ * thing on every path that honours it. Until objectstack#6938 this path did not
968
+ * read it at all: the console `apiHandler` wrapped and the runner's own fallback
969
+ * did not, so the same action changed body shape with the host it ran under.
970
+ */
971
+ buildApiRequestBody(action) {
972
+ const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value)
973
+ ? value
974
+ : undefined);
975
+ const bodyExtra = asRecord(action.bodyExtra);
976
+ const base = asRecord(action.params) ?? this.context.data;
977
+ // `'flat'` (and an absent//malformed value) means no wrapping — the same
978
+ // predicate the console read-sites use, so an off-spec `bodyShape` degrades
979
+ // to the documented default here too instead of producing a `{ undefined: … }`
980
+ // key on the wire.
981
+ const shape = action.bodyShape;
982
+ const wrap = shape && typeof shape === 'object' && typeof shape.wrap === 'string' && shape.wrap
983
+ ? shape.wrap
984
+ : undefined;
985
+ if (wrap) {
986
+ // The payload nests; `bodyExtra` rides flat beside it. No key collision to
987
+ // resolve in this branch — that is the point of the wrap — so "merged last"
988
+ // is preserved by construction.
989
+ return { [wrap]: base ?? {}, ...bodyExtra };
990
+ }
991
+ // Nothing to merge: hand back the historical value as-is, so a host passing a
992
+ // non-object `context.data` still serializes exactly what it used to.
993
+ if (!bodyExtra)
994
+ return base ?? {};
995
+ return { ...asRecord(base), ...bodyExtra };
996
+ }
712
997
  /**
713
998
  * Execute API action — supports both simple string endpoint and complex ApiConfig.
714
999
  */
@@ -728,7 +1013,7 @@ export class ActionRunner {
728
1013
  // Simple string endpoint
729
1014
  url = this.interpolateTarget(apiConfig, action);
730
1015
  method = action.method || 'POST';
731
- body = JSON.stringify(action.params || this.context.data || {});
1016
+ body = JSON.stringify(this.buildApiRequestBody(action));
732
1017
  }
733
1018
  else {
734
1019
  // Complex ApiConfig
@@ -749,7 +1034,7 @@ export class ActionRunner {
749
1034
  : JSON.stringify(config.body);
750
1035
  }
751
1036
  else if (method !== 'GET' && method !== 'HEAD') {
752
- body = JSON.stringify(action.params || this.context.data || {});
1037
+ body = JSON.stringify(this.buildApiRequestBody(action));
753
1038
  }
754
1039
  }
755
1040
  const fetchInit = { method, headers };
@@ -816,6 +1101,37 @@ export class ActionRunner {
816
1101
  interpolateTarget(target, action) {
817
1102
  if (typeof target !== 'string' || target.indexOf('${') === -1)
818
1103
  return target;
1104
+ // ── The `${param.X}` scope is an INTERNAL runtime value bag, not an
1105
+ // authoring surface (objectui#4097 ruling B, 2026-08-11) ──────────────
1106
+ //
1107
+ // A non-array `params` reaches this line from ONE direction only: a host
1108
+ // synthesizing a value bag on its way into `runner.execute()` — the
1109
+ // collected values a param dialog just gathered, `_selectedIds` from an
1110
+ // aggregate bulk dispatch, `_rowRecord` from a row surface. Eight internal
1111
+ // producers, measured. No AUTHOR can put a value here: `ActionSchema.params`
1112
+ // is `z.array(ActionParamSchema)`, so the object form is refused at parse,
1113
+ // and the refusal message names the sanctioned spellings.
1114
+ //
1115
+ // That refusal is why this branch survived objectui#4097 while its two
1116
+ // siblings did not. The objectstack#6828 maintainer ruling targeted the
1117
+ // AUTHORING vocabulary — "a key with three meanings and no authorized
1118
+ // spelling for the third" — and both authored meanings are now handled
1119
+ // upstream at parse; PR objectui#4262 removed the third runtime read
1120
+ // (`params.newTab`). What is left here is a channel no author can reach or
1121
+ // observe, so removing it would spend a four-package runtime-contract
1122
+ // migration on an already-unreachable shape. Option A was declined for
1123
+ // exactly that reason: "plumbing expansion with zero pull".
1124
+ //
1125
+ // ⚠️ The hazard the ruling flagged, recorded here so the next reader sees
1126
+ // it at the code rather than in a closed thread: this line's safety rests
1127
+ // ENTIRELY on the producer refusing an authored object-form `params`. If a
1128
+ // future spec loosening ever admits one, an authored scope silently becomes
1129
+ // reachable again — and it would arrive as an interpolation source for URLs
1130
+ // and api targets, which is the shape a params dialog field named for a
1131
+ // navigation directive could steer. The guards are the upstream refusal pin
1132
+ // and the two local pins (`ActionRunner.resultDialog.test.ts`,
1133
+ // `ActionRunner.test.ts`); a spec change that turns them red is the signal,
1134
+ // not a nuisance.
819
1135
  const params = (action.params && typeof action.params === 'object' && !Array.isArray(action.params))
820
1136
  ? action.params
821
1137
  : {};
@@ -6,26 +6,46 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  /**
9
- * What an action is allowed to carry — objectstack#4075 step 1.
9
+ * What an action is allowed to carry — objectstack#4075, all three steps.
10
10
  *
11
- * `ActionDef` ends with `[key: string]: any`, so it accepts any key of any type.
12
- * Concretely (objectui#2990): deleting `ActionDef.execute` produced ZERO compile
13
- * errors even though the field had just been removed, and stale metadata still
14
- * authoring `execute: 'markDone'` type-checks today. The same deletion against
15
- * `@object-ui/types`' `ActionSchema` — which has no index signature — correctly
16
- * produced `TS2353` at the authoring site. One of the two readers can catch a
17
- * retired key; the other is structurally incapable.
11
+ * `ActionDef` used to end with `[key: string]: any`, so it accepted any key of
12
+ * any type. Concretely (objectui#2990): deleting `ActionDef.execute` produced
13
+ * ZERO compile errors even though the field had just been removed, and stale
14
+ * metadata authoring `execute: 'markDone'` type-checked. The same deletion
15
+ * against `@object-ui/types`' `ActionSchema` — which has no index signature —
16
+ * correctly produced `TS2353` at the authoring site. One of the two readers
17
+ * could catch a retired key; the other was structurally incapable.
18
18
  *
19
- * That asymmetry is the issue. An open key set on a DECLARED METADATA CONTRACT
19
+ * That asymmetry was the issue. An open key set on a DECLARED METADATA CONTRACT
20
20
  * is what lets a typo (`targt`, `exectue`) and a tombstoned key (`execute`) both
21
21
  * sail through to a runner that then silently does nothing — the #2169 "Mark
22
22
  * Done does nothing" shape.
23
23
  *
24
- * This module is step 1 of the staged narrowing: it makes the key set VISIBLE
25
- * and warns on anything outside it, without changing a single type. Nothing
26
- * breaks, and an invisible failure becomes an audible one. Steps 2 and 3 —
27
- * promoting the legitimate keys to explicit optional fields, then removing the
28
- * index signature — are what finally let `tsc` catch typos and retired keys.
24
+ * This module was step 1 of the staged narrowing: make the key set VISIBLE and
25
+ * warn on anything outside it, without changing a single type. Step 2 promoted
26
+ * the legitimate keys to explicit optional fields; **step 3 removed the index
27
+ * signature**, so `tsc` now rejects a typo or a retired key outright.
28
+ *
29
+ * ── Why this module SURVIVES step 3 ──────────────────────────────────────────
30
+ * Step 1 wrote that the dev-mode warning "can retire" once the index signature
31
+ * came down. Step 3 measured that and kept it, because the two mechanisms cover
32
+ * DISJOINT populations rather than the same one twice:
33
+ *
34
+ * - `tsc` sees action literals AUTHORED IN CODE. It rejects an unknown key at
35
+ * the construction site, which is the strongest possible signal — but only
36
+ * for actions that exist as TypeScript.
37
+ * - the warning below sees actions that arrive as DATA. objectstack#3903
38
+ * established that stored `sys_metadata` rows are rehydrated UNPARSED, so
39
+ * authored metadata reaches `runner.execute()` as a plain object no
40
+ * compiler ever looked at. That is the population `execute: 'markDone'`
41
+ * actually lives in, and no type can reach it.
42
+ *
43
+ * Retiring the warning would therefore have re-opened the runtime half of the
44
+ * gap while closing the compile-time half — which is why `executeScript`'s
45
+ * rename-prescription branch also survives, for the same rows and the same
46
+ * reason. What DID retire with the index signature is the inverted pin in
47
+ * `actionKeys.pin.test.ts` that asserted the signature was still there; it now
48
+ * asserts the opposite, which was always its stated completion condition.
29
49
  *
30
50
  * The lists are pinned by `__tests__/actionKeys.pin.test.ts`, which re-derives
31
51
  * each one from its actual source. A hand-maintained list that drifts from the
@@ -66,8 +86,22 @@
66
86
  * deprecating them toward a spelling that does not exist would be worse than
67
87
  * leaving them declared.
68
88
  *
69
- * Step 3 remains: remove the index signature, at which point `tsc` catches both
70
- * typos and retired keys and the dev-mode warning below can retire with it.
89
+ * ── What step 3 found the first two steps had missed ─────────────────────────
90
+ * Deleting the index signature is the only thing that can name the keys still
91
+ * hiding behind it, and it named five, in two groups:
92
+ *
93
+ * `to` / `external` / `newTab` / `replace` — the `navigation` alias's own
94
+ * spelling. Step 1 had already ruled these legitimate and listed them below in
95
+ * {@link NAVIGATION_ALIAS_KEYS}, but step 2's scope was the SPEC-owned keys, so
96
+ * they were declared as data and never as fields; `executeNavigation` read them
97
+ * through the index signature. Promoted, and deliberately not `@deprecated` —
98
+ * step 2's acceptance ruled on exactly that point.
99
+ *
100
+ * `description` — objectui vocabulary from `@object-ui/types`' renderer view of
101
+ * an action, forwarded by all four action renderers (`check:action-forward-parity`
102
+ * requires it) and read by the param-collection dialog for its subtitle
103
+ * (objectui#4192). Authorable, forwarded, read — and undeclared. These were the
104
+ * only two `TS2353`s the deletion produced across the whole workspace.
71
105
  */
72
106
  /**
73
107
  * Every property `ActionDef` declares, in declaration order.
@@ -76,7 +110,7 @@
76
110
  * `keyof ActionDef` cannot substitute for this list: the index signature widens
77
111
  * it to `string | number`, which is precisely the problem being worked around.
78
112
  */
79
- export declare const ACTION_DEF_KEYS: readonly ["type", "actionType", "name", "label", "confirmText", "confirm", "condition", "disabled", "api", "endpoint", "method", "navigate", "onClick", "reload", "close", "redirect", "toast", "successMessage", "errorMessage", "refreshAfter", "undoable", "params", "actionParams", "resultDialog", "target", "body", "openIn", "modal", "chain", "chainMode", "onSuccess", "onFailure", "opensInNewTab", "newTabUrl", "locations", "visible", "requiredPermissions", "icon", "variant", "order", "component", "objectName", "ai", "aria", "bodyExtra", "bodyShape", "mode", "recordIdField", "recordIdParam", "requiresFeature", "shortcut", "bulkEnabled"];
113
+ export declare const ACTION_DEF_KEYS: readonly ["type", "actionType", "name", "label", "description", "confirmText", "confirm", "condition", "disabled", "api", "endpoint", "method", "navigate", "to", "external", "newTab", "replace", "onClick", "reload", "close", "redirect", "toast", "successMessage", "errorMessage", "refreshAfter", "undoable", "params", "actionParams", "resultDialog", "target", "body", "openIn", "modal", "chain", "chainMode", "onSuccess", "onFailure", "opensInNewTab", "newTabUrl", "locations", "visible", "requiredPermissions", "icon", "variant", "order", "component", "objectName", "ai", "aria", "bodyExtra", "bodyShape", "mode", "recordIdField", "recordIdParam", "requiresFeature", "shortcut", "bulkEnabled"];
80
114
  /**
81
115
  * Every property `@objectstack/spec`'s `ActionSchema` declares.
82
116
  *
@@ -91,7 +125,7 @@ export declare const ACTION_DEF_KEYS: readonly ["type", "actionType", "name", "l
91
125
  * test and wrong in shipped code — `@object-ui/types` made the same call for
92
126
  * `ActionComponent`. The pin test does the walk and fails the day this drifts.
93
127
  */
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"];
128
+ export declare const SPEC_ACTION_KEYS: readonly ["_lock", "_lockDocsUrl", "_lockReason", "_lockSource", "_packageId", "_packageVersion", "_provenance", "ai", "aria", "body", "bodyExtra", "bodyShape", "bulkEnabled", "component", "confirmText", "description", "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
129
  /**
96
130
  * The `navigation` alias's own spelling of a target, read off the action itself
97
131
  * when no nested `navigate` object is present (`ActionRunner.executeNavigation`).
@@ -111,8 +145,15 @@ export declare const NAVIGATION_ALIAS_KEYS: readonly ["to", "external", "newTab"
111
145
  * Warned about separately from unknown keys, and more loudly: an unknown key is
112
146
  * probably a typo, while a retired key is metadata that used to work. That
113
147
  * distinction is why `executeScript` carries a runtime branch returning the
114
- * rename prescription — a branch that exists solely to compensate for the index
115
- * signature, and that can retire with it in step 3.
148
+ * rename prescription.
149
+ *
150
+ * Step 1 expected that branch to retire with the index signature. It does not,
151
+ * and the reason is the one in this module's header: `tsc` closed the
152
+ * code-authored half of the gap, while `execute: 'markDone'` lives in stored
153
+ * rows that reach the runner UNPARSED (objectstack#3903). The branch reads the
154
+ * key off an untyped view of the action precisely so that `ActionDef` can go on
155
+ * NOT declaring it — declaring it to make the read compile would re-legitimize
156
+ * a tombstone the spec keeps only in order to reject it by name.
116
157
  */
117
158
  export declare const RETIRED_ACTION_KEYS: Readonly<Record<string, string>>;
118
159
  /** Every key an action may legitimately carry today. */
@@ -133,3 +174,38 @@ export declare function resetActionKeyWarnings(): void;
133
174
  * down, this is the audible half.
134
175
  */
135
176
  export declare function warnOnUnknownActionKeys(action: object | null | undefined, where?: string): void;
177
+ /**
178
+ * Does this action carry the DEPRECATED object-form `params` — a static request
179
+ * payload written under the key that means "parameter definitions"?
180
+ *
181
+ * The discriminator is `Array.isArray` and the type guard is `api`, mirroring
182
+ * `@objectstack/spec`'s `inline-action-api-params-to-body-extra` conversion
183
+ * (ADR-0087 D2) key for key. Contract parity with the producer is the point: the
184
+ * conversion rewrites exactly this shape to `bodyExtra` at load, so the runner
185
+ * must recognize exactly the same shape as "the thing that got rewritten".
186
+ *
187
+ * The `api` guard is also what keeps the deprecation out of objectstack#6828's
188
+ * territory — object-form `params` on a `type: 'url'` action is a THIRD meaning
189
+ * (`interpolateTarget`'s `${param.X}` scope, `executeUrl`'s `params.newTab`) and
190
+ * `bodyExtra` is NOT its replacement. Do not widen this guard without that
191
+ * ruling.
192
+ */
193
+ export declare function hasDeprecatedObjectParams(action: object | null | undefined): boolean;
194
+ /**
195
+ * Dev-mode only: name the object-form `params` that `@objectstack/spec` 17 already
196
+ * refuses at the authoring door, and prescribe `bodyExtra`.
197
+ *
198
+ * This is the audible half of the compat window the maintainer's 2026-08-06
199
+ * ruling on objectstack#5777 ordered (direction A — a separate key, no same-name
200
+ * union): the runner keeps READING a non-array `params` as the static payload for
201
+ * one version window, says so, and the arm is deleted at 18. The loud half lives
202
+ * at the producer, where it belongs — spec's `params` field refuses the object
203
+ * form outright with a message naming `bodyExtra`, and the ADR-0087 conversion
204
+ * rewrites sources that still carry it. So a warning is the correct severity
205
+ * HERE: by the time metadata reaches this runner the producer has already had its
206
+ * say, and what is left to catch is a host composing an ActionDef in code.
207
+ *
208
+ * Dev-only and warn-once, matching {@link warnOnUnknownActionKeys} — a warning
209
+ * that floods a production console is a warning that gets muted.
210
+ */
211
+ export declare function warnOnDeprecatedObjectParams(action: object | null | undefined, where?: string): void;