@object-ui/core 17.6.0 → 17.7.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 (134) hide show
  1. package/CHANGELOG.md +6610 -0
  2. package/README.md +101 -14
  3. package/dist/actions/ActionEngine.d.ts +4 -2
  4. package/dist/actions/ActionEngine.js +13 -8
  5. package/dist/actions/ActionRunner.d.ts +288 -27
  6. package/dist/actions/ActionRunner.js +443 -63
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/actionKeys.d.ts +9 -7
  9. package/dist/actions/actionKeys.js +57 -14
  10. package/dist/actions/actionResponse.js +2 -2
  11. package/dist/actions/index.d.ts +1 -0
  12. package/dist/actions/index.js +1 -0
  13. package/dist/actions/recordDelete.d.ts +118 -0
  14. package/dist/actions/recordDelete.js +112 -0
  15. package/dist/adapters/ValueDataSource.d.ts +6 -1
  16. package/dist/adapters/ValueDataSource.js +1090 -98
  17. package/dist/adapters/batchTransaction.d.ts +1 -1
  18. package/dist/adapters/batchTransaction.js +1 -1
  19. package/dist/builder/schema-builder.d.ts +38 -81
  20. package/dist/builder/schema-builder.js +33 -152
  21. package/dist/data-scope/ViewDataProvider.d.ts +1 -1
  22. package/dist/data-scope/ViewDataProvider.js +21 -1
  23. package/dist/data-scope/element-data-source.d.ts +261 -7
  24. package/dist/data-scope/element-data-source.js +342 -52
  25. package/dist/data-scope/index.d.ts +9 -4
  26. package/dist/data-scope/index.js +9 -4
  27. package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
  28. package/dist/evaluator/ExpressionEvaluator.js +184 -12
  29. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  30. package/dist/evaluator/FormulaFunctions.js +166 -27
  31. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  32. package/dist/evaluator/declaredPredicate.js +117 -16
  33. package/dist/evaluator/fieldRules.d.ts +88 -4
  34. package/dist/evaluator/fieldRules.js +203 -31
  35. package/dist/evaluator/index.d.ts +1 -0
  36. package/dist/evaluator/index.js +1 -0
  37. package/dist/evaluator/listConditional.d.ts +103 -34
  38. package/dist/evaluator/listConditional.js +127 -50
  39. package/dist/evaluator/optionRules.d.ts +77 -6
  40. package/dist/evaluator/optionRules.js +87 -10
  41. package/dist/evaluator/predicateInput.d.ts +15 -4
  42. package/dist/evaluator/predicateInput.js +35 -2
  43. package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
  44. package/dist/evaluator/rowPredicateCanon.js +65 -115
  45. package/dist/evaluator/subjectPermissions.d.ts +76 -0
  46. package/dist/evaluator/subjectPermissions.js +44 -0
  47. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  48. package/dist/evaluator/unevaluablePredicate.js +70 -0
  49. package/dist/index.d.ts +14 -0
  50. package/dist/index.js +80 -2
  51. package/dist/protocols/index.d.ts +0 -1
  52. package/dist/protocols/index.js +9 -1
  53. package/dist/registry/Registry.d.ts +358 -31
  54. package/dist/registry/Registry.js +263 -11
  55. package/dist/registry/WidgetRegistry.js +7 -3
  56. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  57. package/dist/registry/html-tier-intrinsics.js +110 -0
  58. package/dist/registry/public-blocks.d.ts +8 -0
  59. package/dist/registry/public-blocks.js +57 -0
  60. package/dist/theme/ThemeEngine.js +1 -1
  61. package/dist/utils/chart-category-key.d.ts +82 -0
  62. package/dist/utils/chart-category-key.js +84 -0
  63. package/dist/utils/chart-measure-key.d.ts +64 -0
  64. package/dist/utils/chart-measure-key.js +65 -0
  65. package/dist/utils/chart-presentation.d.ts +95 -112
  66. package/dist/utils/chart-presentation.js +169 -104
  67. package/dist/utils/chart-series.d.ts +131 -5
  68. package/dist/utils/chart-series.js +98 -16
  69. package/dist/utils/column-sortability.d.ts +157 -0
  70. package/dist/utils/column-sortability.js +217 -0
  71. package/dist/utils/dashboard-filters.d.ts +38 -6
  72. package/dist/utils/dashboard-filters.js +107 -61
  73. package/dist/utils/dataset-format.d.ts +211 -30
  74. package/dist/utils/dataset-format.js +389 -46
  75. package/dist/utils/date-display.d.ts +354 -0
  76. package/dist/utils/date-display.js +463 -0
  77. package/dist/utils/date-macros.js +7 -0
  78. package/dist/utils/drill-down.d.ts +78 -0
  79. package/dist/utils/drill-down.js +93 -0
  80. package/dist/utils/emptiness.d.ts +96 -0
  81. package/dist/utils/emptiness.js +101 -0
  82. package/dist/utils/expand-fields.d.ts +76 -6
  83. package/dist/utils/expand-fields.js +76 -6
  84. package/dist/utils/extract-records.d.ts +65 -5
  85. package/dist/utils/extract-records.js +65 -8
  86. package/dist/utils/filter-converter.d.ts +152 -4
  87. package/dist/utils/filter-converter.js +1092 -22
  88. package/dist/utils/filter-tokens.d.ts +49 -5
  89. package/dist/utils/filter-tokens.js +105 -33
  90. package/dist/utils/grouping-fields.d.ts +61 -0
  91. package/dist/utils/grouping-fields.js +84 -0
  92. package/dist/utils/managedBy.d.ts +3 -3
  93. package/dist/utils/managedBy.js +5 -5
  94. package/dist/utils/native-date-value.d.ts +48 -0
  95. package/dist/utils/native-date-value.js +167 -0
  96. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  97. package/dist/utils/non-grid-row-ceiling.js +141 -0
  98. package/dist/utils/normalize-list-view.d.ts +121 -1
  99. package/dist/utils/normalize-list-view.js +186 -6
  100. package/dist/utils/number-display.d.ts +63 -14
  101. package/dist/utils/number-display.js +34 -12
  102. package/dist/utils/object-aggregate-query.d.ts +113 -0
  103. package/dist/utils/object-aggregate-query.js +50 -0
  104. package/dist/utils/parent-scope.d.ts +123 -0
  105. package/dist/utils/parent-scope.js +140 -0
  106. package/dist/utils/predicate-fields.d.ts +45 -12
  107. package/dist/utils/predicate-fields.js +137 -12
  108. package/dist/utils/record-color.d.ts +75 -0
  109. package/dist/utils/record-color.js +65 -0
  110. package/dist/utils/record-source.d.ts +298 -0
  111. package/dist/utils/record-source.js +331 -0
  112. package/dist/utils/record-title.d.ts +148 -8
  113. package/dist/utils/record-title.js +220 -26
  114. package/dist/utils/reference-keys.d.ts +21 -22
  115. package/dist/utils/reference-keys.js +532 -29
  116. package/dist/utils/sort-query.d.ts +115 -15
  117. package/dist/utils/sort-query.js +240 -13
  118. package/dist/utils/text-comparand.d.ts +92 -0
  119. package/dist/utils/text-comparand.js +99 -0
  120. package/dist/utils/without-denied-fields.d.ts +49 -0
  121. package/dist/utils/without-denied-fields.js +52 -0
  122. package/dist/validation/index.d.ts +8 -4
  123. package/dist/validation/index.js +8 -4
  124. package/dist/validation/schema-validator.d.ts +1 -1
  125. package/dist/validation/schema-validator.js +200 -82
  126. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  127. package/dist/validation/validators/object-validation-engine.js +14 -3
  128. package/package.json +8 -9
  129. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  130. package/dist/data-scope/DataScopeManager.js +0 -229
  131. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  132. package/dist/protocols/ResponsiveProtocol.js +0 -158
  133. package/dist/validation/validation-engine.d.ts +0 -88
  134. package/dist/validation/validation-engine.js +0 -434
@@ -10,6 +10,47 @@ import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';
10
10
  import { globalUndoManager } from './UndoManager.js';
11
11
  import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys.js';
12
12
  import { readActionPayload } from './actionResponse.js';
13
+ import { toPredicateRecord } from '../utils/predicate-record.js';
14
+ /**
15
+ * The text the runner writes itself, and so the only text
16
+ * {@link ActionRunner.setTranslator}'s translator is asked for. In each entry
17
+ * `key` names the locale-pack entry (`@object-ui/i18n`'s packs define it) and
18
+ * `defaultValue` is the English source, which is also what shows when no
19
+ * translator is installed.
20
+ *
21
+ * - `completedSuccessfully` — the success toast when the author supplied no
22
+ * copy for it: no `outcomeMessages` entry for the answer's `outcome` and no
23
+ * `successMessage` (objectui#10900, objectui#11344).
24
+ * - `failed` — the error toast when the error that reached the toast carries
25
+ * no readable message, and a parallel chain's result when its last action
26
+ * rejected (objectui#10969).
27
+ * - `parallelFailed` — a parallel chain's error when no failed action reported
28
+ * one of its own (objectui#10969).
29
+ * - `undo` — the label the runner hands the toast handler for an undoable
30
+ * success toast's Undo affordance (objectui#10969).
31
+ *
32
+ * An author's `outcomeMessages` / `successMessage` / `errorMessage` and an
33
+ * action's own error are never in this table: the host localizes authored copy
34
+ * before dispatch, and the runner only fills in its `${result.*}` tokens.
35
+ */
36
+ const RUNNER_TEXT = {
37
+ completedSuccessfully: {
38
+ key: 'actions.completedSuccessfully',
39
+ defaultValue: 'Action completed successfully',
40
+ },
41
+ failed: {
42
+ key: 'actions.failed',
43
+ defaultValue: 'Action failed',
44
+ },
45
+ parallelFailed: {
46
+ key: 'actions.parallelFailed',
47
+ defaultValue: 'One or more parallel actions failed',
48
+ },
49
+ undo: {
50
+ key: 'actions.undo',
51
+ defaultValue: 'Undo',
52
+ },
53
+ };
13
54
  /**
14
55
  * Bind the server-CEL-parity `os.user` identity alias into an action context
15
56
  * (#2358 trap 1). `@objectstack/spec` documents the canonical CEL identity
@@ -80,6 +121,24 @@ function readContextObjectName(context) {
80
121
  const name = context.objectName;
81
122
  return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined;
82
123
  }
124
+ /**
125
+ * The field definitions the host published for `objectName`, or `undefined`
126
+ * (objectui#11122).
127
+ *
128
+ * A host publishes `objectFields` BESIDE `objectName` (`useConsoleActionRuntime`
129
+ * and `RecordDetailView` both do), so the pair names one object and its fields.
130
+ * They are answered only for THAT object: an action that retargets another
131
+ * object (a related-list row on the record page carries the child's
132
+ * `objectName`) gets no field map rather than the host object's, because
133
+ * reading one object's field types against another object's row would decide
134
+ * which of its fields are relations by the wrong schema.
135
+ */
136
+ function readContextObjectFields(context, objectName) {
137
+ if (readContextObjectName(context) !== objectName)
138
+ return undefined;
139
+ const fields = context.objectFields;
140
+ return fields && typeof fields === 'object' ? fields : undefined;
141
+ }
83
142
  /**
84
143
  * Whether opening FormView `viewName` from a record of `contextObject` would
85
144
  * cross an object boundary (objectui#4292).
@@ -102,6 +161,91 @@ function crossesObjectBoundary(viewName, contextObject) {
102
161
  return false;
103
162
  return !viewName.startsWith(`${contextObject}.`);
104
163
  }
164
+ /**
165
+ * Is this the declarative single-record field write?
166
+ *
167
+ * Asks the AUTHORED key, not `type`: the spec materializes `type: 'script'`
168
+ * here and refuses every other explicit spelling, so `type` cannot answer the
169
+ * question — see the dispatch comment in {@link ActionRunner.execute}.
170
+ *
171
+ * `'update'` is the only member the spec declares (`'delete'` and `'custom'`
172
+ * have no row-level form), and an unrecognized spelling is a parse rejection
173
+ * upstream. This reads the ONE member rather than "any truthy `operation`" so a
174
+ * future member cannot silently inherit the update write path — the dispatch
175
+ * table's own guard reasoning, applied to this key.
176
+ */
177
+ function isUpdateOperationAction(action) {
178
+ return action.operation === 'update';
179
+ }
180
+ /**
181
+ * The fields an `operation: 'update'` action writes, and their prior values.
182
+ *
183
+ * `undoable` promises to restore EXACTLY the fields the action wrote, so the
184
+ * captured set is the effective payload — `patch` merged under the collected
185
+ * `params` — and never `patch` alone: a param-collected field left out of the
186
+ * capture would make Undo restore SOME of the edit and report a full one, which
187
+ * is worse than offering no Undo at all.
188
+ *
189
+ * Prior values come from the row record the invoking surface stashed under
190
+ * `params._rowRecord` (the same client-side stash the record-id dance reads).
191
+ * A field the row carries as `null` is captured as `null`: it was empty, and
192
+ * clearing it back to empty is what restoring it means on the data plane.
193
+ *
194
+ * ⛔ A field the row does not CARRY is never captured as `null`
195
+ * (objectui#10404). Absent is not empty: a list row is projected by `$select`,
196
+ * and a written field no column shows is missing from the row while the server
197
+ * holds a real value for it. Recording `null` there made Undo write `null` over
198
+ * the value it existed to restore. The list harvest (`listViewPredicates`) now
199
+ * asks for the written fields of an `undoable` action, so a projected row
200
+ * carries them; this is the backstop for the ones it cannot ask for (a field
201
+ * the object does not declare or the principal may not read, a row no harvest
202
+ * shaped). "Carries" means an own key whose value is not `undefined`: JSON
203
+ * cannot send `undefined`, so such a value says nothing about what is stored.
204
+ *
205
+ * Answers `undefined` when any written field is not carried, and the caller
206
+ * then offers no Undo at all. Capturing only the carried fields is not an
207
+ * option, for the reason above: a partial restore reported as a full one is
208
+ * worse than no Undo.
209
+ *
210
+ * ⛔ A relation's prior value is its STORED id, never the record `$expand`
211
+ * put in its place (objectui#11122). Surfaces read rows with `$expand` on the
212
+ * relations they show (a grid, its visible and grouping columns; the record
213
+ * page, every relation the reader may read), and the server replaces the id
214
+ * in place with the related record. Copied verbatim, that record became the
215
+ * Undo value and Undo wrote it into the reference slot, which stores an id:
216
+ * refused under a strict value-shape posture, stored as corruption under the
217
+ * lenient one. So each carried value goes through `toPredicateRecord`, the one
218
+ * rule that binds a fetched record the way the server stores it: a field the
219
+ * object DECLARES relational (`EXPANDABLE_FIELD_TYPES`, the set that decides
220
+ * what is expanded in the first place) collapses to its id, element-wise for a
221
+ * `multiple` relation, and every other field is left exactly as the row
222
+ * carries it. It is read from `fields`, never from the value's shape: a `json`
223
+ * field may hold an object with an `id`, and that object is its stored value.
224
+ *
225
+ * `fields` is the written object's field definitions (`objectSchema.fields`,
226
+ * either served shape), and it is REQUIRED so that a caller without them has
227
+ * to say so: `undefined` collapses nothing, because a caller that cannot name
228
+ * the relations must not guess them.
229
+ *
230
+ * Exported (objectui#11082) because it is THE rule, not this runner's: every
231
+ * surface that builds an `update` Undo snapshot itself calls it rather than
232
+ * restating it. Today that is the console runtime's `api` handler
233
+ * (`useConsoleActionRuntime`) and the record page's own `api` handler
234
+ * (`RecordDetailView`), which reads prior values off the page's loaded record;
235
+ * that record lacks a written field when field-level security hides it from
236
+ * the reader. To name the fields that blocked a capture, ask per field:
237
+ * `captureUpdateUndoData([field], rowRecord, fields) === undefined`.
238
+ */
239
+ export function captureUpdateUndoData(writtenFields, rowRecord, fields) {
240
+ if (!writtenFields.every((field) => rowCarries(rowRecord, field)))
241
+ return undefined;
242
+ const stored = toPredicateRecord(rowRecord, fields);
243
+ return Object.fromEntries(writtenFields.map((field) => [field, stored[field]]));
244
+ }
245
+ /** Whether the row CARRIES `field`: an own key whose value is not `undefined`. */
246
+ function rowCarries(rowRecord, field) {
247
+ return Object.prototype.hasOwnProperty.call(rowRecord, field) && rowRecord[field] !== undefined;
248
+ }
105
249
  /*
106
250
  * ## The two predicate gates in `execute`, and the one question they ask first
107
251
  *
@@ -129,8 +273,10 @@ function crossesObjectBoundary(viewName, contextObject) {
129
273
  * Both read {@link hasDeclaredPredicate} — the repo's one definition of that
130
274
  * question, in `evaluator/declaredPredicate.ts` beside the normalizer it is
131
275
  * 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
276
+ * empty-`source` envelope are NOT declared; a declared `false` IS) and the
277
+ * third state objectui#11358 added (a present value with no evaluable `source`
278
+ * — `0`, `{}`, an array, an `ast`-only envelope — IS declared, and faults).
279
+ * This gate arrived here first as a module-private helper with a
134
280
  * scope note saying it stayed private until that ruling landed; it has, so the
135
281
  * definition moved down a layer and the renderer side reads the same one
136
282
  * (`components/renderers/action/visibility-gate.ts` re-exports it as
@@ -164,9 +310,15 @@ function crossesObjectBoundary(viewName, contextObject) {
164
310
  * historical `Boolean(raw)` coercion, so `0` there meant "hidden" — fail-CLOSED
165
311
  * on junk, the opposite of what this module committed to for `disabled`
166
312
  * (`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.
313
+ * {@link hasDeclaredPredicate} too (objectui#3957), so the two entries answer
314
+ * a value that is not a predicate at all the same way. Since objectui#11358
315
+ * that answer is "declared, and faulting" rather than "no gate": `0` / `{}` /
316
+ * an `ast`-only envelope reach `evaluateCondition`, which reports them and
317
+ * answers its fail-soft `true` — so `disabled` refuses the action (its closed
318
+ * direction, as for any faulting `disabled`) and `condition` lets it run (the
319
+ * direction a faulting `condition` has always taken), and the engine filter
320
+ * hides it (`throwOnError`). Pinned in `ActionRunner.disabledGate.test.ts` /
321
+ * `ActionRunner.conditionGate.test.ts`.
170
322
  */
171
323
  export class ActionRunner {
172
324
  constructor(context = {}) {
@@ -230,6 +382,12 @@ export class ActionRunner {
230
382
  writable: true,
231
383
  value: void 0
232
384
  });
385
+ Object.defineProperty(this, "translate", {
386
+ enumerable: true,
387
+ configurable: true,
388
+ writable: true,
389
+ value: void 0
390
+ });
233
391
  /**
234
392
  * Built-in dispatch, one entry per runnable action type.
235
393
  *
@@ -267,6 +425,21 @@ export class ActionRunner {
267
425
  this.navigationHandler = null;
268
426
  this.paramCollectionHandler = null;
269
427
  this.resultDialogHandler = null;
428
+ this.translate = null;
429
+ }
430
+ /**
431
+ * Set the translator for the text the runner supplies itself — the host's
432
+ * `t`, injected so this package takes no i18n dependency. That text is the
433
+ * `RUNNER_TEXT` table in this file: the generic success toast shown when an
434
+ * action declares neither an `outcomeMessages` entry for the answer nor a
435
+ * `successMessage`, the error fallbacks when no readable error message
436
+ * reached the runner, and the Undo label of an undoable success toast. An
437
+ * author's `outcomeMessages` / `successMessage` / `errorMessage` and an
438
+ * action's own error never go through this translator: the host localizes
439
+ * authored copy before dispatch. With no translator the text stays English.
440
+ */
441
+ setTranslator(translate) {
442
+ this.translate = translate;
270
443
  }
271
444
  /**
272
445
  * Set a custom confirmation handler (e.g., Shadcn AlertDialog).
@@ -329,10 +502,10 @@ export class ActionRunner {
329
502
  try {
330
503
  // `ActionDef` accepts any key of any type, so a typo (`targt`) and a
331
504
  // retired key (`execute`) both reach here having type-checked. Neither
332
- // binds a handler, and binding no handler silently is the #2169 "Mark Done
333
- // does nothing" shape. Dev-only, warn-once, changes nothing (#4075 step 1).
505
+ // binds a handler, and binding no handler silently is the objectstack-ai/objectstack#2169 "Mark Done
506
+ // does nothing" shape. Dev-only, warn-once, changes nothing (objectstack-ai/objectstack#4075 step 1).
334
507
  warnOnUnknownActionKeys(action);
335
- // The compat window for the object-form `params` payload (#5777, maintainer
508
+ // The compat window for the object-form `params` payload (objectstack-ai/objectstack#5777, maintainer
336
509
  // ruling 2026-08-06 direction A). Checked HERE, before the param-collection
337
510
  // block below rewrites `action.params` into a values map — after that point
338
511
  // an action that authored a legitimate ActionParam[] DEFINITION array also
@@ -376,9 +549,16 @@ export class ActionRunner {
376
549
  // code only evaluated the STRING form and treated any object as truthy,
377
550
  // so an envelope-disabled action was ALWAYS "disabled" — silently
378
551
  // blocking every execution (param dialog never opened, handler never
379
- // ran). `evaluateCondition` already handles boolean/string/envelope;
380
- // and the renderers are authoritative for the visual disabled state, so
381
- // any eval failure here defaults to NOT-disabled (don't false-block).
552
+ // ran). `evaluateCondition` already handles boolean/string/envelope.
553
+ //
554
+ // A predicate that FAULTS — an unbound root, a misspelled field,
555
+ // `current_user.can(…)` before the permissions payload has loaded — does
556
+ // NOT reach the `catch` below: `evaluateCondition` handles its own
557
+ // faults and answers its fail-soft `true`, which on this key means
558
+ // DISABLED, so the action is refused ("Action is disabled"). That is the
559
+ // closed direction every `disabled` leg takes (objectui#11212,
560
+ // objectui#11242). The `catch` only covers a throw from outside that
561
+ // fault handling, and leaves such an action not disabled.
382
562
  let isDisabled = false;
383
563
  try {
384
564
  isDisabled = this.evaluator.evaluateCondition(action.disabled);
@@ -425,6 +605,32 @@ export class ActionRunner {
425
605
  action.params._rowRecord = rowRecord;
426
606
  }
427
607
  }
608
+ // ── `operation` is read BEFORE `type`, and this is the only order that
609
+ // can work ────────────────────────────────────────────────────────────
610
+ //
611
+ // `operation: 'update'` is a PARALLEL key beside `type`, not a member of
612
+ // it: `@objectstack/spec` materializes `type: 'script'` on such an action
613
+ // (the platform action route is the script type's own route) and refuses
614
+ // any other explicit `type`. `ActionType` therefore did not gain a
615
+ // member, which is why `builtinExecutors` — a `Record<RunnableActionType,
616
+ // …>` — still compiles unchanged across the bump that added this key.
617
+ //
618
+ // The cost of that design is paid here: by the time `actionType` is
619
+ // consulted, an update action is indistinguishable from every other
620
+ // script action. Dispatched on `type` it would reach the registered
621
+ // `script` handler with its `patch` never merged into the request — the
622
+ // route would be POSTed a payload with no field values in it and answer a
623
+ // green success for having written nothing (objectui#2960). Worse, a row
624
+ // rehydrated from `sys_metadata` UNPARSED (objectui#3903) carries no
625
+ // materialized `type` at all, so `actionType` falls back to the action's
626
+ // NAME, matches no executor, and the action lands in
627
+ // `executeActionSchema`. Reading `operation` first is what makes both of
628
+ // those impossible.
629
+ if (isUpdateOperationAction(action)) {
630
+ const result = await this.executeUpdateOperation(action);
631
+ await this.handlePostExecution(action, result);
632
+ return result;
633
+ }
428
634
  // Check for a registered custom handler first
429
635
  if (actionType && this.handlers.has(actionType)) {
430
636
  const handler = this.handlers.get(actionType);
@@ -477,13 +683,13 @@ export class ActionRunner {
477
683
  const firstFail = results.find(r => r.status === 'fulfilled' && !r.value.success);
478
684
  return {
479
685
  success: false,
480
- error: firstFail?.value?.error || 'One or more parallel actions failed',
686
+ error: firstFail?.value?.error || this.runnerText(RUNNER_TEXT.parallelFailed),
481
687
  };
482
688
  }
483
689
  const lastResult = results[results.length - 1];
484
690
  return lastResult.status === 'fulfilled'
485
691
  ? lastResult.value
486
- : { success: false, error: 'Action failed' };
692
+ : { success: false, error: this.runnerText(RUNNER_TEXT.failed) };
487
693
  }
488
694
  // Sequential execution — stop on first failure
489
695
  let lastResult = { success: true };
@@ -495,6 +701,61 @@ export class ActionRunner {
495
701
  }
496
702
  return lastResult;
497
703
  }
704
+ /**
705
+ * One entry of `RUNNER_TEXT`, in the installed translator's language. An
706
+ * empty answer from the translator falls back to the English source rather
707
+ * than raising an empty toast or an empty label.
708
+ */
709
+ runnerText({ key, defaultValue }) {
710
+ return this.translate?.(key, { defaultValue }) || defaultValue;
711
+ }
712
+ /**
713
+ * The success toast's text, by the contract's three rungs
714
+ * (`ActionSchema.outcomeMessages`, objectui#11344):
715
+ *
716
+ * 1. `outcomeMessages[outcome]`, where `outcome` is the top-level `outcome`
717
+ * of the handler's return value — the payload `${result.*}` reads, so it
718
+ * is the same fact an author writes as `${result.outcome}`;
719
+ * 2. `successMessage`;
720
+ * 3. the runner's own default text, in its translator's language.
721
+ *
722
+ * Rungs 1 and 2 are the author's copy. Both arrive already localized — the
723
+ * host's localizer resolves the bundle entry or the inline `I18nLabel` map
724
+ * before dispatch — and both have their `${result.*}` tokens filled in here,
725
+ * through the one token grammar and scope `onSuccess.navigate` reads
726
+ * (`substituteScopeTokens` over `readActionPayload(result.data)`). Only the
727
+ * sink differs: a toast is text, so a value is inserted as-is, where a URL
728
+ * position percent-encodes it.
729
+ *
730
+ * ⛔ `result.data.message` is not a rung. It used to outrank both rungs of
731
+ * author copy, which put whatever language the server happened to write in
732
+ * front of the user's locale; the contract names no server-message rung, and
733
+ * an author who wants the server's sentence can still write
734
+ * `${result.message}` as the copy.
735
+ *
736
+ * A rung counts only when it is a non-empty string. An `I18nLabel` map that
737
+ * reached the runner without passing through a localizer has no language to
738
+ * resolve against here, so it is skipped rather than handed to the toast as
739
+ * an object — the React #31 crash the error branch below guards against.
740
+ */
741
+ composeSuccessMessage(action, result) {
742
+ const payload = readActionPayload(result.data);
743
+ const outcome = payload && typeof payload === 'object'
744
+ ? payload.outcome
745
+ : undefined;
746
+ const outcomeMessages = action.outcomeMessages;
747
+ const outcomeCopy = typeof outcome === 'string'
748
+ && outcomeMessages && typeof outcomeMessages === 'object'
749
+ && Object.prototype.hasOwnProperty.call(outcomeMessages, outcome)
750
+ ? outcomeMessages[outcome]
751
+ : undefined;
752
+ for (const copy of [outcomeCopy, action.successMessage]) {
753
+ if (typeof copy === 'string' && copy.trim() !== '') {
754
+ return substituteScopeTokens(copy, { result: payload }, (value) => value);
755
+ }
756
+ }
757
+ return this.runnerText(RUNNER_TEXT.completedSuccessfully);
758
+ }
498
759
  /**
499
760
  * Post-execution: emit toast notifications, handle chaining, callbacks.
500
761
  */
@@ -510,27 +771,25 @@ export class ActionRunner {
510
771
  const showToast = action.toast ?? { showOnSuccess: true, showOnError: true };
511
772
  const duration = action.toast?.duration;
512
773
  if (result.success && !hasResultDialog && !result.silent && showToast.showOnSuccess !== false) {
513
- // Prefer a DYNAMIC message the server returned (result.data.message)
514
- // over the static action.successMessage. Server-driven actions like
515
- // check_app_updates / publish / install compute a real outcome
516
- // ("2 app updates available: CRM 1.0.0→1.0.1", "Published v1.2.0")
517
- // that the static label can't express; without this the user only ever
518
- // sees a generic "Done". Falls back to the static label, then a default.
519
- const dyn = (result.data && typeof result.data === 'object'
520
- && typeof result.data.message === 'string')
521
- ? String(result.data.message).trim()
522
- : '';
523
- const message = dyn || action.successMessage || 'Action completed successfully';
774
+ // The server returns FACTS and the console writes the sentence
775
+ // (ruling A on objectstack-ai/cloud#2315) — see `composeSuccessMessage`.
776
+ const message = this.composeSuccessMessage(action, result);
524
777
  // Undoable action: register the captured operation on the global
525
778
  // UndoManager and surface an "Undo" affordance on the toast (the
526
- // consumer's toast handler wires the button to UndoManager).
779
+ // consumer's toast handler wires the button to UndoManager). The
780
+ // button's label is the runner's own text, so it is translated here
781
+ // rather than left to each handler's English default (objectui#10969).
527
782
  if (result.undo) {
528
783
  try {
529
784
  globalUndoManager.push(result.undo);
530
785
  }
531
786
  catch { /* non-fatal */ }
532
787
  }
533
- this.toastHandler(message, { type: 'success', duration, undo: result.undo ? {} : undefined });
788
+ this.toastHandler(message, {
789
+ type: 'success',
790
+ duration,
791
+ undo: result.undo ? { label: this.runnerText(RUNNER_TEXT.undo) } : undefined,
792
+ });
534
793
  }
535
794
  if (!result.success && showToast.showOnError !== false && result.error) {
536
795
  // `result.error` is typed as a string, but a handler bug can leak an
@@ -543,7 +802,7 @@ export class ActionRunner {
543
802
  ? raw
544
803
  : (raw && typeof raw.message === 'string')
545
804
  ? raw.message
546
- : 'Action failed';
805
+ : this.runnerText(RUNNER_TEXT.failed);
547
806
  this.toastHandler(message, { type: 'error', duration });
548
807
  }
549
808
  }
@@ -586,34 +845,28 @@ export class ActionRunner {
586
845
  }
587
846
  // ── ActionSchema.onSuccess — post-success navigation ────────────────────
588
847
  //
589
- // objectui#5221, the console half of objectstack#9566/#9474. The spec
848
+ // `053fdc8f9`, the console half of objectstack#9566/#9474. The spec
590
849
  // declares `onSuccess` as a CLOSED STRICT object
591
850
  // `{ navigate: string, openIn: 'self' | 'newTab' }`, refine-scoped to
592
851
  // `type: 'api'` and `type: 'script'` — the two types whose success event
593
852
  // carries a server response for `${result.*}` to read.
594
853
  //
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.
854
+ // That declared meaning is the key's ONLY meaning. The runner's older
855
+ // chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
856
+ // dispatched through `executeChain`) was retired by objectui#5934
857
+ // (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
858
+ // at parse, so no validated metadata could ever reach it, and the census
859
+ // found zero producers outside the channel's own pins.
603
860
  //
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.
861
+ // `readOnSuccessNavigation` stays as the shape guard, not as a
862
+ // discriminator: stored rows are rehydrated UNPARSED (objectstack-ai/objectstack#3903), so the value
863
+ // is still read as data, and a shape the spec refuses gets no reading —
864
+ // no navigation, no callback dispatch, no lenient fallback.
608
865
  if (result.success && action.onSuccess) {
609
866
  const navigation = readOnSuccessNavigation(action.onSuccess);
610
867
  if (navigation) {
611
868
  this.navigateOnSuccess(navigation, action, result);
612
869
  }
613
- else {
614
- const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
615
- await this.executeChain(callbacks, 'sequential');
616
- }
617
870
  }
618
871
  if (!result.success && action.onFailure) {
619
872
  const callbacks = Array.isArray(action.onFailure) ? action.onFailure : [action.onFailure];
@@ -672,15 +925,122 @@ export class ActionRunner {
672
925
  result.redirect = url;
673
926
  }
674
927
  }
928
+ /**
929
+ * `operation: 'update'` — the declarative single-record field write.
930
+ *
931
+ * ## The write is the ROUTE's, never this client's
932
+ *
933
+ * This method composes a request and hands it to the registered `script`
934
+ * dispatch; it does not touch a `DataSource`. That is a security boundary,
935
+ * not an implementation preference. `POST /api/v1/actions/{object}/{action}`
936
+ * runs the write AS THE CALLER — the caller's permissions, the object's hooks
937
+ * and its validations all fire as for a user edit — while a client-side
938
+ * `dataSource.update()` reaches the data plane having consulted none of them.
939
+ * The spec says the same thing from its end by refusing `target`, `body`,
940
+ * `method` and `bodyExtra` beside this key and pinning `type` to `'script'`:
941
+ * there is exactly one execution surface for an update action, and it is the
942
+ * route.
943
+ *
944
+ * Consequently, a consumer that has registered NO server-action dispatch gets
945
+ * a loud refusal naming the remedy. The two silent alternatives were both
946
+ * available and both wrong: falling through to `executeScript` ends at "No
947
+ * script provided" (an update action declares no `target` — the author is
948
+ * sent hunting for a field they were right not to write), and falling through
949
+ * to `executeActionSchema` reports on an action that never ran.
950
+ *
951
+ * ## `patch` rides UNDER the collected params
952
+ *
953
+ * The spec's words: static field values "merged UNDER the user-supplied
954
+ * `params` so a fixed value can be declared without exposing it in the
955
+ * dialog". So a collected param of the same name WINS — the author declared a
956
+ * default, and the user answered it.
957
+ */
958
+ async executeUpdateOperation(action) {
959
+ const dispatch = this.handlers.get('script');
960
+ if (!dispatch) {
961
+ return {
962
+ success: false,
963
+ error: 'An `operation: "update"` action is written by the platform action route, which this ' +
964
+ 'client runner cannot reach: no `script` handler is registered. The write is never ' +
965
+ 'performed client-side — a direct data-plane update bypasses the route\'s permission ' +
966
+ 'floor, the object\'s hooks and its validations. Register a handler that POSTs to ' +
967
+ '/api/v1/actions/{object}/{action} — build one with createServerActionHandler from ' +
968
+ '@object-ui/core.',
969
+ };
970
+ }
971
+ const collected = (action.params && !Array.isArray(action.params))
972
+ ? { ...action.params }
973
+ : {};
974
+ const patch = (action.patch && typeof action.patch === 'object' && !Array.isArray(action.patch))
975
+ ? { ...action.patch }
976
+ : {};
977
+ // `patch` UNDER the collected params — see the doc above.
978
+ const params = { ...patch, ...collected };
979
+ // The row stash and the record selector are transport mechanics, not
980
+ // written fields: the dispatch strips `_rowRecord` before POSTing and reads
981
+ // `recordId` as the record's address.
982
+ const rowRecord = (collected._rowRecord && typeof collected._rowRecord === 'object')
983
+ ? collected._rowRecord
984
+ : undefined;
985
+ const writtenFields = Object.keys(params).filter((k) => k !== '_rowRecord' && k !== 'recordId');
986
+ const result = await dispatch({ ...action, params }, this.context);
987
+ // A refusal is returned exactly as the dispatch reported it. The route's
988
+ // envelope rule (`interpretActionResponse`) has already turned a 4xx/5xx
989
+ // AND a 200-with-`success:false` business rejection into `error`, and
990
+ // `handlePostExecution` surfaces that as the failure toast. Re-labelling it
991
+ // here, or answering success because the POST itself completed, is the
992
+ // objectui#2960 shape this branch exists to prevent.
993
+ if (!result.success)
994
+ return result;
995
+ // Undo: prior values of exactly the fields this action wrote. Needs a row
996
+ // record to read them from — without one there is nothing to restore, so
997
+ // the affordance is correctly not offered rather than offered empty. The
998
+ // same holds when the row lacks a written field (objectui#10404): no Undo,
999
+ // never one that writes `null` over the stored value. A relation the row
1000
+ // carries expanded is captured as its stored id, read against the field
1001
+ // map the host published for this object (objectui#11122).
1002
+ if (action.undoable && rowRecord && writtenFields.length > 0) {
1003
+ const objectName = action.objectName || readContextObjectName(this.context);
1004
+ const recordId = collected.recordId ?? rowRecord.id;
1005
+ if (objectName && recordId != null) {
1006
+ const undoData = captureUpdateUndoData(writtenFields, rowRecord, readContextObjectFields(this.context, objectName));
1007
+ if (undoData) {
1008
+ result.undo = {
1009
+ id: `undo-${objectName}-${String(recordId)}-${Date.now()}`,
1010
+ type: 'update',
1011
+ objectName,
1012
+ recordId: String(recordId),
1013
+ timestamp: Date.now(),
1014
+ // objectui#11080 — no English verb here. The toast that reports the
1015
+ // Undo / Redo already says which one it is, from a pack key, so an
1016
+ // action that declared no label is named by the object it acted on.
1017
+ description: action.label || objectName,
1018
+ undoData,
1019
+ redoData: Object.fromEntries(writtenFields.map((k) => [k, params[k]])),
1020
+ };
1021
+ }
1022
+ else {
1023
+ // The success toast then carries no Undo button, which is how the
1024
+ // user learns this one cannot be undone; this names the cause for
1025
+ // the author.
1026
+ console.warn('[ActionRunner] `undoable` update succeeded but offers no Undo: the row it ran on does not '
1027
+ + 'carry every field it wrote, so their prior values are unknown and an Undo would overwrite '
1028
+ + 'stored data. A list row carries a written field when the object declares it and the '
1029
+ + 'principal may read it.', { action: action.name, missing: writtenFields.filter((k) => !rowCarries(rowRecord, k)) });
1030
+ }
1031
+ }
1032
+ }
1033
+ return result;
1034
+ }
675
1035
  /**
676
1036
  * Execute script action — evaluates client-side expression via ExpressionEvaluator.
677
1037
  * Supports ${} template expressions referencing data, record, user context.
678
1038
  */
679
1039
  async executeScript(action) {
680
1040
  // `target` is the only handler slot. The `execute` alias was removed in
681
- // @objectstack/spec 17 (#3855), which rejects an authored `execute` at parse
1041
+ // @objectstack/spec 17 (objectstack-ai/objectstack#3855), which rejects an authored `execute` at parse
682
1042
  // with the rename prescription — so parsed metadata cannot carry it and a
683
- // `target || execute` fallback could only ever evaluate to `target` (#3856).
1043
+ // `target || execute` fallback could only ever evaluate to `target` (objectstack-ai/objectstack#3856).
684
1044
  const script = action.target;
685
1045
  if (!script) {
686
1046
  // A spec `body` IS a script — this runner just cannot run one (see the
@@ -703,11 +1063,11 @@ export class ActionRunner {
703
1063
  // `ActionDef` no longer declares `execute` — nor any open index signature
704
1064
  // — so `tsc` now catches the retired key at every site that AUTHORS an
705
1065
  // 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
1066
+ // reach. It is not this half: objectstack-ai/objectstack#3903 established that stored
707
1067
  // `sys_metadata` rows are rehydrated UNPARSED, so metadata written before
708
1068
  // spec 17 arrives here as a plain object the compiler never saw, still
709
1069
  // 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
1070
+ // re-legitimize a tombstone (objectstack-ai/objectstack#3855 removed it; the spec keeps it only to
711
1071
  // reject it BY NAME), and deleting the branch would send those rows back
712
1072
  // to a bare "no script provided" — which reads as "you forgot a field" to
713
1073
  // an author who did write one, the objectstack#2169 shape.
@@ -1029,7 +1389,7 @@ export class ActionRunner {
1029
1389
  * old object-form `params` page — POSTed an empty body here.
1030
1390
  *
1031
1391
  * `params` contributes only in its DEPRECATED non-array form (the compat window
1032
- * #5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
1392
+ * objectstack-ai/objectstack#5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
1033
1393
  * parameter DEFINITION list, not a payload: it reaches this method unconsumed
1034
1394
  * only when no `paramCollectionHandler` is mounted, and POSTing the definitions
1035
1395
  * as the request body was never a payload any endpoint wanted. It now falls
@@ -1220,16 +1580,15 @@ export class ActionRunner {
1220
1580
  // BEFORE the request, so there is no result to read; admitting `${result.*}`
1221
1581
  // there would widen the authorable surface with nothing behind it and
1222
1582
  // silently blank the token instead of leaving the author's mistake visible.
1583
+ //
1584
+ // The success toast's copy is the one other caller of the grammar, through
1585
+ // `substituteScopeTokens` directly (`composeSuccessMessage`): `result` only,
1586
+ // because that is the one scope the contract declares for the copy, and no
1587
+ // encoding, because a toast is text.
1223
1588
  const scopes = { param: params, ctx: this.buildInterpolationContext() };
1224
1589
  if (resultScope !== undefined)
1225
1590
  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);
1229
- if (value == null)
1230
- return '';
1231
- return encodeURIComponent(String(value));
1232
- });
1591
+ return substituteScopeTokens(target, scopes, encodeURIComponent);
1233
1592
  }
1234
1593
  /**
1235
1594
  * Assemble the `ctx` namespace exposed to target interpolation.
@@ -1295,15 +1654,14 @@ export async function executeAction(action, context = {}) {
1295
1654
  return await runner.execute(action);
1296
1655
  }
1297
1656
  /**
1298
- * Is this `onSuccess` the SPEC's navigation block, or this runner's older
1299
- * chained-callback channel (`ActionDef | ActionDef[]`)?
1657
+ * Is this `onSuccess` the spec's navigation block?
1300
1658
  *
1301
1659
  * 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.
1660
+ * `navigate`. Stored rows are rehydrated UNPARSED (objectstack-ai/objectstack#3903), so the runner reads
1661
+ * the value as data and anything else gets NO reading — since objectui#5934
1662
+ * retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
1663
+ * there is no other channel for an off-contract shape to fall into. This is a
1664
+ * shape GUARD on unparsed data, not a discriminator between two meanings.
1307
1665
  */
1308
1666
  export function readOnSuccessNavigation(value) {
1309
1667
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -1313,6 +1671,28 @@ export function readOnSuccessNavigation(value) {
1313
1671
  return null;
1314
1672
  return value;
1315
1673
  }
1674
+ /**
1675
+ * The runner's ONE `${scope.path}` token grammar — the substitution behind
1676
+ * `interpolateTarget` (url / api targets and the `onSuccess.navigate` hop) and
1677
+ * behind the success toast's `${result.*}` copy (`composeSuccessMessage`).
1678
+ *
1679
+ * The scope MAP defines the vocabulary: the pattern is built from its keys, so
1680
+ * a token naming a scope the caller did not pass is left in the text exactly as
1681
+ * written. A path that resolves to nothing becomes `''`. `encode` is the only
1682
+ * thing a caller varies, and it belongs to the SINK, not to the dialect: a URL
1683
+ * position percent-encodes each value, a toast inserts it as text.
1684
+ */
1685
+ function substituteScopeTokens(template, scopes, encode) {
1686
+ if (typeof template !== 'string' || template.indexOf('${') === -1)
1687
+ return template;
1688
+ const pattern = new RegExp(`\\$\\{(${Object.keys(scopes).join('|')})\\.([\\w.]+)\\}`, 'g');
1689
+ return template.replace(pattern, (_match, scope, path) => {
1690
+ const value = readPath(scopes[scope], path);
1691
+ if (value == null)
1692
+ return '';
1693
+ return encode(String(value));
1694
+ });
1695
+ }
1316
1696
  /**
1317
1697
  * Dot-path read used by target interpolation. Plain reduce — kept inline
1318
1698
  * to avoid pulling lodash for a 3-line helper.