@object-ui/core 17.6.0 → 18.0.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 (147) hide show
  1. package/CHANGELOG.md +7260 -0
  2. package/README.md +228 -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 +473 -69
  6. package/dist/actions/ActionRunner.js +792 -125
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/UndoManager.d.ts +8 -13
  9. package/dist/actions/actionKeys.d.ts +9 -7
  10. package/dist/actions/actionKeys.js +67 -14
  11. package/dist/actions/actionResponse.js +2 -2
  12. package/dist/actions/index.d.ts +2 -0
  13. package/dist/actions/index.js +2 -0
  14. package/dist/actions/navigationTarget.d.ts +41 -0
  15. package/dist/actions/navigationTarget.js +20 -0
  16. package/dist/actions/recordDelete.d.ts +154 -0
  17. package/dist/actions/recordDelete.js +110 -0
  18. package/dist/adapters/ApiDataSource.d.ts +8 -4
  19. package/dist/adapters/ApiDataSource.js +19 -7
  20. package/dist/adapters/ValueDataSource.d.ts +6 -1
  21. package/dist/adapters/ValueDataSource.js +1090 -98
  22. package/dist/adapters/batchTransaction.d.ts +1 -1
  23. package/dist/adapters/batchTransaction.js +1 -1
  24. package/dist/adapters/resolveDataSource.d.ts +9 -6
  25. package/dist/adapters/resolveDataSource.js +9 -12
  26. package/dist/builder/schema-builder.d.ts +38 -81
  27. package/dist/builder/schema-builder.js +33 -152
  28. package/dist/data-scope/ViewDataProvider.d.ts +8 -25
  29. package/dist/data-scope/ViewDataProvider.js +27 -54
  30. package/dist/data-scope/element-data-source.d.ts +261 -7
  31. package/dist/data-scope/element-data-source.js +342 -52
  32. package/dist/data-scope/index.d.ts +9 -4
  33. package/dist/data-scope/index.js +9 -4
  34. package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
  35. package/dist/evaluator/ExpressionEvaluator.js +184 -12
  36. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  37. package/dist/evaluator/FormulaFunctions.js +166 -27
  38. package/dist/evaluator/deadlineSettled.d.ts +40 -0
  39. package/dist/evaluator/deadlineSettled.js +45 -0
  40. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  41. package/dist/evaluator/declaredPredicate.js +117 -16
  42. package/dist/evaluator/fieldRules.d.ts +88 -4
  43. package/dist/evaluator/fieldRules.js +208 -31
  44. package/dist/evaluator/index.d.ts +2 -0
  45. package/dist/evaluator/index.js +2 -0
  46. package/dist/evaluator/listConditional.d.ts +103 -34
  47. package/dist/evaluator/listConditional.js +127 -50
  48. package/dist/evaluator/optionRules.d.ts +77 -6
  49. package/dist/evaluator/optionRules.js +87 -10
  50. package/dist/evaluator/predicateInput.d.ts +15 -4
  51. package/dist/evaluator/predicateInput.js +35 -2
  52. package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
  53. package/dist/evaluator/rowPredicateCanon.js +65 -115
  54. package/dist/evaluator/subjectPermissions.d.ts +105 -0
  55. package/dist/evaluator/subjectPermissions.js +86 -0
  56. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  57. package/dist/evaluator/unevaluablePredicate.js +70 -0
  58. package/dist/index.d.ts +15 -0
  59. package/dist/index.js +83 -2
  60. package/dist/protocols/index.d.ts +0 -1
  61. package/dist/protocols/index.js +9 -1
  62. package/dist/registry/Registry.d.ts +382 -34
  63. package/dist/registry/Registry.js +247 -12
  64. package/dist/registry/WidgetRegistry.js +7 -3
  65. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  66. package/dist/registry/html-tier-intrinsics.js +110 -0
  67. package/dist/registry/public-blocks.d.ts +8 -0
  68. package/dist/registry/public-blocks.js +57 -0
  69. package/dist/theme/ThemeEngine.js +1 -1
  70. package/dist/utils/affordanceGrants.d.ts +371 -0
  71. package/dist/utils/affordanceGrants.js +252 -0
  72. package/dist/utils/chart-category-key.d.ts +82 -0
  73. package/dist/utils/chart-category-key.js +84 -0
  74. package/dist/utils/chart-measure-key.d.ts +64 -0
  75. package/dist/utils/chart-measure-key.js +65 -0
  76. package/dist/utils/chart-presentation.d.ts +95 -112
  77. package/dist/utils/chart-presentation.js +169 -104
  78. package/dist/utils/chart-series.d.ts +165 -5
  79. package/dist/utils/chart-series.js +123 -18
  80. package/dist/utils/column-sortability.d.ts +157 -0
  81. package/dist/utils/column-sortability.js +217 -0
  82. package/dist/utils/dashboard-filters.d.ts +38 -6
  83. package/dist/utils/dashboard-filters.js +107 -61
  84. package/dist/utils/dataset-format.d.ts +211 -30
  85. package/dist/utils/dataset-format.js +389 -46
  86. package/dist/utils/date-display.d.ts +346 -0
  87. package/dist/utils/date-display.js +644 -0
  88. package/dist/utils/date-macros.js +7 -0
  89. package/dist/utils/drill-down.d.ts +78 -0
  90. package/dist/utils/drill-down.js +93 -0
  91. package/dist/utils/emptiness.d.ts +96 -0
  92. package/dist/utils/emptiness.js +101 -0
  93. package/dist/utils/expand-fields.d.ts +81 -9
  94. package/dist/utils/expand-fields.js +81 -9
  95. package/dist/utils/extract-records.d.ts +65 -5
  96. package/dist/utils/extract-records.js +65 -8
  97. package/dist/utils/filter-converter.d.ts +158 -6
  98. package/dist/utils/filter-converter.js +1098 -24
  99. package/dist/utils/filter-tokens.d.ts +49 -5
  100. package/dist/utils/filter-tokens.js +105 -33
  101. package/dist/utils/grouping-fields.d.ts +61 -0
  102. package/dist/utils/grouping-fields.js +84 -0
  103. package/dist/utils/managedBy.d.ts +3 -3
  104. package/dist/utils/managedBy.js +5 -5
  105. package/dist/utils/native-date-value.d.ts +48 -0
  106. package/dist/utils/native-date-value.js +167 -0
  107. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  108. package/dist/utils/non-grid-row-ceiling.js +141 -0
  109. package/dist/utils/normalize-list-view.d.ts +135 -11
  110. package/dist/utils/normalize-list-view.js +243 -64
  111. package/dist/utils/number-display.d.ts +63 -14
  112. package/dist/utils/number-display.js +34 -12
  113. package/dist/utils/object-aggregate-query.d.ts +113 -0
  114. package/dist/utils/object-aggregate-query.js +50 -0
  115. package/dist/utils/parent-scope.d.ts +123 -0
  116. package/dist/utils/parent-scope.js +140 -0
  117. package/dist/utils/predicate-fields.d.ts +45 -12
  118. package/dist/utils/predicate-fields.js +141 -12
  119. package/dist/utils/record-color.d.ts +75 -0
  120. package/dist/utils/record-color.js +65 -0
  121. package/dist/utils/record-source.d.ts +298 -0
  122. package/dist/utils/record-source.js +331 -0
  123. package/dist/utils/record-title.d.ts +148 -8
  124. package/dist/utils/record-title.js +220 -26
  125. package/dist/utils/reference-keys.d.ts +21 -22
  126. package/dist/utils/reference-keys.js +532 -29
  127. package/dist/utils/sort-query.d.ts +115 -15
  128. package/dist/utils/sort-query.js +240 -13
  129. package/dist/utils/text-comparand.d.ts +92 -0
  130. package/dist/utils/text-comparand.js +99 -0
  131. package/dist/utils/without-denied-fields.d.ts +49 -0
  132. package/dist/utils/without-denied-fields.js +52 -0
  133. package/dist/validation/index.d.ts +8 -4
  134. package/dist/validation/index.js +8 -4
  135. package/dist/validation/schema-validator.d.ts +1 -1
  136. package/dist/validation/schema-validator.js +225 -83
  137. package/dist/validation/server-owned-value.d.ts +5 -1
  138. package/dist/validation/server-owned-value.js +5 -1
  139. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  140. package/dist/validation/validators/object-validation-engine.js +14 -3
  141. package/package.json +8 -9
  142. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  143. package/dist/data-scope/DataScopeManager.js +0 -229
  144. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  145. package/dist/protocols/ResponsiveProtocol.js +0 -158
  146. package/dist/validation/validation-engine.d.ts +0 -88
  147. package/dist/validation/validation-engine.js +0 -434
@@ -10,6 +10,56 @@ 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 { classifyNavigationTarget } from './navigationTarget.js';
14
+ import { toPredicateRecord } from '../utils/predicate-record.js';
15
+ /**
16
+ * What a navigation target outside `classifyNavigationTarget`'s admitted set
17
+ * answers (objectui#12191). It names the admitted set, so the author learns the
18
+ * fix from the refusal. ⚠️ Nothing derives this text from the classifier: a
19
+ * change to the set changes this sentence by hand.
20
+ * `navigationTarget.mailto-12191.test.ts` pins that it names each admitted kind.
21
+ */
22
+ const NAVIGATION_TARGET_REFUSAL = 'Invalid URL scheme. Only http://, https://, mailto: and in-app relative paths (/path, ./path) are allowed.';
23
+ /**
24
+ * The text the runner writes itself, and so the only text
25
+ * {@link ActionRunner.setTranslator}'s translator is asked for. In each entry
26
+ * `key` names the locale-pack entry (`@object-ui/i18n`'s packs define it) and
27
+ * `defaultValue` is the English source, which is also what shows when no
28
+ * translator is installed.
29
+ *
30
+ * - `completedSuccessfully` — the success toast when the author supplied no
31
+ * copy for it: no `outcomeMessages` entry for the answer's `outcome` and no
32
+ * `successMessage` (objectui#10900, objectui#11344).
33
+ * - `failed` — the error toast when the error that reached the toast carries
34
+ * no readable message, and a parallel chain's result when its last action
35
+ * rejected (objectui#10969).
36
+ * - `parallelFailed` — a parallel chain's error when no failed action reported
37
+ * one of its own (objectui#10969).
38
+ * - `undo` — the label the runner hands the toast handler for an undoable
39
+ * success toast's Undo affordance (objectui#10969).
40
+ *
41
+ * An author's `outcomeMessages` / `successMessage` / `errorMessage` and an
42
+ * action's own error are never in this table: the host localizes authored copy
43
+ * before dispatch, and the runner only fills in its `${result.*}` tokens.
44
+ */
45
+ const RUNNER_TEXT = {
46
+ completedSuccessfully: {
47
+ key: 'actions.completedSuccessfully',
48
+ defaultValue: 'Action completed successfully',
49
+ },
50
+ failed: {
51
+ key: 'actions.failed',
52
+ defaultValue: 'Action failed',
53
+ },
54
+ parallelFailed: {
55
+ key: 'actions.parallelFailed',
56
+ defaultValue: 'One or more parallel actions failed',
57
+ },
58
+ undo: {
59
+ key: 'actions.undo',
60
+ defaultValue: 'Undo',
61
+ },
62
+ };
13
63
  /**
14
64
  * Bind the server-CEL-parity `os.user` identity alias into an action context
15
65
  * (#2358 trap 1). `@objectstack/spec` documents the canonical CEL identity
@@ -80,6 +130,79 @@ function readContextObjectName(context) {
80
130
  const name = context.objectName;
81
131
  return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined;
82
132
  }
133
+ /**
134
+ * The field definitions the host published for `objectName`, or `undefined`
135
+ * (objectui#11122).
136
+ *
137
+ * A host publishes `objectFields` BESIDE `objectName` (`useConsoleActionRuntime`
138
+ * and `RecordDetailView` both do), so the pair names one object and its fields.
139
+ * They are answered only for THAT object: an action that retargets another
140
+ * object (a related-list row on the record page carries the child's
141
+ * `objectName`) gets no field map rather than the host object's, because
142
+ * reading one object's field types against another object's row would decide
143
+ * which of its fields are relations by the wrong schema.
144
+ */
145
+ function readContextObjectFields(context, objectName) {
146
+ if (readContextObjectName(context) !== objectName)
147
+ return undefined;
148
+ const fields = context.objectFields;
149
+ return fields && typeof fields === 'object' ? fields : undefined;
150
+ }
151
+ /**
152
+ * The declared actions the host published for `objectName`, or `undefined`
153
+ * (objectui#13022).
154
+ *
155
+ * A host publishes `objectActions` (the object's `actions` list, the
156
+ * declarations the platform action route resolves an action's `name` against)
157
+ * beside `objectName` and `objectFields`; `useConsoleActionRuntime` and
158
+ * `RecordDetailView` both do. Answered only for THAT object, for the reason
159
+ * {@link readContextObjectFields} gives: a dispatch that retargets another
160
+ * object names a declaration this list does not hold.
161
+ */
162
+ function readContextObjectActions(context, objectName) {
163
+ if (!objectName || readContextObjectName(context) !== objectName)
164
+ return undefined;
165
+ const actions = context.objectActions;
166
+ return Array.isArray(actions) ? actions : undefined;
167
+ }
168
+ /** A `patch` as the route reads one: a plain object's own members, else none. */
169
+ function patchMembers(patch) {
170
+ return patch && typeof patch === 'object' && !Array.isArray(patch)
171
+ ? { ...patch }
172
+ : {};
173
+ }
174
+ /**
175
+ * The static values the platform action route writes for an
176
+ * `operation: 'update'` dispatch: the `patch` of the DECLARATION the action's
177
+ * `name` names (objectui#13022).
178
+ *
179
+ * The route resolves that declaration by the dispatch's object and the
180
+ * action's `name`, and writes its `patch` under the params it is sent
181
+ * (`declarativeUpdateWrite`). A page action row's own `patch` is never that
182
+ * write: `@objectstack/spec` 18 retires the key on `action:button`,
183
+ * `action:icon` and the `action:group` / `action:menu` member, and none of
184
+ * those renderers forwards it. So the declaration is read from one of two
185
+ * places, and no third:
186
+ *
187
+ * 1. The declarations the host published for the dispatch's object
188
+ * ({@link readContextObjectActions}), when they name this action: the
189
+ * lookup the route performs. A declaration with no `patch` writes the
190
+ * collected params alone.
191
+ * 2. Otherwise the def itself. A host that dispatches an object's action whole
192
+ * (`ObjectGrid`'s rows, `DeclaredActionsBar`, `RelatedRecordActionsBridge`,
193
+ * `page:header`) hands the runner the declaration, `patch` included. A page
194
+ * row reaches the runner through a renderer, which forwards no `patch`, so
195
+ * this reads nothing for one.
196
+ *
197
+ * Read for the Undo snapshot only; ⛔ never sent (objectui#12941).
198
+ */
199
+ function declaredUpdatePatch(action, context, objectName) {
200
+ const name = typeof action.name === 'string' && action.name !== '' ? action.name : undefined;
201
+ const declared = name === undefined
202
+ ? undefined
203
+ : readContextObjectActions(context, objectName)?.find((candidate) => !!candidate && typeof candidate === 'object' && candidate.name === name);
204
+ return patchMembers(declared ? declared.patch : action.patch);
205
+ }
83
206
  /**
84
207
  * Whether opening FormView `viewName` from a record of `contextObject` would
85
208
  * cross an object boundary (objectui#4292).
@@ -102,6 +225,99 @@ function crossesObjectBoundary(viewName, contextObject) {
102
225
  return false;
103
226
  return !viewName.startsWith(`${contextObject}.`);
104
227
  }
228
+ /**
229
+ * Is this the declarative single-record field write?
230
+ *
231
+ * Asks the AUTHORED key, not `type`: the spec materializes `type: 'script'`
232
+ * here and refuses every other explicit spelling, so `type` cannot answer the
233
+ * question — see the dispatch comment in {@link ActionRunner.execute}.
234
+ *
235
+ * `'update'` is the only member the spec declares (`'delete'` and `'custom'`
236
+ * have no row-level form), and an unrecognized spelling is a parse rejection
237
+ * upstream. This reads the ONE member rather than "any truthy `operation`" so a
238
+ * future member cannot silently inherit the update write path — the dispatch
239
+ * table's own guard reasoning, applied to this key.
240
+ */
241
+ function isUpdateOperationAction(action) {
242
+ return action.operation === 'update';
243
+ }
244
+ /**
245
+ * Whether the action declares its own success copy, read the way
246
+ * `composeSuccessMessage` reads its `successMessage` rung: a non-empty string.
247
+ * A navigation toasts only then (objectui#12755).
248
+ */
249
+ function declaresSuccessMessage(action) {
250
+ return typeof action.successMessage === 'string' && action.successMessage.trim() !== '';
251
+ }
252
+ /**
253
+ * The fields an `operation: 'update'` action writes, and their prior values.
254
+ *
255
+ * `undoable` promises to restore EXACTLY the fields the action wrote, so the
256
+ * captured set is the effective payload — `patch` merged under the collected
257
+ * `params` — and never `patch` alone: a param-collected field left out of the
258
+ * capture would make Undo restore SOME of the edit and report a full one, which
259
+ * is worse than offering no Undo at all.
260
+ *
261
+ * Prior values come from the row record the invoking surface stashed under
262
+ * `params._rowRecord` (the same client-side stash the record-id dance reads).
263
+ * A field the row carries as `null` is captured as `null`: it was empty, and
264
+ * clearing it back to empty is what restoring it means on the data plane.
265
+ *
266
+ * ⛔ A field the row does not CARRY is never captured as `null`
267
+ * (objectui#10404). Absent is not empty: a list row is projected by `$select`,
268
+ * and a written field no column shows is missing from the row while the server
269
+ * holds a real value for it. Recording `null` there made Undo write `null` over
270
+ * the value it existed to restore. The list harvest (`listViewPredicates`) now
271
+ * asks for the written fields of an `undoable` action, so a projected row
272
+ * carries them; this is the backstop for the ones it cannot ask for (a field
273
+ * the object does not declare or the principal may not read, a row no harvest
274
+ * shaped). "Carries" means an own key whose value is not `undefined`: JSON
275
+ * cannot send `undefined`, so such a value says nothing about what is stored.
276
+ *
277
+ * Answers `undefined` when any written field is not carried, and the caller
278
+ * then offers no Undo at all. Capturing only the carried fields is not an
279
+ * option, for the reason above: a partial restore reported as a full one is
280
+ * worse than no Undo.
281
+ *
282
+ * ⛔ A relation's prior value is its STORED id, never the record `$expand`
283
+ * put in its place (objectui#11122). Surfaces read rows with `$expand` on the
284
+ * relations they show (a grid, its visible and grouping columns; the record
285
+ * page, every relation the reader may read), and the server replaces the id
286
+ * in place with the related record. Copied verbatim, that record became the
287
+ * Undo value and Undo wrote it into the reference slot, which stores an id:
288
+ * refused under a strict value-shape posture, stored as corruption under the
289
+ * lenient one. So each carried value goes through `toPredicateRecord`, the one
290
+ * rule that binds a fetched record the way the server stores it: a field the
291
+ * object DECLARES relational (`EXPANDABLE_FIELD_TYPES`, the set that decides
292
+ * what is expanded in the first place) collapses to its id, element-wise for a
293
+ * `multiple` relation, and every other field is left exactly as the row
294
+ * carries it. It is read from `fields`, never from the value's shape: a `json`
295
+ * field may hold an object with an `id`, and that object is its stored value.
296
+ *
297
+ * `fields` is the written object's field definitions (`objectSchema.fields`,
298
+ * either served shape), and it is REQUIRED so that a caller without them has
299
+ * to say so: `undefined` collapses nothing, because a caller that cannot name
300
+ * the relations must not guess them.
301
+ *
302
+ * Exported (objectui#11082) because it is THE rule, not this runner's: every
303
+ * surface that builds an `update` Undo snapshot itself calls it rather than
304
+ * restating it. Today that is the console runtime's `api` handler
305
+ * (`useConsoleActionRuntime`) and the record page's own `api` handler
306
+ * (`RecordDetailView`), which reads prior values off the page's loaded record;
307
+ * that record lacks a written field when field-level security hides it from
308
+ * the reader. To name the fields that blocked a capture, ask per field:
309
+ * `captureUpdateUndoData([field], rowRecord, fields) === undefined`.
310
+ */
311
+ export function captureUpdateUndoData(writtenFields, rowRecord, fields) {
312
+ if (!writtenFields.every((field) => rowCarries(rowRecord, field)))
313
+ return undefined;
314
+ const stored = toPredicateRecord(rowRecord, fields);
315
+ return Object.fromEntries(writtenFields.map((field) => [field, stored[field]]));
316
+ }
317
+ /** Whether the row CARRIES `field`: an own key whose value is not `undefined`. */
318
+ function rowCarries(rowRecord, field) {
319
+ return Object.prototype.hasOwnProperty.call(rowRecord, field) && rowRecord[field] !== undefined;
320
+ }
105
321
  /*
106
322
  * ## The two predicate gates in `execute`, and the one question they ask first
107
323
  *
@@ -129,8 +345,10 @@ function crossesObjectBoundary(viewName, contextObject) {
129
345
  * Both read {@link hasDeclaredPredicate} — the repo's one definition of that
130
346
  * question, in `evaluator/declaredPredicate.ts` beside the normalizer it is
131
347
  * 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
348
+ * empty-`source` envelope are NOT declared; a declared `false` IS) and the
349
+ * third state objectui#11358 added (a present value with no evaluable `source`
350
+ * — `0`, `{}`, an array, an `ast`-only envelope — IS declared, and faults).
351
+ * This gate arrived here first as a module-private helper with a
134
352
  * scope note saying it stayed private until that ruling landed; it has, so the
135
353
  * definition moved down a layer and the renderer side reads the same one
136
354
  * (`components/renderers/action/visibility-gate.ts` re-exports it as
@@ -164,9 +382,15 @@ function crossesObjectBoundary(viewName, contextObject) {
164
382
  * historical `Boolean(raw)` coercion, so `0` there meant "hidden" — fail-CLOSED
165
383
  * on junk, the opposite of what this module committed to for `disabled`
166
384
  * (`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.
385
+ * {@link hasDeclaredPredicate} too (objectui#3957), so the two entries answer
386
+ * a value that is not a predicate at all the same way. Since objectui#11358
387
+ * that answer is "declared, and faulting" rather than "no gate": `0` / `{}` /
388
+ * an `ast`-only envelope reach `evaluateCondition`, which reports them and
389
+ * answers its fail-soft `true` — so `disabled` refuses the action (its closed
390
+ * direction, as for any faulting `disabled`) and `condition` lets it run (the
391
+ * direction a faulting `condition` has always taken), and the engine filter
392
+ * hides it (`throwOnError`). Pinned in `ActionRunner.disabledGate.test.ts` /
393
+ * `ActionRunner.conditionGate.test.ts`.
170
394
  */
171
395
  export class ActionRunner {
172
396
  constructor(context = {}) {
@@ -230,6 +454,12 @@ export class ActionRunner {
230
454
  writable: true,
231
455
  value: void 0
232
456
  });
457
+ Object.defineProperty(this, "translate", {
458
+ enumerable: true,
459
+ configurable: true,
460
+ writable: true,
461
+ value: void 0
462
+ });
233
463
  /**
234
464
  * Built-in dispatch, one entry per runnable action type.
235
465
  *
@@ -267,6 +497,21 @@ export class ActionRunner {
267
497
  this.navigationHandler = null;
268
498
  this.paramCollectionHandler = null;
269
499
  this.resultDialogHandler = null;
500
+ this.translate = null;
501
+ }
502
+ /**
503
+ * Set the translator for the text the runner supplies itself — the host's
504
+ * `t`, injected so this package takes no i18n dependency. That text is the
505
+ * `RUNNER_TEXT` table in this file: the generic success toast shown when an
506
+ * action declares neither an `outcomeMessages` entry for the answer nor a
507
+ * `successMessage`, the error fallbacks when no readable error message
508
+ * reached the runner, and the Undo label of an undoable success toast. An
509
+ * author's `outcomeMessages` / `successMessage` / `errorMessage` and an
510
+ * action's own error never go through this translator: the host localizes
511
+ * authored copy before dispatch. With no translator the text stays English.
512
+ */
513
+ setTranslator(translate) {
514
+ this.translate = translate;
270
515
  }
271
516
  /**
272
517
  * Set a custom confirmation handler (e.g., Shadcn AlertDialog).
@@ -325,14 +570,33 @@ export class ActionRunner {
325
570
  unregisterScript(scriptName) {
326
571
  this.scripts.delete(scriptName);
327
572
  }
328
- async execute(action) {
573
+ /**
574
+ * Run `action`; with `callContext`, run it against that context for this
575
+ * one dispatch (objectui#13063).
576
+ *
577
+ * The runner's context names the PAGE: its `record` and its `objectName`.
578
+ * A dispatch that runs against some other record, such as a related-list
579
+ * row on a record page, which is a record of the CHILD object, hands that
580
+ * record here as `{ record, objectName }`. Every executor then reads it as
581
+ * the record the action runs against, the way a dispatch from that record's
582
+ * own page does: `executeForm` forwards its id and object, and its
583
+ * boundary check compares the target view against the child object instead
584
+ * of the page's.
585
+ *
586
+ * The runner's own context is not touched: the dispatch runs on a one-shot
587
+ * runner over the merged context that shares this runner's handlers (see
588
+ * {@link ActionRunner.scopedTo}).
589
+ */
590
+ async execute(action, callContext) {
591
+ if (callContext)
592
+ return this.scopedTo(callContext).execute(action);
329
593
  try {
330
594
  // `ActionDef` accepts any key of any type, so a typo (`targt`) and a
331
595
  // 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).
596
+ // binds a handler, and binding no handler silently is the objectstack-ai/objectstack#2169 "Mark Done
597
+ // does nothing" shape. Dev-only, warn-once, changes nothing (objectstack-ai/objectstack#4075 step 1).
334
598
  warnOnUnknownActionKeys(action);
335
- // The compat window for the object-form `params` payload (#5777, maintainer
599
+ // The compat window for the object-form `params` payload (objectstack-ai/objectstack#5777, maintainer
336
600
  // ruling 2026-08-06 direction A). Checked HERE, before the param-collection
337
601
  // block below rewrites `action.params` into a values map — after that point
338
602
  // an action that authored a legitimate ActionParam[] DEFINITION array also
@@ -376,9 +640,16 @@ export class ActionRunner {
376
640
  // code only evaluated the STRING form and treated any object as truthy,
377
641
  // so an envelope-disabled action was ALWAYS "disabled" — silently
378
642
  // 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).
643
+ // ran). `evaluateCondition` already handles boolean/string/envelope.
644
+ //
645
+ // A predicate that FAULTS — an unbound root, a misspelled field,
646
+ // `current_user.can(…)` before the permissions payload has loaded — does
647
+ // NOT reach the `catch` below: `evaluateCondition` handles its own
648
+ // faults and answers its fail-soft `true`, which on this key means
649
+ // DISABLED, so the action is refused ("Action is disabled"). That is the
650
+ // closed direction every `disabled` leg takes (objectui#11212,
651
+ // objectui#11242). The `catch` only covers a throw from outside that
652
+ // fault handling, and leaves such an action not disabled.
382
653
  let isDisabled = false;
383
654
  try {
384
655
  isDisabled = this.evaluator.evaluateCondition(action.disabled);
@@ -395,8 +666,14 @@ export class ActionRunner {
395
666
  // structured `confirm.message` read that used to OUTRANK it is retired
396
667
  // (objectui#4314): it had zero producers and no bundle key, so a dialog
397
668
  // authored that way silently lost localization.
669
+ //
670
+ // Read through `templateEvaluatorFor`, not the runner's own evaluator:
671
+ // a row dispatch binds no `record` in the runner's context, so a
672
+ // `${record.*}` hole the record header fills was shown raw from a list
673
+ // row (objectui#12967).
674
+ const templates = this.templateEvaluatorFor(action);
398
675
  if (action.confirmText) {
399
- const confirmed = await this.confirmHandler(this.evaluator.evaluate(action.confirmText));
676
+ const confirmed = await this.confirmHandler(templates.evaluate(action.confirmText));
400
677
  if (!confirmed) {
401
678
  return { success: false, error: 'Action cancelled by user' };
402
679
  }
@@ -411,49 +688,69 @@ export class ActionRunner {
411
688
  : (Array.isArray(action.params) ? action.params : undefined);
412
689
  if (paramDefs && paramDefs.length > 0) {
413
690
  if (this.paramCollectionHandler) {
414
- const collected = await this.paramCollectionHandler(paramDefs, action);
415
- if (collected === null) {
416
- return { success: false, error: 'Action cancelled by user (params)' };
417
- }
418
691
  // Merge collected params into action.params as a values map for downstream consumers.
419
692
  // Preserve any pre-attached row context (`_rowRecord`) used by list_item
420
693
  // actions so downstream handlers (apiHandler) can inject the row id.
694
+ // Read ONCE, before any run: a second `run` after a refusal merges
695
+ // its values over the same base, not over the first run's values.
421
696
  const priorParams = Array.isArray(action.params) ? {} : (action.params || {});
422
697
  const rowRecord = priorParams._rowRecord;
423
- action.params = { ...priorParams, ...collected };
424
- if (rowRecord !== undefined)
425
- action.params._rowRecord = rowRecord;
698
+ const applyCollected = (collected) => {
699
+ action.params = { ...priorParams, ...collected };
700
+ if (rowRecord !== undefined)
701
+ action.params._rowRecord = rowRecord;
702
+ };
703
+ const cancelled = { success: false, error: 'Action cancelled by user (params)' };
704
+ // The handler-driven session (objectui#13161) — see `ParamCollectionHandler`.
705
+ // The dialog that collected the values stays open until it hears
706
+ // how the run went, so a refusal lands beside what the user typed
707
+ // instead of on a closed dialog.
708
+ let lastRun;
709
+ let inFlight = false;
710
+ const run = (values) => {
711
+ if (inFlight && lastRun)
712
+ return lastRun;
713
+ inFlight = true;
714
+ lastRun = (async () => {
715
+ try {
716
+ let attempt;
717
+ try {
718
+ applyCollected(values);
719
+ attempt = await this.dispatchCollected(action, actionType);
720
+ }
721
+ catch (error) {
722
+ // A dispatch that throws is a refused run, exactly as the
723
+ // `catch` at the end of `execute` reports one.
724
+ attempt = { success: false, error: error.message };
725
+ }
726
+ // A refused attempt gets its post-execution now, as a lone run
727
+ // would; a successful one waits for the session to end below.
728
+ if (!attempt.success)
729
+ await this.handlePostExecution(action, attempt);
730
+ return attempt;
731
+ }
732
+ finally {
733
+ inFlight = false;
734
+ }
735
+ })();
736
+ return lastRun;
737
+ };
738
+ const collected = await this.paramCollectionHandler(paramDefs, action, (text) => String(templates.evaluate(text) ?? ''), run);
739
+ if (lastRun) {
740
+ const last = await lastRun;
741
+ if (last.success) {
742
+ await this.handlePostExecution(action, last);
743
+ return last;
744
+ }
745
+ return collected === null ? cancelled : last;
746
+ }
747
+ if (collected === null) {
748
+ return cancelled;
749
+ }
750
+ applyCollected(collected);
426
751
  }
427
752
  }
428
- // Check for a registered custom handler first
429
- if (actionType && this.handlers.has(actionType)) {
430
- const handler = this.handlers.get(actionType);
431
- const result = await handler(action, this.context);
432
- await this.handlePostExecution(action, result);
433
- return result;
434
- }
435
- // Built-in action execution by type.
436
- let result;
437
- const builtin = actionType
438
- ? this.builtinExecutors[actionType]
439
- : undefined;
440
- if (builtin) {
441
- result = await builtin(action);
442
- }
443
- else if (action.navigate) {
444
- // Legacy fallback: check for navigate, api, or onClick
445
- result = await this.executeNavigation(action);
446
- }
447
- else if (action.api || action.endpoint) {
448
- result = await this.executeAPI(action);
449
- }
450
- else if (action.onClick) {
451
- await action.onClick();
452
- result = { success: true };
453
- }
454
- else {
455
- result = await this.executeActionSchema(action);
456
- }
753
+ const result = await this.dispatchCollected(action, actionType);
457
754
  await this.handlePostExecution(action, result);
458
755
  return result;
459
756
  }
@@ -463,6 +760,66 @@ export class ActionRunner {
463
760
  return result;
464
761
  }
465
762
  }
763
+ /**
764
+ * Dispatch an action whose params are collected, and answer what it
765
+ * returned — WITHOUT post-execution, which each caller runs itself:
766
+ * `execute` right after a lone run, and the param-collection session
767
+ * (`ParamCollectionHandler`'s `run`, objectui#13161) once per refused attempt
768
+ * and once for the successful one when the session ends.
769
+ */
770
+ async dispatchCollected(action, actionType) {
771
+ // ── `operation` is read BEFORE `type`, and this is the only order that
772
+ // can work ────────────────────────────────────────────────────────────
773
+ //
774
+ // `operation: 'update'` is a PARALLEL key beside `type`, not a member of
775
+ // it: `@objectstack/spec` materializes `type: 'script'` on such an action
776
+ // (the platform action route is the script type's own route) and refuses
777
+ // any other explicit `type`. `ActionType` therefore did not gain a
778
+ // member, which is why `builtinExecutors` — a `Record<RunnableActionType,
779
+ // …>` — still compiles unchanged across the bump that added this key.
780
+ //
781
+ // The cost of that design is paid here: by the time `actionType` is
782
+ // consulted, an update action is indistinguishable from every other
783
+ // script action. Dispatched on `type` it would reach the registered
784
+ // `script` handler as a plain script action: the route still writes the
785
+ // declared `patch` (it applies it from the declaration, never from the
786
+ // request — objectui#12941), but no Undo snapshot of the written fields
787
+ // would be taken, and with no `script` handler registered the action
788
+ // would end at `executeScript`'s "No script provided" instead of the
789
+ // update path's refusal naming the remedy. Worse, a row rehydrated from
790
+ // `sys_metadata` UNPARSED (objectui#3903) carries no materialized `type`
791
+ // at all, so `actionType` falls back to the action's NAME, matches no
792
+ // executor, and the action lands in `executeActionSchema`, reported on
793
+ // and never run. Reading `operation` first is what makes all of those
794
+ // impossible.
795
+ if (isUpdateOperationAction(action)) {
796
+ return this.executeUpdateOperation(action);
797
+ }
798
+ // Check for a registered custom handler first
799
+ if (actionType && this.handlers.has(actionType)) {
800
+ const handler = this.handlers.get(actionType);
801
+ return handler(action, this.context);
802
+ }
803
+ // Built-in action execution by type.
804
+ const builtin = actionType
805
+ ? this.builtinExecutors[actionType]
806
+ : undefined;
807
+ if (builtin) {
808
+ return builtin(action);
809
+ }
810
+ else if (action.navigate) {
811
+ // Legacy fallback: check for navigate, api, or onClick
812
+ return this.executeNavigation(action);
813
+ }
814
+ else if (action.api || action.endpoint) {
815
+ return this.executeAPI(action);
816
+ }
817
+ else if (action.onClick) {
818
+ await action.onClick();
819
+ return { success: true };
820
+ }
821
+ return this.executeActionSchema(action);
822
+ }
466
823
  /**
467
824
  * Execute multiple actions in sequence or parallel.
468
825
  */
@@ -477,13 +834,13 @@ export class ActionRunner {
477
834
  const firstFail = results.find(r => r.status === 'fulfilled' && !r.value.success);
478
835
  return {
479
836
  success: false,
480
- error: firstFail?.value?.error || 'One or more parallel actions failed',
837
+ error: firstFail?.value?.error || this.runnerText(RUNNER_TEXT.parallelFailed),
481
838
  };
482
839
  }
483
840
  const lastResult = results[results.length - 1];
484
841
  return lastResult.status === 'fulfilled'
485
842
  ? lastResult.value
486
- : { success: false, error: 'Action failed' };
843
+ : { success: false, error: this.runnerText(RUNNER_TEXT.failed) };
487
844
  }
488
845
  // Sequential execution — stop on first failure
489
846
  let lastResult = { success: true };
@@ -495,6 +852,61 @@ export class ActionRunner {
495
852
  }
496
853
  return lastResult;
497
854
  }
855
+ /**
856
+ * One entry of `RUNNER_TEXT`, in the installed translator's language. An
857
+ * empty answer from the translator falls back to the English source rather
858
+ * than raising an empty toast or an empty label.
859
+ */
860
+ runnerText({ key, defaultValue }) {
861
+ return this.translate?.(key, { defaultValue }) || defaultValue;
862
+ }
863
+ /**
864
+ * The success toast's text, by the contract's three rungs
865
+ * (`ActionSchema.outcomeMessages`, objectui#11344):
866
+ *
867
+ * 1. `outcomeMessages[outcome]`, where `outcome` is the top-level `outcome`
868
+ * of the handler's return value — the payload `${result.*}` reads, so it
869
+ * is the same fact an author writes as `${result.outcome}`;
870
+ * 2. `successMessage`;
871
+ * 3. the runner's own default text, in its translator's language.
872
+ *
873
+ * Rungs 1 and 2 are the author's copy. Both arrive already localized — the
874
+ * host's localizer resolves the bundle entry or the inline `I18nLabel` map
875
+ * before dispatch — and both have their `${result.*}` tokens filled in here,
876
+ * through the one token grammar and scope `onSuccess.navigate` reads
877
+ * (`substituteScopeTokens` over `readActionPayload(result.data)`). Only the
878
+ * sink differs: a toast is text, so a value is inserted as-is, where a URL
879
+ * position percent-encodes it.
880
+ *
881
+ * ⛔ `result.data.message` is not a rung. It used to outrank both rungs of
882
+ * author copy, which put whatever language the server happened to write in
883
+ * front of the user's locale; the contract names no server-message rung, and
884
+ * an author who wants the server's sentence can still write
885
+ * `${result.message}` as the copy.
886
+ *
887
+ * A rung counts only when it is a non-empty string. An `I18nLabel` map that
888
+ * reached the runner without passing through a localizer has no language to
889
+ * resolve against here, so it is skipped rather than handed to the toast as
890
+ * an object — the React #31 crash the error branch below guards against.
891
+ */
892
+ composeSuccessMessage(action, result) {
893
+ const payload = readActionPayload(result.data);
894
+ const outcome = payload && typeof payload === 'object'
895
+ ? payload.outcome
896
+ : undefined;
897
+ const outcomeMessages = action.outcomeMessages;
898
+ const outcomeCopy = typeof outcome === 'string'
899
+ && outcomeMessages && typeof outcomeMessages === 'object'
900
+ && Object.prototype.hasOwnProperty.call(outcomeMessages, outcome)
901
+ ? outcomeMessages[outcome]
902
+ : undefined;
903
+ for (const copy of [outcomeCopy, action.successMessage]) {
904
+ if (typeof copy === 'string' && copy.trim() !== '') {
905
+ return substituteScopeTokens(copy, { result: payload }, (value) => value);
906
+ }
907
+ }
908
+ return this.runnerText(RUNNER_TEXT.completedSuccessfully);
909
+ }
498
910
  /**
499
911
  * Post-execution: emit toast notifications, handle chaining, callbacks.
500
912
  */
@@ -510,27 +922,25 @@ export class ActionRunner {
510
922
  const showToast = action.toast ?? { showOnSuccess: true, showOnError: true };
511
923
  const duration = action.toast?.duration;
512
924
  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';
925
+ // The server returns FACTS and the console writes the sentence
926
+ // (ruling A on objectstack-ai/cloud#2315) — see `composeSuccessMessage`.
927
+ const message = this.composeSuccessMessage(action, result);
524
928
  // Undoable action: register the captured operation on the global
525
929
  // UndoManager and surface an "Undo" affordance on the toast (the
526
- // consumer's toast handler wires the button to UndoManager).
930
+ // consumer's toast handler wires the button to UndoManager). The
931
+ // button's label is the runner's own text, so it is translated here
932
+ // rather than left to each handler's English default (objectui#10969).
527
933
  if (result.undo) {
528
934
  try {
529
935
  globalUndoManager.push(result.undo);
530
936
  }
531
937
  catch { /* non-fatal */ }
532
938
  }
533
- this.toastHandler(message, { type: 'success', duration, undo: result.undo ? {} : undefined });
939
+ this.toastHandler(message, {
940
+ type: 'success',
941
+ duration,
942
+ undo: result.undo ? { label: this.runnerText(RUNNER_TEXT.undo) } : undefined,
943
+ });
534
944
  }
535
945
  if (!result.success && showToast.showOnError !== false && result.error) {
536
946
  // `result.error` is typed as a string, but a handler bug can leak an
@@ -543,7 +953,7 @@ export class ActionRunner {
543
953
  ? raw
544
954
  : (raw && typeof raw.message === 'string')
545
955
  ? raw.message
546
- : 'Action failed';
956
+ : this.runnerText(RUNNER_TEXT.failed);
547
957
  this.toastHandler(message, { type: 'error', duration });
548
958
  }
549
959
  }
@@ -586,34 +996,28 @@ export class ActionRunner {
586
996
  }
587
997
  // ── ActionSchema.onSuccess — post-success navigation ────────────────────
588
998
  //
589
- // objectui#5221, the console half of objectstack#9566/#9474. The spec
999
+ // `053fdc8f9`, the console half of objectstack#9566/#9474. The spec
590
1000
  // declares `onSuccess` as a CLOSED STRICT object
591
1001
  // `{ navigate: string, openIn: 'self' | 'newTab' }`, refine-scoped to
592
1002
  // `type: 'api'` and `type: 'script'` — the two types whose success event
593
1003
  // carries a server response for `${result.*}` to read.
594
1004
  //
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.
1005
+ // That declared meaning is the key's ONLY meaning. The runner's older
1006
+ // chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
1007
+ // dispatched through `executeChain`) was retired by objectui#5934
1008
+ // (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
1009
+ // at parse, so no validated metadata could ever reach it, and the census
1010
+ // found zero producers outside the channel's own pins.
603
1011
  //
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.
1012
+ // `readOnSuccessNavigation` stays as the shape guard, not as a
1013
+ // discriminator: stored rows are rehydrated UNPARSED (objectstack-ai/objectstack#3903), so the value
1014
+ // is still read as data, and a shape the spec refuses gets no reading —
1015
+ // no navigation, no callback dispatch, no lenient fallback.
608
1016
  if (result.success && action.onSuccess) {
609
1017
  const navigation = readOnSuccessNavigation(action.onSuccess);
610
1018
  if (navigation) {
611
1019
  this.navigateOnSuccess(navigation, action, result);
612
1020
  }
613
- else {
614
- const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
615
- await this.executeChain(callbacks, 'sequential');
616
- }
617
1021
  }
618
1022
  if (!result.success && action.onFailure) {
619
1023
  const callbacks = Array.isArray(action.onFailure) ? action.onFailure : [action.onFailure];
@@ -628,7 +1032,9 @@ export class ActionRunner {
628
1032
  * react-router's `navigate` — so `openIn: 'self'` is a real SPA route hop,
629
1033
  * in place, immune to popup blocking. No `window.location` / `window.open`
630
1034
  * mechanism is introduced here; the no-handler fallback is the same
631
- * `result.redirect` hand-off `navigateTo` already uses.
1035
+ * `result.redirect` hand-off `navigateTo` already uses. The one exception is
1036
+ * shared with `navigateTo`: a `mailto:` target is not a route, so it is a
1037
+ * full-page navigation in place (objectui#12191).
632
1038
  *
633
1039
  * **No default is written here.** `openIn` is a materialised
634
1040
  * `.default('self')` in the spec, so parse output always carries a resolved
@@ -652,15 +1058,21 @@ export class ActionRunner {
652
1058
  const url = this.interpolateTarget(block.navigate, action, readActionPayload(result.data));
653
1059
  // `navigate` is author-supplied metadata and lands in a URL position, so
654
1060
  // 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 });
1061
+ const kind = classifyNavigationTarget(url);
1062
+ if (kind === null) {
1063
+ console.warn(`[ActionRunner] onSuccess.navigate resolved to a URL this runner refuses to open — no navigation performed. ${NAVIGATION_TARGET_REFUSAL}`, { action: action.name, navigate: block.navigate, resolved: url });
1064
+ return;
1065
+ }
1066
+ // `mailto:` hands off to the mail client in place, whatever `openIn` says,
1067
+ // and never through the host's router (see `navigateTo`).
1068
+ if (kind === 'mail') {
1069
+ window.location.href = url;
658
1070
  return;
659
1071
  }
660
1072
  const newTab = block.openIn === 'newTab';
661
1073
  // Same external-URL convention `navigateTo` hands the handler; `newTab`
662
1074
  // stays the declared choice alone, never `?? isExternal`.
663
- const external = url.startsWith('http://') || url.startsWith('https://');
1075
+ const external = kind === 'web';
664
1076
  if (this.navigationHandler) {
665
1077
  this.navigationHandler(url, { external, newTab });
666
1078
  return;
@@ -672,15 +1084,149 @@ export class ActionRunner {
672
1084
  result.redirect = url;
673
1085
  }
674
1086
  }
1087
+ /**
1088
+ * `operation: 'update'` — the declarative single-record field write.
1089
+ *
1090
+ * ## The write is the ROUTE's, never this client's
1091
+ *
1092
+ * This method composes a request and hands it to the registered `script`
1093
+ * dispatch; it does not touch a `DataSource`. That is a security boundary,
1094
+ * not an implementation preference. `POST /api/v1/actions/{object}/{action}`
1095
+ * runs the write AS THE CALLER — the caller's permissions, the object's hooks
1096
+ * and its validations all fire as for a user edit — while a client-side
1097
+ * `dataSource.update()` reaches the data plane having consulted none of them.
1098
+ * The spec says the same thing from its end by refusing `target`, `body`,
1099
+ * `method` and `bodyExtra` beside this key and pinning `type` to `'script'`:
1100
+ * there is exactly one execution surface for an update action, and it is the
1101
+ * route.
1102
+ *
1103
+ * Consequently, a consumer that has registered NO server-action dispatch gets
1104
+ * a loud refusal naming the remedy. The two silent alternatives were both
1105
+ * available and both wrong: falling through to `executeScript` ends at "No
1106
+ * script provided" (an update action declares no `target` — the author is
1107
+ * sent hunting for a field they were right not to write), and falling through
1108
+ * to `executeActionSchema` reports on an action that never ran.
1109
+ *
1110
+ * ## `patch` is the route's: it never rides on the wire (objectui#12941)
1111
+ *
1112
+ * The request carries the values collected for the action's declared
1113
+ * `params`, plus the transport keys the dispatch reads (`recordId`, and the
1114
+ * `_rowRecord` stash it strips before POSTing) — and NOTHING from `patch`.
1115
+ * The route applies the declared `patch` itself, from the declaration it
1116
+ * resolves by the action's name (`@objectstack/runtime`'s
1117
+ * `declarativeUpdateWrite`, which writes `{ ...patch, ...params }`). The
1118
+ * patch keys are not params the action declares, so posting them makes every
1119
+ * declarative-update button a request carrying undeclared params — the very
1120
+ * request the ADR-0104 D2 param contract refuses (objectstack#24268). A
1121
+ * client copy of `patch` on the wire also adds nothing: the server never
1122
+ * reads it.
1123
+ *
1124
+ * The spec's words for the merge the route performs: static field values
1125
+ * "merged UNDER the user-supplied `params` so a fixed value can be declared
1126
+ * without exposing it in the dialog". So a collected param of the same name
1127
+ * WINS — the author declared a default, and the user answered it. This
1128
+ * runner still computes that same merge, but only to know what the route
1129
+ * WROTE, which is what an Undo snapshot must cover; it is never sent.
1130
+ *
1131
+ * ## The `patch` in that merge is the DECLARATION's (objectui#13022)
1132
+ *
1133
+ * The route writes the `patch` of the declaration the action's `name` names,
1134
+ * so that is the `patch` the snapshot covers: read by
1135
+ * {@link declaredUpdatePatch} from the declarations the host published, else
1136
+ * off a def that is the declaration itself. A page action row's own `patch`
1137
+ * is never written (`@objectstack/spec` 18 retires it) and never covered.
1138
+ */
1139
+ async executeUpdateOperation(action) {
1140
+ const dispatch = this.handlers.get('script');
1141
+ if (!dispatch) {
1142
+ return {
1143
+ success: false,
1144
+ error: 'An `operation: "update"` action is written by the platform action route, which this ' +
1145
+ 'client runner cannot reach: no `script` handler is registered. The write is never ' +
1146
+ 'performed client-side — a direct data-plane update bypasses the route\'s permission ' +
1147
+ 'floor, the object\'s hooks and its validations. Register a handler that POSTs to ' +
1148
+ '/api/v1/actions/{object}/{action} — build one with createServerActionHandler from ' +
1149
+ '@object-ui/core.',
1150
+ };
1151
+ }
1152
+ const collected = (action.params && !Array.isArray(action.params))
1153
+ ? { ...action.params }
1154
+ : {};
1155
+ // The object the write lands on: the action's own, else the host's.
1156
+ const objectName = action.objectName || readContextObjectName(this.context);
1157
+ // What the route WRITES: the DECLARATION's `patch` UNDER the collected
1158
+ // params, the route's own merge (see the doc above), never a page row's
1159
+ // own `patch` (objectui#13022). Read for the Undo snapshot only — ⛔ never
1160
+ // the request: the route applies `patch` from its declaration.
1161
+ const patch = declaredUpdatePatch(action, this.context, objectName);
1162
+ const written = { ...patch, ...collected };
1163
+ // The row stash and the record selector are transport mechanics, not
1164
+ // written fields: the dispatch strips `_rowRecord` before POSTing and reads
1165
+ // `recordId` as the record's address.
1166
+ const rowRecord = (collected._rowRecord && typeof collected._rowRecord === 'object')
1167
+ ? collected._rowRecord
1168
+ : undefined;
1169
+ const writtenFields = Object.keys(written).filter((k) => k !== '_rowRecord' && k !== 'recordId');
1170
+ // What the request CARRIES: the collected values alone, as a copy of its
1171
+ // own so a dispatch that edits its bag cannot reach the reads below.
1172
+ const wire = { ...collected };
1173
+ const result = await dispatch({ ...action, params: wire }, this.context);
1174
+ // A refusal is returned exactly as the dispatch reported it. The route's
1175
+ // envelope rule (`interpretActionResponse`) has already turned a 4xx/5xx
1176
+ // AND a 200-with-`success:false` business rejection into `error`, and
1177
+ // `handlePostExecution` surfaces that as the failure toast. Re-labelling it
1178
+ // here, or answering success because the POST itself completed, is the
1179
+ // objectui#2960 shape this branch exists to prevent.
1180
+ if (!result.success)
1181
+ return result;
1182
+ // Undo: prior values of exactly the fields this action wrote. Needs a row
1183
+ // record to read them from — without one there is nothing to restore, so
1184
+ // the affordance is correctly not offered rather than offered empty. The
1185
+ // same holds when the row lacks a written field (objectui#10404): no Undo,
1186
+ // never one that writes `null` over the stored value. A relation the row
1187
+ // carries expanded is captured as its stored id, read against the field
1188
+ // map the host published for this object (objectui#11122).
1189
+ if (action.undoable && rowRecord && writtenFields.length > 0) {
1190
+ const recordId = collected.recordId ?? rowRecord.id;
1191
+ if (objectName && recordId != null) {
1192
+ const undoData = captureUpdateUndoData(writtenFields, rowRecord, readContextObjectFields(this.context, objectName));
1193
+ if (undoData) {
1194
+ result.undo = {
1195
+ id: `undo-${objectName}-${String(recordId)}-${Date.now()}`,
1196
+ type: 'update',
1197
+ objectName,
1198
+ recordId: String(recordId),
1199
+ timestamp: Date.now(),
1200
+ // objectui#11080 — no English verb here. The toast that reports the
1201
+ // Undo / Redo already says which one it is, from a pack key, so an
1202
+ // action that declared no label is named by the object it acted on.
1203
+ description: action.label || objectName,
1204
+ undoData,
1205
+ redoData: Object.fromEntries(writtenFields.map((k) => [k, written[k]])),
1206
+ };
1207
+ }
1208
+ else {
1209
+ // The success toast then carries no Undo button, which is how the
1210
+ // user learns this one cannot be undone; this names the cause for
1211
+ // the author.
1212
+ console.warn('[ActionRunner] `undoable` update succeeded but offers no Undo: the row it ran on does not '
1213
+ + 'carry every field it wrote, so their prior values are unknown and an Undo would overwrite '
1214
+ + 'stored data. A list row carries a written field when the object declares it and the '
1215
+ + 'principal may read it.', { action: action.name, missing: writtenFields.filter((k) => !rowCarries(rowRecord, k)) });
1216
+ }
1217
+ }
1218
+ }
1219
+ return result;
1220
+ }
675
1221
  /**
676
1222
  * Execute script action — evaluates client-side expression via ExpressionEvaluator.
677
1223
  * Supports ${} template expressions referencing data, record, user context.
678
1224
  */
679
1225
  async executeScript(action) {
680
1226
  // `target` is the only handler slot. The `execute` alias was removed in
681
- // @objectstack/spec 17 (#3855), which rejects an authored `execute` at parse
1227
+ // @objectstack/spec 17 (objectstack-ai/objectstack#3855), which rejects an authored `execute` at parse
682
1228
  // with the rename prescription — so parsed metadata cannot carry it and a
683
- // `target || execute` fallback could only ever evaluate to `target` (#3856).
1229
+ // `target || execute` fallback could only ever evaluate to `target` (objectstack-ai/objectstack#3856).
684
1230
  const script = action.target;
685
1231
  if (!script) {
686
1232
  // A spec `body` IS a script — this runner just cannot run one (see the
@@ -703,11 +1249,11 @@ export class ActionRunner {
703
1249
  // `ActionDef` no longer declares `execute` — nor any open index signature
704
1250
  // — so `tsc` now catches the retired key at every site that AUTHORS an
705
1251
  // 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
1252
+ // reach. It is not this half: objectstack-ai/objectstack#3903 established that stored
707
1253
  // `sys_metadata` rows are rehydrated UNPARSED, so metadata written before
708
1254
  // spec 17 arrives here as a plain object the compiler never saw, still
709
1255
  // 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
1256
+ // re-legitimize a tombstone (objectstack-ai/objectstack#3855 removed it; the spec keeps it only to
711
1257
  // reject it BY NAME), and deleting the branch would send those rows back
712
1258
  // to a bare "no script provided" — which reads as "you forgot a field" to
713
1259
  // an author who did write one, the objectstack#2169 shape.
@@ -761,7 +1307,29 @@ export class ActionRunner {
761
1307
  if (!rawUrl) {
762
1308
  return { success: false, error: 'No URL provided for url action' };
763
1309
  }
764
- return this.navigateTo(action, rawUrl, action);
1310
+ return this.announceNavigation(action, await this.navigateTo(action, rawUrl, action));
1311
+ }
1312
+ /**
1313
+ * A navigation announces nothing unless its action declares the copy
1314
+ * (objectui#12755).
1315
+ *
1316
+ * `type: 'url'`, its `navigation` alias and `type: 'form'` only change the
1317
+ * route: nothing was written, so there is nothing to confirm. Their
1318
+ * `{ success: true }` used to reach `handlePostExecution` like a write's, and
1319
+ * the generic `completedSuccessfully` toast ("操作已成功完成") told the user an
1320
+ * operation had completed when a page had merely opened (objectstack-ai/cloud#2909:
1321
+ * an "invite member" button that only navigated read as "invitation sent").
1322
+ *
1323
+ * The result keeps `success: true` (callers read it as "the navigation
1324
+ * happened") and is marked `silent`, the flag that already means "no success
1325
+ * toast for this result". An author who wants a toast after the navigation
1326
+ * declares `successMessage`, and that copy still shows: the declaration is the
1327
+ * only signal, so there is no separate switch to forget.
1328
+ */
1329
+ announceNavigation(action, result) {
1330
+ if (!result.success || declaresSuccessMessage(action))
1331
+ return result;
1332
+ return { ...result, silent: true };
765
1333
  }
766
1334
  /**
767
1335
  * The one navigator behind `type: 'url'` and its `navigation` alias.
@@ -785,11 +1353,19 @@ export class ActionRunner {
785
1353
  // like `?provider=foo+bar`).
786
1354
  const interpolated = this.interpolateTarget(rawUrl, action);
787
1355
  let url = this.evaluator.evaluate(interpolated);
788
- if (!this.isValidUrl(url)) {
789
- return {
790
- success: false,
791
- error: 'Invalid URL scheme. Only http://, https://, and relative URLs are allowed.',
792
- };
1356
+ const kind = classifyNavigationTarget(url);
1357
+ if (kind === null) {
1358
+ return { success: false, error: NAVIGATION_TARGET_REFUSAL };
1359
+ }
1360
+ // A `mailto:` target opens the mail client IN PLACE (objectui#12191): it
1361
+ // never opens a new tab, so `openIn` is not consulted. It does not go to
1362
+ // the host's navigation handler either. The console's handler opens every
1363
+ // `external` URL in a new tab and hands the rest to react-router, which
1364
+ // resolves `mailto:a@b.com` as a path under the current route. So this is
1365
+ // a full-page navigation here, the same mechanism as the `/api/` case below.
1366
+ if (kind === 'mail') {
1367
+ window.location.href = url;
1368
+ return { success: true };
793
1369
  }
794
1370
  // Promote same-origin-style `/api/...` paths to absolute when an
795
1371
  // `apiBase` is provided in context (typical of split SPA + backend
@@ -803,7 +1379,7 @@ export class ActionRunner {
803
1379
  }
804
1380
  // `source.external` is the `navigation` shape's explicit override; the
805
1381
  // scheme heuristic covers every other case.
806
- const isExternal = Boolean(source.external) || url.startsWith('http://') || url.startsWith('https://');
1382
+ const isExternal = Boolean(source.external) || classifyNavigationTarget(url) === 'web';
807
1383
  // Same-origin API endpoints (most commonly the auth provider's
808
1384
  // `/api/v1/auth/sign-in/social` redirect dance) issue server-side
809
1385
  // 302s that must be followed by the *browser*, not the SPA router.
@@ -994,9 +1570,9 @@ export class ActionRunner {
994
1570
  : '');
995
1571
  if (this.navigationHandler) {
996
1572
  this.navigationHandler(to, { external: false });
997
- return { success: true };
1573
+ return this.announceNavigation(action, { success: true });
998
1574
  }
999
- return { success: true, redirect: to };
1575
+ return this.announceNavigation(action, { success: true, redirect: to });
1000
1576
  }
1001
1577
  /**
1002
1578
  * `navigation` — objectui's alias of the spec's `url` (`ObjectUiLocalActionType`).
@@ -1014,7 +1590,7 @@ export class ActionRunner {
1014
1590
  if (!rawUrl) {
1015
1591
  return { success: false, error: 'No URL provided for navigation action' };
1016
1592
  }
1017
- return this.navigateTo(action, rawUrl, nav);
1593
+ return this.announceNavigation(action, await this.navigateTo(action, rawUrl, nav));
1018
1594
  }
1019
1595
  /**
1020
1596
  * Assemble the request body for the runner's OWN `executeAPI` — the path taken
@@ -1029,7 +1605,7 @@ export class ActionRunner {
1029
1605
  * old object-form `params` page — POSTed an empty body here.
1030
1606
  *
1031
1607
  * `params` contributes only in its DEPRECATED non-array form (the compat window
1032
- * #5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
1608
+ * objectstack-ai/objectstack#5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
1033
1609
  * parameter DEFINITION list, not a payload: it reaches this method unconsumed
1034
1610
  * only when no `paramCollectionHandler` is mounted, and POSTing the definitions
1035
1611
  * as the request body was never a payload any endpoint wanted. It now falls
@@ -1138,15 +1714,6 @@ export class ActionRunner {
1138
1714
  return { success: false, error: error.message };
1139
1715
  }
1140
1716
  }
1141
- /**
1142
- * Validate URL to prevent javascript: or data: protocol injection.
1143
- */
1144
- isValidUrl(url) {
1145
- return typeof url === 'string' && (url.startsWith('http://') ||
1146
- url.startsWith('https://') ||
1147
- url.startsWith('/') ||
1148
- url.startsWith('./'));
1149
- }
1150
1717
  /**
1151
1718
  * Substitute `${param.X}` and `${ctx.X}` tokens in an action target.
1152
1719
  *
@@ -1220,16 +1787,15 @@ export class ActionRunner {
1220
1787
  // BEFORE the request, so there is no result to read; admitting `${result.*}`
1221
1788
  // there would widen the authorable surface with nothing behind it and
1222
1789
  // silently blank the token instead of leaving the author's mistake visible.
1790
+ //
1791
+ // The success toast's copy is the one other caller of the grammar, through
1792
+ // `substituteScopeTokens` directly (`composeSuccessMessage`): `result` only,
1793
+ // because that is the one scope the contract declares for the copy, and no
1794
+ // encoding, because a toast is text.
1223
1795
  const scopes = { param: params, ctx: this.buildInterpolationContext() };
1224
1796
  if (resultScope !== undefined)
1225
1797
  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
- });
1798
+ return substituteScopeTokens(target, scopes, encodeURIComponent);
1233
1799
  }
1234
1800
  /**
1235
1801
  * Assemble the `ctx` namespace exposed to target interpolation.
@@ -1271,6 +1837,36 @@ export class ActionRunner {
1271
1837
  }
1272
1838
  return ctx;
1273
1839
  }
1840
+ /**
1841
+ * The evaluator a dispatch's authored copy is read through: `confirmText`,
1842
+ * and the param dialog's `description` / `helpText` via the handler's
1843
+ * `interpolate` argument.
1844
+ *
1845
+ * One rule for every door (objectui#12967): the record the action runs
1846
+ * against is `record`. The record header binds it in the runner's context
1847
+ * (`RecordDetailView`'s `ActionProvider`). A row surface does not — it
1848
+ * stashes the clicked row under `params._rowRecord` (ObjectGrid's row
1849
+ * actions, a related list's row actions, `DeclaredActionsBar`, a page
1850
+ * header, `action:button`'s update write) and fires through the shared
1851
+ * runner, whose context names the page, not the row. So when the dispatch
1852
+ * carries a row, the row is bound as `record` for this dispatch's templates,
1853
+ * and it wins over a page-level `record` (a related-list row on a record page
1854
+ * is the child, not the page's record). Nothing else in the scope changes,
1855
+ * and the runner's own context is not touched.
1856
+ *
1857
+ * A hole naming a field the record does not carry degrades the way the
1858
+ * header's always has: the evaluator reads `undefined` and the hole prints
1859
+ * empty.
1860
+ */
1861
+ templateEvaluatorFor(action) {
1862
+ const params = action.params;
1863
+ const row = params && typeof params === 'object' && !Array.isArray(params)
1864
+ ? params._rowRecord
1865
+ : undefined;
1866
+ if (!row || typeof row !== 'object' || Array.isArray(row))
1867
+ return this.evaluator;
1868
+ return new ExpressionEvaluator({ ...this.context, record: row });
1869
+ }
1274
1870
  updateContext(newContext) {
1275
1871
  this.context = withIdentityAlias({ ...this.context, ...newContext });
1276
1872
  // Feed the merged (re-aliased) context so a refreshed `user` also
@@ -1280,6 +1876,56 @@ export class ActionRunner {
1280
1876
  getContext() {
1281
1877
  return this.context;
1282
1878
  }
1879
+ /**
1880
+ * A one-shot runner for {@link ActionRunner.execute}'s `callContext`
1881
+ * (objectui#13063): this runner's context with `callContext` merged over it,
1882
+ * and this runner's handlers, translator and registries, shared rather than
1883
+ * copied, so the dispatch confirms, collects params, toasts and navigates
1884
+ * exactly as this runner does.
1885
+ *
1886
+ * Two keys follow the merge rather than the base:
1887
+ *
1888
+ * - `ctx`, the `ctx.*` mirror `ActionProvider` publishes beside the flat
1889
+ * keys. A `${ctx.record.…}` hole has to read the same record as a
1890
+ * `${record.…}` one.
1891
+ * - `objectFields` / `objectActions`. A host publishes them BESIDE
1892
+ * `objectName`, so the pair names one object (objectui#11122,
1893
+ * objectui#13022). When `callContext` names another object and brings no
1894
+ * definitions of its own, the base's are dropped: the page object's field
1895
+ * types and declarations must not answer for the child's record. That is
1896
+ * what a dispatch with no `callContext` already gets for a retargeted
1897
+ * action, so the update path's capture sees the same input either way.
1898
+ */
1899
+ scopedTo(callContext) {
1900
+ const objectChanged = 'objectName' in callContext &&
1901
+ readContextObjectName(callContext) !== readContextObjectName(this.context);
1902
+ const follow = (bag) => {
1903
+ const merged = { ...bag, ...callContext };
1904
+ if (objectChanged) {
1905
+ if (!('objectFields' in callContext))
1906
+ delete merged.objectFields;
1907
+ if (!('objectActions' in callContext))
1908
+ delete merged.objectActions;
1909
+ }
1910
+ return merged;
1911
+ };
1912
+ const merged = follow(this.context);
1913
+ const mirror = this.context.ctx;
1914
+ if (mirror && typeof mirror === 'object' && !Array.isArray(mirror)) {
1915
+ merged.ctx = follow(mirror);
1916
+ }
1917
+ const scoped = new ActionRunner(merged);
1918
+ scoped.handlers = this.handlers;
1919
+ scoped.scripts = this.scripts;
1920
+ scoped.confirmHandler = this.confirmHandler;
1921
+ scoped.toastHandler = this.toastHandler;
1922
+ scoped.modalHandler = this.modalHandler;
1923
+ scoped.navigationHandler = this.navigationHandler;
1924
+ scoped.paramCollectionHandler = this.paramCollectionHandler;
1925
+ scoped.resultDialogHandler = this.resultDialogHandler;
1926
+ scoped.translate = this.translate;
1927
+ return scoped;
1928
+ }
1283
1929
  /**
1284
1930
  * Get the expression evaluator (for components that need to evaluate visibility, etc.)
1285
1931
  */
@@ -1295,15 +1941,14 @@ export async function executeAction(action, context = {}) {
1295
1941
  return await runner.execute(action);
1296
1942
  }
1297
1943
  /**
1298
- * Is this `onSuccess` the SPEC's navigation block, or this runner's older
1299
- * chained-callback channel (`ActionDef | ActionDef[]`)?
1944
+ * Is this `onSuccess` the spec's navigation block?
1300
1945
  *
1301
1946
  * 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.
1947
+ * `navigate`. Stored rows are rehydrated UNPARSED (objectstack-ai/objectstack#3903), so the runner reads
1948
+ * the value as data and anything else gets NO reading — since objectui#5934
1949
+ * retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
1950
+ * there is no other channel for an off-contract shape to fall into. This is a
1951
+ * shape GUARD on unparsed data, not a discriminator between two meanings.
1307
1952
  */
1308
1953
  export function readOnSuccessNavigation(value) {
1309
1954
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -1313,6 +1958,28 @@ export function readOnSuccessNavigation(value) {
1313
1958
  return null;
1314
1959
  return value;
1315
1960
  }
1961
+ /**
1962
+ * The runner's ONE `${scope.path}` token grammar — the substitution behind
1963
+ * `interpolateTarget` (url / api targets and the `onSuccess.navigate` hop) and
1964
+ * behind the success toast's `${result.*}` copy (`composeSuccessMessage`).
1965
+ *
1966
+ * The scope MAP defines the vocabulary: the pattern is built from its keys, so
1967
+ * a token naming a scope the caller did not pass is left in the text exactly as
1968
+ * written. A path that resolves to nothing becomes `''`. `encode` is the only
1969
+ * thing a caller varies, and it belongs to the SINK, not to the dialect: a URL
1970
+ * position percent-encodes each value, a toast inserts it as text.
1971
+ */
1972
+ function substituteScopeTokens(template, scopes, encode) {
1973
+ if (typeof template !== 'string' || template.indexOf('${') === -1)
1974
+ return template;
1975
+ const pattern = new RegExp(`\\$\\{(${Object.keys(scopes).join('|')})\\.([\\w.]+)\\}`, 'g');
1976
+ return template.replace(pattern, (_match, scope, path) => {
1977
+ const value = readPath(scopes[scope], path);
1978
+ if (value == null)
1979
+ return '';
1980
+ return encode(String(value));
1981
+ });
1982
+ }
1316
1983
  /**
1317
1984
  * Dot-path read used by target interpolation. Plain reduce — kept inline
1318
1985
  * to avoid pulling lodash for a 3-line helper.