@object-ui/core 17.3.0 → 17.6.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 (99) hide show
  1. package/CHANGELOG.md +2018 -0
  2. package/README.md +38 -34
  3. package/dist/actions/ActionEngine.d.ts +43 -26
  4. package/dist/actions/ActionEngine.js +67 -54
  5. package/dist/actions/ActionRunner.d.ts +281 -31
  6. package/dist/actions/ActionRunner.js +458 -36
  7. package/dist/actions/actionKeys.d.ts +163 -27
  8. package/dist/actions/actionKeys.js +238 -27
  9. package/dist/actions/actionResponse.js +1 -1
  10. package/dist/actions/index.d.ts +1 -0
  11. package/dist/actions/index.js +1 -0
  12. package/dist/actions/recordIdParam.d.ts +77 -0
  13. package/dist/actions/recordIdParam.js +37 -0
  14. package/dist/actions/serverActionHandler.d.ts +1 -1
  15. package/dist/actions/serverActionHandler.js +1 -1
  16. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  17. package/dist/data-scope/ViewDataProvider.js +83 -5
  18. package/dist/data-scope/element-data-source.d.ts +113 -0
  19. package/dist/data-scope/element-data-source.js +216 -0
  20. package/dist/data-scope/index.d.ts +2 -1
  21. package/dist/data-scope/index.js +1 -0
  22. package/dist/evaluator/ExpressionEvaluator.d.ts +42 -1
  23. package/dist/evaluator/ExpressionEvaluator.js +42 -1
  24. package/dist/evaluator/SafeExpressionParser.js +0 -4
  25. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  26. package/dist/evaluator/declaredPredicate.js +152 -0
  27. package/dist/evaluator/fieldRules.d.ts +35 -0
  28. package/dist/evaluator/fieldRules.js +79 -20
  29. package/dist/evaluator/index.d.ts +2 -0
  30. package/dist/evaluator/index.js +2 -0
  31. package/dist/evaluator/listConditional.d.ts +54 -4
  32. package/dist/evaluator/listConditional.js +125 -19
  33. package/dist/evaluator/optionRules.d.ts +55 -0
  34. package/dist/evaluator/optionRules.js +60 -0
  35. package/dist/evaluator/predicateInput.d.ts +51 -3
  36. package/dist/evaluator/predicateInput.js +62 -5
  37. package/dist/evaluator/rowPredicateCanon.d.ts +67 -0
  38. package/dist/evaluator/rowPredicateCanon.js +208 -0
  39. package/dist/index.d.ts +8 -1
  40. package/dist/index.js +31 -1
  41. package/dist/protocols/DndProtocol.d.ts +42 -1
  42. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  43. package/dist/protocols/SharingProtocol.d.ts +11 -1
  44. package/dist/registry/Registry.d.ts +62 -16
  45. package/dist/theme/ThemeEngine.d.ts +0 -8
  46. package/dist/theme/ThemeEngine.js +22 -103
  47. package/dist/theme/index.d.ts +1 -1
  48. package/dist/theme/index.js +7 -1
  49. package/dist/types/index.d.ts +125 -12
  50. package/dist/utils/chart-presentation.d.ts +205 -0
  51. package/dist/utils/chart-presentation.js +280 -0
  52. package/dist/utils/chart-series.d.ts +390 -23
  53. package/dist/utils/chart-series.js +776 -32
  54. package/dist/utils/column-identity.d.ts +43 -0
  55. package/dist/utils/column-identity.js +47 -1
  56. package/dist/utils/compare-to.d.ts +40 -13
  57. package/dist/utils/compare-to.js +4 -32
  58. package/dist/utils/dashboard-filters.d.ts +54 -8
  59. package/dist/utils/dashboard-filters.js +261 -12
  60. package/dist/utils/dataset-format.d.ts +73 -14
  61. package/dist/utils/dataset-format.js +142 -9
  62. package/dist/utils/dataset-pivot.d.ts +28 -7
  63. package/dist/utils/dataset-pivot.js +27 -6
  64. package/dist/utils/debug.js +7 -0
  65. package/dist/utils/dom-props.d.ts +137 -0
  66. package/dist/utils/dom-props.js +153 -0
  67. package/dist/utils/expand-fields.d.ts +97 -7
  68. package/dist/utils/expand-fields.js +97 -7
  69. package/dist/utils/filter-tokens.js +0 -1
  70. package/dist/utils/freeze-schema.d.ts +38 -1
  71. package/dist/utils/freeze-schema.js +10 -0
  72. package/dist/utils/humanize-label.d.ts +60 -0
  73. package/dist/utils/humanize-label.js +62 -0
  74. package/dist/utils/managedBy.d.ts +27 -2
  75. package/dist/utils/normalize-list-view.d.ts +36 -6
  76. package/dist/utils/normalize-list-view.js +93 -9
  77. package/dist/utils/number-display.d.ts +155 -0
  78. package/dist/utils/number-display.js +71 -0
  79. package/dist/utils/predicate-fields.d.ts +29 -4
  80. package/dist/utils/predicate-fields.js +44 -4
  81. package/dist/utils/resolve-view-id.d.ts +31 -0
  82. package/dist/utils/resolve-view-id.js +45 -0
  83. package/dist/utils/retired-field-types.d.ts +90 -0
  84. package/dist/utils/retired-field-types.js +124 -0
  85. package/dist/utils/sort-query.d.ts +57 -0
  86. package/dist/utils/sort-query.js +35 -0
  87. package/dist/utils/unmaterialized-fields.d.ts +52 -0
  88. package/dist/utils/unmaterialized-fields.js +57 -0
  89. package/dist/validation/index.d.ts +1 -0
  90. package/dist/validation/index.js +1 -0
  91. package/dist/validation/schema-validator.js +105 -0
  92. package/dist/validation/server-owned-value.d.ts +62 -0
  93. package/dist/validation/server-owned-value.js +99 -0
  94. package/dist/validation/validators/object-validation-engine.js +0 -2
  95. package/package.json +5 -5
  96. package/dist/__benchmarks__/core.bench.d.ts +0 -8
  97. package/dist/__benchmarks__/core.bench.js +0 -53
  98. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  99. package/dist/utils/merge-views-into-objects.js +0 -39
@@ -5,9 +5,11 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
9
- import { globalUndoManager } from './UndoManager';
10
- import { warnOnUnknownActionKeys } from './actionKeys';
8
+ import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator.js';
9
+ import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';
10
+ import { globalUndoManager } from './UndoManager.js';
11
+ import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys.js';
12
+ import { readActionPayload } from './actionResponse.js';
11
13
  /**
12
14
  * Bind the server-CEL-parity `os.user` identity alias into an action context
13
15
  * (#2358 trap 1). `@objectstack/spec` documents the canonical CEL identity
@@ -27,6 +29,145 @@ function withIdentityAlias(context) {
27
29
  return context;
28
30
  return { ...context, os: { ...(os && typeof os === 'object' ? os : {}), user } };
29
31
  }
32
+ /**
33
+ * The query param naming the record a form route opens (`?recordId=<id>`).
34
+ *
35
+ * The console's reserved-param registry (`@object-ui/app-shell`,
36
+ * `src/urlParams.ts` → `RECORD_DRAWER_PARAM`) owns this spelling and
37
+ * `apps/console`'s `FormPage` re-declares it as `FORM_RECORD_ID_PARAM`. It is a
38
+ * literal in all three places because `@object-ui/core` sits BELOW both in the
39
+ * dependency graph and cannot import either; the console's `FormPage.test.ts`
40
+ * pins the spellings equal so they cannot drift apart in silence.
41
+ */
42
+ const FORM_RECORD_ID_PARAM = 'recordId';
43
+ /**
44
+ * The query param carrying the OBJECT the forwarded {@link FORM_RECORD_ID_PARAM}
45
+ * belongs to (objectui#4292).
46
+ *
47
+ * It is an ASSERTION, never an override: the form's object always comes from the
48
+ * FormView metadata, and this param exists solely so the consumer can refuse
49
+ * when the two disagree. (The registry's `formObject` is the other thing — it
50
+ * SELECTS which object the record-form overlay edits. Same `<param>Object`
51
+ * naming, deliberately opposite powers, which is why they are separate names on
52
+ * separate surfaces.) Widening it into a selector would hand any hand-authored
53
+ * URL the ability to point a form at an arbitrary object — the hole this closes,
54
+ * re-opened from the other side.
55
+ */
56
+ const FORM_RECORD_OBJECT_PARAM = 'recordObject';
57
+ /**
58
+ * The object the firing context's record belongs to, or `undefined` when the
59
+ * host did not say (objectui#4292).
60
+ *
61
+ * `context.objectName` is MEASURED to be this repo's one spelling of that fact,
62
+ * not a new convention invented here:
63
+ *
64
+ * - `@object-ui/react`'s `ActionProvider` documents the flat trio `record`,
65
+ * `user`, `objectName` and mirrors it into the canonical `ctx.*` scope;
66
+ * - `useConsoleActionRuntime` seeds `context: { ...(objectName ? { objectName }
67
+ * : {}), … }` — so every console surface that fires a record action
68
+ * (`DeclaredActionsBar`, `ObjectView`, `RecordDetailView`) already publishes
69
+ * it, and `RecordDetailView` passes it explicitly a second time;
70
+ * - `app-shell`'s `recordFormNavigation` resolver already reads exactly this
71
+ * key as its last precedence step for `navigate_create` / `navigate_edit`.
72
+ *
73
+ * `action.objectName` is deliberately NOT consulted. It declares which object an
74
+ * ACTION belongs to, which is a statement about the action's placement, not
75
+ * about the record the id was read from — and trusting metadata about the action
76
+ * to describe the record is precisely the conflation that made this defect
77
+ * possible.
78
+ */
79
+ function readContextObjectName(context) {
80
+ const name = context.objectName;
81
+ return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined;
82
+ }
83
+ /**
84
+ * Whether opening FormView `viewName` from a record of `contextObject` would
85
+ * cross an object boundary (objectui#4292).
86
+ *
87
+ * View identity is `<object>.<key>` (ADR-0017; `viewEnvelope` builds exactly
88
+ * that, `defaultListViewId` compares exactly this way), so the question is
89
+ * answered as a PREFIX match against the object we already hold rather than by
90
+ * parsing an object name out of the view name — the target's object is a fact
91
+ * the metadata owns, and this runner only asks whether it is the one in hand.
92
+ *
93
+ * Answers `false` — "not comparable, carry on" — whenever either end is missing:
94
+ * no `contextObject` (host published none), or an unqualified `viewName` (no
95
+ * dot, so it names no object). Only a qualified name under a DIFFERENT object
96
+ * is a boundary crossing.
97
+ */
98
+ function crossesObjectBoundary(viewName, contextObject) {
99
+ if (!contextObject)
100
+ return false;
101
+ if (!viewName.includes('.'))
102
+ return false;
103
+ return !viewName.startsWith(`${contextObject}.`);
104
+ }
105
+ /*
106
+ * ## The two predicate gates in `execute`, and the one question they ask first
107
+ *
108
+ * `execute` gates on `condition` and on `disabled`, and both need ONE question
109
+ * answered before evaluating — "is a gate DECLARED here, i.e. is there a
110
+ * condition to reach a verdict on?" — for OPPOSITE reasons, which is exactly why
111
+ * the question has to be asked separately from the verdict:
112
+ *
113
+ * - `disabled` (objectui#3848): an EMPTY predicate must not be handed to
114
+ * `evaluateCondition`, whose answer for "no condition" is `true` = BLOCKED.
115
+ * Measured consequence of asking `!= null && !== false` instead:
116
+ * `disabled: ''` returned `{ success: false, error: 'Action is disabled' }`
117
+ * and the handler never ran.
118
+ * - `condition` (objectui#3872): a DECLARED-FALSE predicate must not be
119
+ * skipped by a truthiness test. `if (action.condition)` never asked whether
120
+ * a gate was declared, so `condition: false` — the most explicit "never
121
+ * execute" an author can write — landed on `if (false)`, the evaluator was
122
+ * never consulted, and the action RAN. Measured on `origin/main` @
123
+ * `2937bcf7d`: `condition: false` → `{"success":true}`, handler ran, while
124
+ * the semantically identical `{ dialect: 'cel', source: 'false' }` was
125
+ * refused. Asking "declared?" routes `false` to `evaluateCondition`, which
126
+ * short-circuits booleans (`if (typeof condition === 'boolean') return
127
+ * condition`) and blocks.
128
+ *
129
+ * Both read {@link hasDeclaredPredicate} — the repo's one definition of that
130
+ * question, in `evaluator/declaredPredicate.ts` beside the normalizer it is
131
+ * derived from, with the scope objectui#3850 ruled on (`''` / whitespace-only /
132
+ * empty-`source` envelope / non-predicate junk are NOT declared; a declared
133
+ * `false` IS). This gate arrived here first as a module-private helper with a
134
+ * scope note saying it stayed private until that ruling landed; it has, so the
135
+ * definition moved down a layer and the renderer side reads the same one
136
+ * (`components/renderers/action/visibility-gate.ts` re-exports it as
137
+ * `hasDeclaredVisibilityGate`, `SchemaRenderer`'s `disabled` chain calls it).
138
+ *
139
+ * ## Why the gates normalize to DECIDE but evaluate the RAW value
140
+ *
141
+ * Historically the two were not interchangeable for a string already spelled as
142
+ * a `${…}` template: `toPredicateInput` assumed a bare expression and wrapped
143
+ * unconditionally, so `'${x}'` became `'${${x}}'`, which no longer matched the
144
+ * single-template fast path and did not parse — leaving a constant verdict whose
145
+ * direction was set by the caller's error policy (fail-soft got the unparsed
146
+ * string back, `Boolean(…)` = `true`; fail-closed got a throw). On `disabled`
147
+ * that meant "always blocked", on `condition` the opposite ("always execute").
148
+ * That defect was objectui#3871, fixed at the normalizer: an already-`${…}`
149
+ * string is returned untouched, the normalizer is idempotent, and
150
+ * `evaluateCondition(toPredicateInput(raw))` agrees with
151
+ * `evaluateCondition(raw)` on every shape these gates accept.
152
+ *
153
+ * The gates still read the raw value. Nothing forces the change now that the two
154
+ * agree, and it would be churn on the execution path rather than a fix — but the
155
+ * reason has shifted from "must" to "no reason to", so the equality is pinned
156
+ * next to each gate's suite (`ActionRunner.disabledGate.test.ts` /
157
+ * `ActionRunner.conditionGate.test.ts`, the two cases that replaced the
158
+ * objectui#3871 tripwires) instead of being assumed. Every non-empty shape —
159
+ * boolean, bare CEL, `${…}` template, `{ dialect, source }` envelope — keeps the
160
+ * verdict it reaches today.
161
+ *
162
+ * The `visible`-filter template in `ActionEngine.getActionsForLocation` used to
163
+ * be the one place this scope was NOT shared: its non-predicate branch kept a
164
+ * historical `Boolean(raw)` coercion, so `0` there meant "hidden" — fail-CLOSED
165
+ * on junk, the opposite of what this module committed to for `disabled`
166
+ * (`catch { isDisabled = false }`). That filter now reads
167
+ * {@link hasDeclaredPredicate} too (objectui#3957), so a value that is not a
168
+ * predicate at all decides nothing on either key, at either entry: `0` / `{}` are
169
+ * "no gate" here, in the engine filter, and in the shared definition.
170
+ */
30
171
  export class ActionRunner {
31
172
  constructor(context = {}) {
32
173
  Object.defineProperty(this, "handlers", {
@@ -191,16 +332,45 @@ export class ActionRunner {
191
332
  // binds a handler, and binding no handler silently is the #2169 "Mark Done
192
333
  // does nothing" shape. Dev-only, warn-once, changes nothing (#4075 step 1).
193
334
  warnOnUnknownActionKeys(action);
335
+ // The compat window for the object-form `params` payload (#5777, maintainer
336
+ // ruling 2026-08-06 direction A). Checked HERE, before the param-collection
337
+ // block below rewrites `action.params` into a values map — after that point
338
+ // an action that authored a legitimate ActionParam[] DEFINITION array also
339
+ // carries an object under `params`, and the warning could no longer tell the
340
+ // two apart. Dev-only, warn-once, changes nothing.
341
+ warnOnDeprecatedObjectParams(action);
194
342
  // Resolve the action type
195
343
  const actionType = action.type || action.actionType || action.name || '';
196
- // Conditional execution
197
- if (action.condition) {
344
+ // Conditional execution. Ask "is a `condition` gate DECLARED?" — not "is
345
+ // the raw value truthy?" (objectui#3872). Truthiness cannot answer that
346
+ // question, and on this key it answered it in the OVER-PERMISSIVE
347
+ // direction: `condition: false` (the most explicit "never execute" an
348
+ // author can write, and what a template that switches an action off
349
+ // emits) fell on `if (false)`, so the evaluator was never consulted and
350
+ // the action executed anyway — while the semantically identical
351
+ // `{ dialect: 'cel', source: 'false' }` was refused. `evaluateCondition`
352
+ // short-circuits booleans, so once the gate asks the right question the
353
+ // verdict needs no boolean branch of its own. See `hasDeclaredPredicate`
354
+ // for the shared "declared?" scope (objectui#3850) and for why the verdict
355
+ // still reads the RAW value (`toPredicateInput` used to double-wrap an
356
+ // already-`${…}` string into a constant verdict — objectui#3871, fixed at
357
+ // the normalizer; on THIS key that constant meant "always execute").
358
+ if (hasDeclaredPredicate(action.condition)) {
198
359
  const shouldExecute = this.evaluator.evaluateCondition(action.condition);
199
360
  if (!shouldExecute) {
200
361
  return { success: false, error: 'Action condition not met' };
201
362
  }
202
363
  }
203
- if (action.disabled != null && action.disabled !== false) {
364
+ // Ask "is a `disabled` gate DECLARED?" — not "is the key present and not
365
+ // `false`?" (objectui#3848). The old test let every EMPTY predicate reach
366
+ // `evaluateCondition`, whose answer for "no condition" is `true`, which on
367
+ // this key means BLOCKED: `disabled: ''` / `' '` /
368
+ // `{ dialect: 'cel', source: '' }` all returned `Action is disabled` with
369
+ // the handler never invoked. See `hasDeclaredPredicate` above for why the
370
+ // gate normalizes to decide while the verdict below reads the RAW value
371
+ // (historically load-bearing: `toPredicateInput` double-wrapped an
372
+ // already-`${…}` string — objectui#3871, now fixed, so the two agree).
373
+ if (hasDeclaredPredicate(action.disabled)) {
204
374
  // `disabled` may be a boolean, a CEL string, or the normalized envelope
205
375
  // `{ dialect, source }` (what `objectstack build` emits). The previous
206
376
  // code only evaluated the STRING form and treated any object as truthy,
@@ -220,14 +390,13 @@ export class ActionRunner {
220
390
  return { success: false, error: 'Action is disabled' };
221
391
  }
222
392
  }
223
- // Confirmation (structured or simple)
224
- const confirmMessage = action.confirm?.message || action.confirmText;
225
- if (confirmMessage) {
226
- const confirmed = await this.confirmHandler(this.evaluator.evaluate(confirmMessage), action.confirm ? {
227
- title: action.confirm.title,
228
- confirmText: action.confirm.confirmText,
229
- cancelText: action.confirm.cancelText,
230
- } : undefined);
393
+ // Confirmation. One spelling: `confirmText` — the only one the
394
+ // translation bundle can address (`…._actions.{name}.confirmText`). The
395
+ // structured `confirm.message` read that used to OUTRANK it is retired
396
+ // (objectui#4314): it had zero producers and no bundle key, so a dialog
397
+ // authored that way silently lost localization.
398
+ if (action.confirmText) {
399
+ const confirmed = await this.confirmHandler(this.evaluator.evaluate(action.confirmText));
231
400
  if (!confirmed) {
232
401
  return { success: false, error: 'Action cancelled by user' };
233
402
  }
@@ -389,12 +558,10 @@ export class ActionRunner {
389
558
  catch (err) {
390
559
  // Acknowledgement failure is non-fatal; the underlying action already
391
560
  // succeeded. Log so consumers can wire it through their error reporter.
392
- // eslint-disable-next-line no-console
393
561
  console.warn('[ActionRunner] resultDialog handler rejected; treating as acknowledged', err);
394
562
  }
395
563
  }
396
564
  else {
397
- // eslint-disable-next-line no-console
398
565
  console.warn('[ActionRunner] action.resultDialog set but no resultDialogHandler registered — the response value will not be shown to the user.', { action: action.name, data: result.data });
399
566
  }
400
567
  }
@@ -417,16 +584,94 @@ export class ActionRunner {
417
584
  if (chainResult.reload)
418
585
  result.reload = true;
419
586
  }
420
- // Execute onSuccess/onFailure callbacks
587
+ // ── ActionSchema.onSuccess — post-success navigation ────────────────────
588
+ //
589
+ // objectui#5221, the console half of objectstack#9566/#9474. The spec
590
+ // declares `onSuccess` as a CLOSED STRICT object
591
+ // `{ navigate: string, openIn: 'self' | 'newTab' }`, refine-scoped to
592
+ // `type: 'api'` and `type: 'script'` — the two types whose success event
593
+ // carries a server response for `${result.*}` to read.
594
+ //
595
+ // This runner's OWN `ActionDef.onSuccess` predates that key and means
596
+ // something else entirely: `ActionDef | ActionDef[]`, chained callbacks.
597
+ // The two are told apart by the spec's own declaration — a non-array object
598
+ // whose `navigate` is a STRING is the spec block and nothing else can be:
599
+ // `navigate` on a callback ActionDef is the deprecated nested navigation
600
+ // ENVELOPE (`executeNavigation` reads `navigate.to`), so a string there has
601
+ // never been runnable. This is a NARROWING to the declared contract, not a
602
+ // lenient fallback: a shape the spec refuses gets no new reading here.
603
+ //
604
+ // Before this branch existed, the ruled shape fell into the callback path,
605
+ // dispatched `{ navigate: '<string>' }` as an action, and failed inside
606
+ // `executeNavigation` with "No URL provided for navigation action" — the
607
+ // author got a red toast and no hop.
421
608
  if (result.success && action.onSuccess) {
422
- const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
423
- await this.executeChain(callbacks, 'sequential');
609
+ const navigation = readOnSuccessNavigation(action.onSuccess);
610
+ if (navigation) {
611
+ this.navigateOnSuccess(navigation, action, result);
612
+ }
613
+ else {
614
+ const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
615
+ await this.executeChain(callbacks, 'sequential');
616
+ }
424
617
  }
425
618
  if (!result.success && action.onFailure) {
426
619
  const callbacks = Array.isArray(action.onFailure) ? action.onFailure : [action.onFailure];
427
620
  await this.executeChain(callbacks, 'sequential');
428
621
  }
429
622
  }
623
+ /**
624
+ * Perform the `ActionSchema.onSuccess` hop for an action that just succeeded.
625
+ *
626
+ * **The router is the app's own.** `navigationHandler` is the seam every
627
+ * navigator in this file already goes through, and the console wires it to
628
+ * react-router's `navigate` — so `openIn: 'self'` is a real SPA route hop,
629
+ * in place, immune to popup blocking. No `window.location` / `window.open`
630
+ * mechanism is introduced here; the no-handler fallback is the same
631
+ * `result.redirect` hand-off `navigateTo` already uses.
632
+ *
633
+ * **No default is written here.** `openIn` is a materialised
634
+ * `.default('self')` in the spec, so parse output always carries a resolved
635
+ * member; this reads the ONE member that changes the branch and leaves the
636
+ * other implicit. A `?? 'self'` would be a second source of truth for a
637
+ * default the producer already resolved.
638
+ *
639
+ * **The two `openIn` spellings never cross.** This reads
640
+ * `onSuccess.openIn` (`'self' | 'newTab'`) and nothing else — never the
641
+ * top-level `type: 'url'` switch, which spells its new-tab member
642
+ * `'new-tab'`. Spec refuses each crossover spelling with a keyed error; a
643
+ * renderer that accepted either would be a second, looser contract than the
644
+ * one authors are validated against.
645
+ */
646
+ navigateOnSuccess(block, action, result) {
647
+ // `${result.*}` reads the HANDLER's return value, one level below the
648
+ // action envelope — the same level `readActionPayload` hands the
649
+ // `redirectUrl` convention. Reading `result.data` raw would see the
650
+ // `{ success, data }` envelope on pre-objectstack#3962 servers, which is
651
+ // the "one level too shallow" bug (#2904) in a new place.
652
+ const url = this.interpolateTarget(block.navigate, action, readActionPayload(result.data));
653
+ // `navigate` is author-supplied metadata and lands in a URL position, so
654
+ // it goes through the same scheme guard as every other navigator here.
655
+ if (!this.isValidUrl(url)) {
656
+ console.warn('[ActionRunner] onSuccess.navigate resolved to a URL this runner refuses to open '
657
+ + '(only http://, https:// and relative URLs are allowed) — no navigation performed.', { action: action.name, navigate: block.navigate, resolved: url });
658
+ return;
659
+ }
660
+ const newTab = block.openIn === 'newTab';
661
+ // Same external-URL convention `navigateTo` hands the handler; `newTab`
662
+ // stays the declared choice alone, never `?? isExternal`.
663
+ const external = url.startsWith('http://') || url.startsWith('https://');
664
+ if (this.navigationHandler) {
665
+ this.navigationHandler(url, { external, newTab });
666
+ return;
667
+ }
668
+ if (newTab) {
669
+ window.open(url, '_blank', 'noopener,noreferrer');
670
+ }
671
+ else {
672
+ result.redirect = url;
673
+ }
674
+ }
430
675
  /**
431
676
  * Execute script action — evaluates client-side expression via ExpressionEvaluator.
432
677
  * Supports ${} template expressions referencing data, record, user context.
@@ -452,12 +697,25 @@ export class ActionRunner {
452
697
  'build one with createServerActionHandler from @object-ui/core.',
453
698
  };
454
699
  }
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') {
700
+ // Read off an UNTYPED view of the action on purpose, and this branch
701
+ // survives step 3 on purpose (objectstack#4075).
702
+ //
703
+ // `ActionDef` no longer declares `execute` — nor any open index signature
704
+ // — so `tsc` now catches the retired key at every site that AUTHORS an
705
+ // action literal in code. That is the half of the problem the type can
706
+ // reach. It is not this half: #3903 established that stored
707
+ // `sys_metadata` rows are rehydrated UNPARSED, so metadata written before
708
+ // spec 17 arrives here as a plain object the compiler never saw, still
709
+ // carrying `execute`. Declaring the key to make this read compile would
710
+ // re-legitimize a tombstone (#3855 removed it; the spec keeps it only to
711
+ // reject it BY NAME), and deleting the branch would send those rows back
712
+ // to a bare "no script provided" — which reads as "you forgot a field" to
713
+ // an author who did write one, the objectstack#2169 shape.
714
+ //
715
+ // So: the cast is the honest spelling of "a key this type deliberately
716
+ // does not have, read from a row this type never validated".
717
+ const retiredExecute = action.execute;
718
+ if (typeof retiredExecute === 'string') {
461
719
  return {
462
720
  success: false,
463
721
  error: '`execute` was removed in @objectstack/spec 17 — rename the key to `target`. ' +
@@ -563,12 +821,24 @@ export class ActionRunner {
563
821
  return { success: true };
564
822
  }
565
823
  // `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.
824
+ // it wins over the legacy `navigate.newTab` escape hatch and the
825
+ // external-URL heuristic.
826
+ //
827
+ // `params.newTab` used to sit between them and is RETIRED (objectui#4097,
828
+ // executing the objectstack#6828 maintainer ruling of 2026-08-10). It could
829
+ // only ever fire on an OBJECT-form `params`, which the spec has always
830
+ // refused on an action (`params` is `z.array(ActionParamSchema)`), so no
831
+ // validated stack could reach it; and no internal caller synthesizes a
832
+ // navigation directive into that bag — the runtime value bags carry
833
+ // collected dialog values, `_selectedIds` and `_rowRecord`, never `newTab`.
834
+ // Removing it also closes the collision hazard where a params dialog
835
+ // declaring a field literally named `newTab` would have had the user's own
836
+ // input silently steer navigation. The spec's refusal message names the
837
+ // sanctioned spelling: `openIn: 'new-tab'`.
568
838
  const openIn = source.openIn ?? action.openIn;
569
839
  const newTab = openIn === 'new-tab' ? true
570
840
  : openIn === 'self' ? false
571
- : (source.newTab ?? action.params?.newTab ?? isExternal);
841
+ : (source.newTab ?? isExternal);
572
842
  // History semantics, the one thing the `navigation` shape carries that
573
843
  // `ActionSchema` has no field for. Omitted from the handler call when
574
844
  // unset so hosts see the same options object they always did.
@@ -675,6 +945,37 @@ export class ActionRunner {
675
945
  * through to `executeActionSchema` and silently no-opped (the "Log Time does
676
946
  * nothing" report). The current record id is forwarded as `?recordId=` for
677
947
  * hosts that support it; the form route ignores unknown query params.
948
+ *
949
+ * ## An id never travels without its object (objectui#4292)
950
+ *
951
+ * `?recordId=` names an id and nothing else, so the consumer has to pick an
952
+ * object to resolve it against, and the only one it can pick is the FormView's
953
+ * own target object (`/forms/:name` does exactly that since objectui#4278).
954
+ * That is right whenever the action fired from a record OF that object, and
955
+ * nothing here used to check that it did: `target: 'showcase_task.edit'` on an
956
+ * Account record header is publishable metadata, and with per-table integer
957
+ * keys `GET /data/showcase_task/42` cheerfully answers for Account 42. Before
958
+ * #4278 the damage stopped at a duplicate insert; now that the route honours
959
+ * the param, the same mis-scoped action PATCHes a different object's record
960
+ * with no error anywhere. So the id is forwarded only when the two objects
961
+ * MATCH, and the object identity rides along as the consumer's second half of
962
+ * the check ({@link FORM_RECORD_OBJECT_PARAM}).
963
+ *
964
+ * The three outcomes, and why the third preserves rather than refuses:
965
+ *
966
+ * - **match** — forward `?recordId=&recordObject=`. The #4278 edit path,
967
+ * plus the identity the consumer re-checks against its own view metadata;
968
+ * - **mismatch** — forward NEITHER, i.e. the bare `/forms/:name`. A "log
969
+ * time" action fired from a Task at another object's create view is not a
970
+ * broken edit, it is a CREATE that happens to be launched from a record —
971
+ * which is exactly what this route did before #4278 taught it to read the
972
+ * param. Refusing here would break a shape that works;
973
+ * - **not comparable** — either end unknown ⇒ forward as before. A host that
974
+ * publishes no `objectName`, or an unqualified target name, gives this
975
+ * runner nothing to compare; tightening on absent information would turn
976
+ * working edit flows into creates on every such host. The guard only ever
977
+ * fires on information it actually has, and the consumer half applies the
978
+ * same rule to a missing `recordObject`.
678
979
  */
679
980
  async executeForm(action) {
680
981
  const name = this.evaluator.evaluate(action.target);
@@ -684,7 +985,13 @@ export class ActionRunner {
684
985
  const recordId = (this.context.record && this.context.record.id) ??
685
986
  (this.context.data && this.context.data.id);
686
987
  const base = `/forms/${name}`;
687
- const to = recordId != null ? `${base}?recordId=${encodeURIComponent(String(recordId))}` : base;
988
+ const contextObject = readContextObjectName(this.context);
989
+ const to = recordId == null || crossesObjectBoundary(name, contextObject)
990
+ ? base
991
+ : `${base}?${FORM_RECORD_ID_PARAM}=${encodeURIComponent(String(recordId))}` +
992
+ (contextObject
993
+ ? `&${FORM_RECORD_OBJECT_PARAM}=${encodeURIComponent(contextObject)}`
994
+ : '');
688
995
  if (this.navigationHandler) {
689
996
  this.navigationHandler(to, { external: false });
690
997
  return { success: true };
@@ -709,6 +1016,60 @@ export class ActionRunner {
709
1016
  }
710
1017
  return this.navigateTo(action, rawUrl, nav);
711
1018
  }
1019
+ /**
1020
+ * Assemble the request body for the runner's OWN `executeAPI` — the path taken
1021
+ * when no host registered an `api` handler.
1022
+ *
1023
+ * `bodyExtra` is merged LAST, which is the spec's documented semantics for the
1024
+ * key ("Static request-body fields for a `type:'api'` action, merged last
1025
+ * (overrides user params)") and what the console's `apiHandler` already does on
1026
+ * both of its branches. Before objectstack#6837 this path read `action.params`
1027
+ * alone and never looked at `bodyExtra` at all, so an action whose payload lives
1028
+ * entirely in `bodyExtra` — the shape the ADR-0087 conversion produces from an
1029
+ * old object-form `params` page — POSTed an empty body here.
1030
+ *
1031
+ * `params` contributes only in its DEPRECATED non-array form (the compat window
1032
+ * #5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
1033
+ * parameter DEFINITION list, not a payload: it reaches this method unconsumed
1034
+ * only when no `paramCollectionHandler` is mounted, and POSTing the definitions
1035
+ * as the request body was never a payload any endpoint wanted. It now falls
1036
+ * through to the context record like an absent `params` does.
1037
+ *
1038
+ * `bodyShape` then decides how that payload is WRAPPED, and the wrap covers the
1039
+ * collected payload ONLY — `bodyExtra` stays at the top level. That is the
1040
+ * spec's own wording for the key ("`{ wrap: 'data' }` nests the user-collected
1041
+ * params under that key, while `recordIdParam` and other top-level keys stay
1042
+ * flat") and what both console read-sites already do, so the key means one
1043
+ * thing on every path that honours it. Until objectstack#6938 this path did not
1044
+ * read it at all: the console `apiHandler` wrapped and the runner's own fallback
1045
+ * did not, so the same action changed body shape with the host it ran under.
1046
+ */
1047
+ buildApiRequestBody(action) {
1048
+ const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value)
1049
+ ? value
1050
+ : undefined);
1051
+ const bodyExtra = asRecord(action.bodyExtra);
1052
+ const base = asRecord(action.params) ?? this.context.data;
1053
+ // `'flat'` (and an absent//malformed value) means no wrapping — the same
1054
+ // predicate the console read-sites use, so an off-spec `bodyShape` degrades
1055
+ // to the documented default here too instead of producing a `{ undefined: … }`
1056
+ // key on the wire.
1057
+ const shape = action.bodyShape;
1058
+ const wrap = shape && typeof shape === 'object' && typeof shape.wrap === 'string' && shape.wrap
1059
+ ? shape.wrap
1060
+ : undefined;
1061
+ if (wrap) {
1062
+ // The payload nests; `bodyExtra` rides flat beside it. No key collision to
1063
+ // resolve in this branch — that is the point of the wrap — so "merged last"
1064
+ // is preserved by construction.
1065
+ return { [wrap]: base ?? {}, ...bodyExtra };
1066
+ }
1067
+ // Nothing to merge: hand back the historical value as-is, so a host passing a
1068
+ // non-object `context.data` still serializes exactly what it used to.
1069
+ if (!bodyExtra)
1070
+ return base ?? {};
1071
+ return { ...asRecord(base), ...bodyExtra };
1072
+ }
712
1073
  /**
713
1074
  * Execute API action — supports both simple string endpoint and complex ApiConfig.
714
1075
  */
@@ -728,7 +1089,7 @@ export class ActionRunner {
728
1089
  // Simple string endpoint
729
1090
  url = this.interpolateTarget(apiConfig, action);
730
1091
  method = action.method || 'POST';
731
- body = JSON.stringify(action.params || this.context.data || {});
1092
+ body = JSON.stringify(this.buildApiRequestBody(action));
732
1093
  }
733
1094
  else {
734
1095
  // Complex ApiConfig
@@ -749,7 +1110,7 @@ export class ActionRunner {
749
1110
  : JSON.stringify(config.body);
750
1111
  }
751
1112
  else if (method !== 'GET' && method !== 'HEAD') {
752
- body = JSON.stringify(action.params || this.context.data || {});
1113
+ body = JSON.stringify(this.buildApiRequestBody(action));
753
1114
  }
754
1115
  }
755
1116
  const fetchInit = { method, headers };
@@ -813,16 +1174,58 @@ export class ActionRunner {
813
1174
  * param. Consumers can extend by stuffing extra keys under
814
1175
  * `context.ctx = {...}` before calling `runner.execute()`.
815
1176
  */
816
- interpolateTarget(target, action) {
1177
+ interpolateTarget(target, action, resultScope) {
817
1178
  if (typeof target !== 'string' || target.indexOf('${') === -1)
818
1179
  return target;
1180
+ // ── The `${param.X}` scope is an INTERNAL runtime value bag, not an
1181
+ // authoring surface (objectui#4097 ruling B, 2026-08-11) ──────────────
1182
+ //
1183
+ // A non-array `params` reaches this line from ONE direction only: a host
1184
+ // synthesizing a value bag on its way into `runner.execute()` — the
1185
+ // collected values a param dialog just gathered, `_selectedIds` from an
1186
+ // aggregate bulk dispatch, `_rowRecord` from a row surface. Eight internal
1187
+ // producers, measured. No AUTHOR can put a value here: `ActionSchema.params`
1188
+ // is `z.array(ActionParamSchema)`, so the object form is refused at parse,
1189
+ // and the refusal message names the sanctioned spellings.
1190
+ //
1191
+ // That refusal is why this branch survived objectui#4097 while its two
1192
+ // siblings did not. The objectstack#6828 maintainer ruling targeted the
1193
+ // AUTHORING vocabulary — "a key with three meanings and no authorized
1194
+ // spelling for the third" — and both authored meanings are now handled
1195
+ // upstream at parse; PR objectui#4262 removed the third runtime read
1196
+ // (`params.newTab`). What is left here is a channel no author can reach or
1197
+ // observe, so removing it would spend a four-package runtime-contract
1198
+ // migration on an already-unreachable shape. Option A was declined for
1199
+ // exactly that reason: "plumbing expansion with zero pull".
1200
+ //
1201
+ // ⚠️ The hazard the ruling flagged, recorded here so the next reader sees
1202
+ // it at the code rather than in a closed thread: this line's safety rests
1203
+ // ENTIRELY on the producer refusing an authored object-form `params`. If a
1204
+ // future spec loosening ever admits one, an authored scope silently becomes
1205
+ // reachable again — and it would arrive as an interpolation source for URLs
1206
+ // and api targets, which is the shape a params dialog field named for a
1207
+ // navigation directive could steer. The guards are the upstream refusal pin
1208
+ // and the two local pins (`ActionRunner.resultDialog.test.ts`,
1209
+ // `ActionRunner.test.ts`); a spec change that turns them red is the signal,
1210
+ // not a nuisance.
819
1211
  const params = (action.params && typeof action.params === 'object' && !Array.isArray(action.params))
820
1212
  ? action.params
821
1213
  : {};
822
- const ctx = this.buildInterpolationContext();
823
- return target.replace(/\$\{(param|ctx)\.([\w.]+)\}/g, (_match, scope, path) => {
824
- const root = scope === 'param' ? params : ctx;
825
- const value = readPath(root, path);
1214
+ // The scope MAP defines the vocabulary — the pattern is built from its
1215
+ // keys, so a scope that is not in play is not a scope this call recognizes.
1216
+ //
1217
+ // `result` (the handler's own return value) is passed by exactly one caller:
1218
+ // the `ActionSchema.onSuccess` post-success hop, where a response exists.
1219
+ // Every other caller — `executeUrl`, `executeAPI` — interpolates a target
1220
+ // BEFORE the request, so there is no result to read; admitting `${result.*}`
1221
+ // there would widen the authorable surface with nothing behind it and
1222
+ // silently blank the token instead of leaving the author's mistake visible.
1223
+ const scopes = { param: params, ctx: this.buildInterpolationContext() };
1224
+ if (resultScope !== undefined)
1225
+ scopes.result = resultScope;
1226
+ const pattern = new RegExp(`\\$\\{(${Object.keys(scopes).join('|')})\\.([\\w.]+)\\}`, 'g');
1227
+ return target.replace(pattern, (_match, scope, path) => {
1228
+ const value = readPath(scopes[scope], path);
826
1229
  if (value == null)
827
1230
  return '';
828
1231
  return encodeURIComponent(String(value));
@@ -891,6 +1294,25 @@ export async function executeAction(action, context = {}) {
891
1294
  const runner = new ActionRunner(context);
892
1295
  return await runner.execute(action);
893
1296
  }
1297
+ /**
1298
+ * Is this `onSuccess` the SPEC's navigation block, or this runner's older
1299
+ * chained-callback channel (`ActionDef | ActionDef[]`)?
1300
+ *
1301
+ * The test IS the spec's declaration: a non-array object carrying a STRING
1302
+ * `navigate`. Nothing else can produce that shape — the spec object is strict
1303
+ * with `navigate: z.string()` required, and on a callback `ActionDef`,
1304
+ * `navigate` is the deprecated nested navigation ENVELOPE that
1305
+ * `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
1306
+ * there has never been runnable.
1307
+ */
1308
+ export function readOnSuccessNavigation(value) {
1309
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1310
+ return null;
1311
+ const navigate = value.navigate;
1312
+ if (typeof navigate !== 'string' || navigate === '')
1313
+ return null;
1314
+ return value;
1315
+ }
894
1316
  /**
895
1317
  * Dot-path read used by target interpolation. Plain reduce — kept inline
896
1318
  * to avoid pulling lodash for a 3-line helper.