@object-ui/core 17.5.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +794 -0
  2. package/README.md +36 -22
  3. package/dist/actions/ActionEngine.d.ts +1 -1
  4. package/dist/actions/ActionEngine.js +1 -2
  5. package/dist/actions/ActionRunner.d.ts +82 -10
  6. package/dist/actions/ActionRunner.js +128 -22
  7. package/dist/actions/actionKeys.d.ts +68 -8
  8. package/dist/actions/actionKeys.js +81 -9
  9. package/dist/actions/actionResponse.js +1 -1
  10. package/dist/actions/index.d.ts +1 -0
  11. package/dist/actions/index.js +1 -0
  12. package/dist/actions/recordIdParam.d.ts +77 -0
  13. package/dist/actions/recordIdParam.js +37 -0
  14. package/dist/actions/serverActionHandler.d.ts +1 -1
  15. package/dist/actions/serverActionHandler.js +1 -1
  16. package/dist/evaluator/ExpressionEvaluator.d.ts +42 -1
  17. package/dist/evaluator/ExpressionEvaluator.js +42 -1
  18. package/dist/evaluator/SafeExpressionParser.js +0 -4
  19. package/dist/evaluator/declaredPredicate.js +1 -1
  20. package/dist/evaluator/fieldRules.d.ts +19 -0
  21. package/dist/evaluator/fieldRules.js +30 -6
  22. package/dist/evaluator/index.d.ts +1 -0
  23. package/dist/evaluator/index.js +1 -0
  24. package/dist/evaluator/listConditional.d.ts +49 -2
  25. package/dist/evaluator/listConditional.js +70 -6
  26. package/dist/evaluator/optionRules.d.ts +55 -0
  27. package/dist/evaluator/optionRules.js +60 -0
  28. package/dist/evaluator/rowPredicateCanon.d.ts +67 -0
  29. package/dist/evaluator/rowPredicateCanon.js +208 -0
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.js +17 -0
  32. package/dist/registry/Registry.d.ts +41 -19
  33. package/dist/theme/index.d.ts +1 -1
  34. package/dist/theme/index.js +1 -1
  35. package/dist/types/index.d.ts +92 -13
  36. package/dist/utils/chart-presentation.d.ts +205 -0
  37. package/dist/utils/chart-presentation.js +280 -0
  38. package/dist/utils/chart-series.d.ts +140 -26
  39. package/dist/utils/chart-series.js +380 -39
  40. package/dist/utils/column-identity.d.ts +43 -0
  41. package/dist/utils/column-identity.js +47 -1
  42. package/dist/utils/dataset-format.js +18 -6
  43. package/dist/utils/debug.js +7 -0
  44. package/dist/utils/expand-fields.d.ts +97 -7
  45. package/dist/utils/expand-fields.js +97 -7
  46. package/dist/utils/filter-tokens.js +0 -1
  47. package/dist/utils/freeze-schema.d.ts +38 -1
  48. package/dist/utils/freeze-schema.js +10 -0
  49. package/dist/utils/humanize-label.d.ts +60 -0
  50. package/dist/utils/humanize-label.js +62 -0
  51. package/dist/utils/managedBy.d.ts +27 -2
  52. package/dist/utils/normalize-list-view.d.ts +10 -0
  53. package/dist/utils/normalize-list-view.js +63 -1
  54. package/dist/utils/number-display.d.ts +7 -3
  55. package/dist/utils/predicate-fields.d.ts +29 -4
  56. package/dist/utils/predicate-fields.js +44 -4
  57. package/dist/utils/retired-field-types.d.ts +90 -0
  58. package/dist/utils/retired-field-types.js +124 -0
  59. package/dist/utils/sort-query.d.ts +12 -10
  60. package/dist/utils/unmaterialized-fields.d.ts +52 -0
  61. package/dist/utils/unmaterialized-fields.js +57 -0
  62. package/dist/validation/index.d.ts +1 -0
  63. package/dist/validation/index.js +1 -0
  64. package/dist/validation/schema-validator.js +105 -0
  65. package/dist/validation/server-owned-value.d.ts +62 -0
  66. package/dist/validation/server-owned-value.js +99 -0
  67. package/dist/validation/validators/object-validation-engine.js +0 -2
  68. package/package.json +4 -4
  69. package/dist/__benchmarks__/core.bench.d.ts +0 -8
  70. package/dist/__benchmarks__/core.bench.js +0 -53
package/README.md CHANGED
@@ -4,7 +4,8 @@ Core logic, types, and validation for Object UI. Zero React dependencies.
4
4
 
5
5
  ## Features
6
6
 
7
- - 🎯 **Type Definitions** - Complete TypeScript schemas for all components
7
+ - 🎯 **Type Definitions** - Re-exported runtime types; the component schema
8
+ vocabulary itself is `@object-ui/types`
8
9
  - 🔍 **Component Registry** - Framework-agnostic component registration system
9
10
  - 📊 **Data Scope** - Data scope management and expression evaluation
10
11
  - ✅ **Validation** - Zod-based schema validation
@@ -20,15 +21,20 @@ npm install @object-ui/core
20
21
 
21
22
  ### Type Definitions
22
23
 
24
+ The component schema vocabulary lives in `@object-ui/types`. Core depends on
25
+ that package and does not re-export it, so import the types from there. The
26
+ page node type is `PageNodeSchema` — the SDUI node, as distinct from the
27
+ authored page document.
28
+
23
29
  ```typescript
24
- import type {
25
- PageSchema,
26
- FormSchema,
30
+ import type {
31
+ PageNodeSchema,
32
+ FormSchema,
27
33
  InputSchema,
28
- BaseSchema
29
- } from '@object-ui/core'
34
+ BaseSchema
35
+ } from '@object-ui/types'
30
36
 
31
- const mySchema: PageSchema = {
37
+ const mySchema: PageNodeSchema = {
32
38
  type: 'page',
33
39
  title: 'My Page',
34
40
  body: []
@@ -40,22 +46,30 @@ const mySchema: PageSchema = {
40
46
  ```typescript
41
47
  import { ComponentRegistry } from '@object-ui/core'
42
48
 
43
- const registry = new ComponentRegistry()
44
- registry.register('button', buttonMetadata)
45
- const metadata = registry.get('button')
49
+ ComponentRegistry.register('button', buttonMetadata)
50
+ const metadata = ComponentRegistry.get('button')
46
51
  ```
47
52
 
53
+ `ComponentRegistry` is a process-level singleton exported by `@object-ui/core`;
54
+ `SchemaRenderer` resolves every `type` against it, so a component registered
55
+ here is renderable from schema anywhere in the app.
56
+
48
57
  ### Data Scope
49
58
 
59
+ `DataScopeManager` owns the named scopes a component tree reads from, and
60
+ `evaluateExpression` evaluates a `${...}` expression against a context. They
61
+ are separate exports: a scope holds data, it does not evaluate.
62
+
50
63
  ```typescript
51
- import { DataScope } from '@object-ui/core'
64
+ import { DataScopeManager, evaluateExpression } from '@object-ui/core'
52
65
 
53
- const scope = new DataScope({
54
- user: { name: 'John', role: 'admin' }
55
- })
66
+ const manager = new DataScopeManager()
67
+ manager.registerScope('user', { data: { name: 'John', role: 'admin' } })
56
68
 
57
- const userName = scope.get('user.name') // 'John'
58
- const isAdmin = scope.evaluate('${user.role === "admin"}') // true
69
+ const userName = manager.getScope('user')?.data.name // 'John'
70
+ const isAdmin = evaluateExpression('${user.role === "admin"}', {
71
+ user: { name: 'John', role: 'admin' },
72
+ }) // true
59
73
  ```
60
74
 
61
75
  ### Server Action Dispatch (`createServerActionHandler`)
@@ -87,16 +101,16 @@ action identity (ADR-0110), the record-id resolution dance (`_rowRecord`,
87
101
  guard, and the `/actions` response-envelope rule (`interpretActionResponse` /
88
102
  `readActionPayload`, also exported).
89
103
 
90
- ### System Views (`defineView`)
104
+ ### System Views (`defineSystemView`)
91
105
 
92
106
  Schemas authored in source code are part of the product contract and must
93
- not be mutated at runtime. Wrap them with `defineView()` to deep-freeze the
94
- graph and tag it as a *System View*.
107
+ not be mutated at runtime. Wrap them with `defineSystemView()` to deep-freeze
108
+ the graph and tag it as a *System View*.
95
109
 
96
110
  ```typescript
97
- import { defineView, cloneAsOverride, isSystemView } from '@object-ui/core'
111
+ import { defineSystemView, cloneAsOverride, isSystemView } from '@object-ui/core'
98
112
 
99
- export const userListView = defineView({
113
+ export const userListView = defineSystemView({
100
114
  type: 'list',
101
115
  data: { object: 'User' },
102
116
  columns: [{ name: 'email' }],
@@ -115,7 +129,7 @@ isSystemView(draft) // false — clone is no longer Sys
115
129
 
116
130
  | Tier | Source | Mutable? | API |
117
131
  | ----------- | --------------------- | -------- | --------------------------- |
118
- | System View | code (`import` / `as const`) | ❌ frozen | `defineView()` |
132
+ | System View | code (`import` / `as const`) | ❌ frozen | `defineSystemView()` |
119
133
  | Tenant View | backend / DB | ⚠️ admin only | `cloneAsOverride()` + persist |
120
134
  | User View | localStorage / API | ✅ user-editable | `cloneAsOverride()` + persist |
121
135
 
@@ -14,7 +14,7 @@
14
14
  * Replaces callback-based patterns with a schema-driven pipeline:
15
15
  * ActionSchema[] → ActionEngine → ActionRunner → ActionResult
16
16
  */
17
- import { ActionRunner, type ActionDef, type ActionContext, type ActionResult } from './ActionRunner';
17
+ import { ActionRunner, type ActionDef, type ActionContext, type ActionResult } from './ActionRunner.js';
18
18
  /**
19
19
  * Action location types — re-exported from `@objectstack/spec/ui` so the
20
20
  * platform has one source of truth (`ACTION_LOCATIONS`). Do NOT redeclare
@@ -14,7 +14,7 @@
14
14
  * Replaces callback-based patterns with a schema-driven pipeline:
15
15
  * ActionSchema[] → ActionEngine → ActionRunner → ActionResult
16
16
  */
17
- import { ActionRunner } from './ActionRunner';
17
+ import { ActionRunner } from './ActionRunner.js';
18
18
  import { toPredicateInput } from '../evaluator/predicateInput.js';
19
19
  import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';
20
20
  export { ACTION_LOCATIONS } from '@object-ui/types';
@@ -38,7 +38,6 @@ function warnHiddenPredicate(name, raw, err) {
38
38
  return;
39
39
  _warnedPredicates.add(key);
40
40
  const msg = err instanceof Error ? err.message : String(err);
41
- // eslint-disable-next-line no-console
42
41
  console.warn(`[ActionEngine] action "${String(name)}" hidden: its \`visible\` predicate threw — ${msg}. ` +
43
42
  `Predicate: ${src}. Action predicates evaluate against { record, recordId, objectName, user, os.user, … }; ` +
44
43
  `use \`record.<field>\` (not a bare field).`);
@@ -21,8 +21,8 @@
21
21
  */
22
22
  import type { UIActionSchema } from '@object-ui/types';
23
23
  import type { Action as SpecActionInput } from '@objectstack/spec/ui';
24
- import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
25
- import { type UndoableOperation } from './UndoManager';
24
+ import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator.js';
25
+ import { type UndoableOperation } from './UndoManager.js';
26
26
  export interface ActionResult {
27
27
  success: boolean;
28
28
  data?: any;
@@ -134,15 +134,22 @@ export interface ActionDef {
134
134
  * it — the two TS2353s the deletion produced repo-wide were both this key.
135
135
  */
136
136
  description?: UIActionSchema['description'];
137
- /** Confirmation text — shows a confirm dialog before executing */
137
+ /**
138
+ * Confirmation text — shows a confirm dialog before executing. The ONE
139
+ * confirm spelling: `@objectstack/spec`'s action surface and the
140
+ * TranslationBundle key (`{ns}.objects.{obj}._actions.{name}.confirmText`)
141
+ * both stand on it.
142
+ */
138
143
  confirmText?: string;
139
- /** Structured confirmation (from crud.ts) */
140
- confirm?: {
141
- title?: string;
142
- message?: string;
143
- confirmText?: string;
144
- cancelText?: string;
145
- };
144
+ /**
145
+ * RETIRED (objectui#4314): the structured confirm object used to sit here
146
+ * and its `message` OUTRANKED `confirmText` — untranslated, since no bundle
147
+ * key exists for it. `?: never` so constructing an action with it is a tsc
148
+ * error rather than a silently-ignored key; the authorable twin
149
+ * (`@object-ui/types` `crud.ts`) carries the same tombstone.
150
+ * @deprecated Retired — author `confirmText` instead.
151
+ */
152
+ confirm?: never;
146
153
  /**
147
154
  * Condition predicate — the action executes only while it holds.
148
155
  *
@@ -382,6 +389,29 @@ export type ActionRunnerHandler = (action: ActionDef, context: ActionContext) =>
382
389
  /**
383
390
  * Confirmation handler — replaces window.confirm.
384
391
  * Consumers can provide an async implementation (e.g., Shadcn AlertDialog).
392
+ *
393
+ * `options` is LIVE, and its producer is not in this file — searching the
394
+ * runner alone will tell you otherwise. The only call site here passes one
395
+ * argument (the structured `confirm` arm that used to forward a bag was
396
+ * retired, objectui#4314), so a runner-scoped sweep reads this parameter as
397
+ * declared-but-never-produced and files it for removal. That has already
398
+ * happened once: objectui#5205, ruled KEEP on 2026-08-22.
399
+ *
400
+ * The producer bypasses the runner entirely. `handleDeleteView` in
401
+ * `packages/app-shell/src/views/ObjectView.tsx` — the admin "delete saved
402
+ * view" confirmation — invokes a `ConfirmationHandler` with all three fields
403
+ * populated and localized (`console.objectView.deleteViewTitle` / `.delete` /
404
+ * `.cancel`), and `app-shell`'s `ActionConfirmDialog.tsx` renders them in
405
+ * place of its generic `actionConfirm.*` fallbacks. Line numbers are left out
406
+ * on purpose: #5205's pointer had already drifted by the time it was executed.
407
+ *
408
+ * So re-measure across the repo, not this package: grep for two-argument
409
+ * calls of `ConfirmationHandler`-typed values (`confirmHandler(`, `onConfirm(`)
410
+ * in `app-shell` as well as here. Dropping `options` costs that dialog its
411
+ * title and both button labels, and narrows a published `.d.ts` against the
412
+ * natural `(message, options) => …` spelling — which TypeScript rejects
413
+ * against a one-parameter target unless the second parameter is written
414
+ * optional.
385
415
  */
386
416
  export type ConfirmationHandler = (message: string, options?: {
387
417
  title?: string;
@@ -604,6 +634,30 @@ export declare class ActionRunner {
604
634
  * Post-execution: emit toast notifications, handle chaining, callbacks.
605
635
  */
606
636
  private handlePostExecution;
637
+ /**
638
+ * Perform the `ActionSchema.onSuccess` hop for an action that just succeeded.
639
+ *
640
+ * **The router is the app's own.** `navigationHandler` is the seam every
641
+ * navigator in this file already goes through, and the console wires it to
642
+ * react-router's `navigate` — so `openIn: 'self'` is a real SPA route hop,
643
+ * in place, immune to popup blocking. No `window.location` / `window.open`
644
+ * mechanism is introduced here; the no-handler fallback is the same
645
+ * `result.redirect` hand-off `navigateTo` already uses.
646
+ *
647
+ * **No default is written here.** `openIn` is a materialised
648
+ * `.default('self')` in the spec, so parse output always carries a resolved
649
+ * member; this reads the ONE member that changes the branch and leaves the
650
+ * other implicit. A `?? 'self'` would be a second source of truth for a
651
+ * default the producer already resolved.
652
+ *
653
+ * **The two `openIn` spellings never cross.** This reads
654
+ * `onSuccess.openIn` (`'self' | 'newTab'`) and nothing else — never the
655
+ * top-level `type: 'url'` switch, which spells its new-tab member
656
+ * `'new-tab'`. Spec refuses each crossover spelling with a keyed error; a
657
+ * renderer that accepted either would be a second, looser contract than the
658
+ * one authors are validated against.
659
+ */
660
+ private navigateOnSuccess;
607
661
  /**
608
662
  * Execute script action — evaluates client-side expression via ExpressionEvaluator.
609
663
  * Supports ${} template expressions referencing data, record, user context.
@@ -787,3 +841,21 @@ export declare class ActionRunner {
787
841
  * Convenience function to execute a single action with a one-off runner.
788
842
  */
789
843
  export declare function executeAction(action: ActionDef, context?: ActionContext): Promise<ActionResult>;
844
+ /** The `ActionSchema.onSuccess` block, as the pinned spec declares it. */
845
+ export interface OnSuccessNavigation {
846
+ navigate: string;
847
+ /** `'self' | 'newTab'` — read as data, so an off-contract value cannot widen the branch. */
848
+ openIn?: unknown;
849
+ }
850
+ /**
851
+ * Is this `onSuccess` the SPEC's navigation block, or this runner's older
852
+ * chained-callback channel (`ActionDef | ActionDef[]`)?
853
+ *
854
+ * The test IS the spec's declaration: a non-array object carrying a STRING
855
+ * `navigate`. Nothing else can produce that shape — the spec object is strict
856
+ * with `navigate: z.string()` required, and on a callback `ActionDef`,
857
+ * `navigate` is the deprecated nested navigation ENVELOPE that
858
+ * `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
859
+ * there has never been runnable.
860
+ */
861
+ export declare function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | null;
@@ -5,10 +5,11 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
9
- import { hasDeclaredPredicate } from '../evaluator/declaredPredicate';
10
- import { globalUndoManager } from './UndoManager';
11
- import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys';
8
+ import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator.js';
9
+ import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';
10
+ import { globalUndoManager } from './UndoManager.js';
11
+ import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys.js';
12
+ import { readActionPayload } from './actionResponse.js';
12
13
  /**
13
14
  * Bind the server-CEL-parity `os.user` identity alias into an action context
14
15
  * (#2358 trap 1). `@objectstack/spec` documents the canonical CEL identity
@@ -389,14 +390,13 @@ export class ActionRunner {
389
390
  return { success: false, error: 'Action is disabled' };
390
391
  }
391
392
  }
392
- // Confirmation (structured or simple)
393
- const confirmMessage = action.confirm?.message || action.confirmText;
394
- if (confirmMessage) {
395
- const confirmed = await this.confirmHandler(this.evaluator.evaluate(confirmMessage), action.confirm ? {
396
- title: action.confirm.title,
397
- confirmText: action.confirm.confirmText,
398
- cancelText: action.confirm.cancelText,
399
- } : undefined);
393
+ // Confirmation. One spelling: `confirmText` — the only one the
394
+ // translation bundle can address (`…._actions.{name}.confirmText`). The
395
+ // structured `confirm.message` read that used to OUTRANK it is retired
396
+ // (objectui#4314): it had zero producers and no bundle key, so a dialog
397
+ // authored that way silently lost localization.
398
+ if (action.confirmText) {
399
+ const confirmed = await this.confirmHandler(this.evaluator.evaluate(action.confirmText));
400
400
  if (!confirmed) {
401
401
  return { success: false, error: 'Action cancelled by user' };
402
402
  }
@@ -558,12 +558,10 @@ export class ActionRunner {
558
558
  catch (err) {
559
559
  // Acknowledgement failure is non-fatal; the underlying action already
560
560
  // succeeded. Log so consumers can wire it through their error reporter.
561
- // eslint-disable-next-line no-console
562
561
  console.warn('[ActionRunner] resultDialog handler rejected; treating as acknowledged', err);
563
562
  }
564
563
  }
565
564
  else {
566
- // eslint-disable-next-line no-console
567
565
  console.warn('[ActionRunner] action.resultDialog set but no resultDialogHandler registered — the response value will not be shown to the user.', { action: action.name, data: result.data });
568
566
  }
569
567
  }
@@ -586,16 +584,94 @@ export class ActionRunner {
586
584
  if (chainResult.reload)
587
585
  result.reload = true;
588
586
  }
589
- // Execute onSuccess/onFailure callbacks
587
+ // ── ActionSchema.onSuccess — post-success navigation ────────────────────
588
+ //
589
+ // objectui#5221, the console half of objectstack#9566/#9474. The spec
590
+ // declares `onSuccess` as a CLOSED STRICT object
591
+ // `{ navigate: string, openIn: 'self' | 'newTab' }`, refine-scoped to
592
+ // `type: 'api'` and `type: 'script'` — the two types whose success event
593
+ // carries a server response for `${result.*}` to read.
594
+ //
595
+ // This runner's OWN `ActionDef.onSuccess` predates that key and means
596
+ // something else entirely: `ActionDef | ActionDef[]`, chained callbacks.
597
+ // The two are told apart by the spec's own declaration — a non-array object
598
+ // whose `navigate` is a STRING is the spec block and nothing else can be:
599
+ // `navigate` on a callback ActionDef is the deprecated nested navigation
600
+ // ENVELOPE (`executeNavigation` reads `navigate.to`), so a string there has
601
+ // never been runnable. This is a NARROWING to the declared contract, not a
602
+ // lenient fallback: a shape the spec refuses gets no new reading here.
603
+ //
604
+ // Before this branch existed, the ruled shape fell into the callback path,
605
+ // dispatched `{ navigate: '<string>' }` as an action, and failed inside
606
+ // `executeNavigation` with "No URL provided for navigation action" — the
607
+ // author got a red toast and no hop.
590
608
  if (result.success && action.onSuccess) {
591
- const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
592
- await this.executeChain(callbacks, 'sequential');
609
+ const navigation = readOnSuccessNavigation(action.onSuccess);
610
+ if (navigation) {
611
+ this.navigateOnSuccess(navigation, action, result);
612
+ }
613
+ else {
614
+ const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
615
+ await this.executeChain(callbacks, 'sequential');
616
+ }
593
617
  }
594
618
  if (!result.success && action.onFailure) {
595
619
  const callbacks = Array.isArray(action.onFailure) ? action.onFailure : [action.onFailure];
596
620
  await this.executeChain(callbacks, 'sequential');
597
621
  }
598
622
  }
623
+ /**
624
+ * Perform the `ActionSchema.onSuccess` hop for an action that just succeeded.
625
+ *
626
+ * **The router is the app's own.** `navigationHandler` is the seam every
627
+ * navigator in this file already goes through, and the console wires it to
628
+ * react-router's `navigate` — so `openIn: 'self'` is a real SPA route hop,
629
+ * in place, immune to popup blocking. No `window.location` / `window.open`
630
+ * mechanism is introduced here; the no-handler fallback is the same
631
+ * `result.redirect` hand-off `navigateTo` already uses.
632
+ *
633
+ * **No default is written here.** `openIn` is a materialised
634
+ * `.default('self')` in the spec, so parse output always carries a resolved
635
+ * member; this reads the ONE member that changes the branch and leaves the
636
+ * other implicit. A `?? 'self'` would be a second source of truth for a
637
+ * default the producer already resolved.
638
+ *
639
+ * **The two `openIn` spellings never cross.** This reads
640
+ * `onSuccess.openIn` (`'self' | 'newTab'`) and nothing else — never the
641
+ * top-level `type: 'url'` switch, which spells its new-tab member
642
+ * `'new-tab'`. Spec refuses each crossover spelling with a keyed error; a
643
+ * renderer that accepted either would be a second, looser contract than the
644
+ * one authors are validated against.
645
+ */
646
+ navigateOnSuccess(block, action, result) {
647
+ // `${result.*}` reads the HANDLER's return value, one level below the
648
+ // action envelope — the same level `readActionPayload` hands the
649
+ // `redirectUrl` convention. Reading `result.data` raw would see the
650
+ // `{ success, data }` envelope on pre-objectstack#3962 servers, which is
651
+ // the "one level too shallow" bug (#2904) in a new place.
652
+ const url = this.interpolateTarget(block.navigate, action, readActionPayload(result.data));
653
+ // `navigate` is author-supplied metadata and lands in a URL position, so
654
+ // it goes through the same scheme guard as every other navigator here.
655
+ if (!this.isValidUrl(url)) {
656
+ console.warn('[ActionRunner] onSuccess.navigate resolved to a URL this runner refuses to open '
657
+ + '(only http://, https:// and relative URLs are allowed) — no navigation performed.', { action: action.name, navigate: block.navigate, resolved: url });
658
+ return;
659
+ }
660
+ const newTab = block.openIn === 'newTab';
661
+ // Same external-URL convention `navigateTo` hands the handler; `newTab`
662
+ // stays the declared choice alone, never `?? isExternal`.
663
+ const external = url.startsWith('http://') || url.startsWith('https://');
664
+ if (this.navigationHandler) {
665
+ this.navigationHandler(url, { external, newTab });
666
+ return;
667
+ }
668
+ if (newTab) {
669
+ window.open(url, '_blank', 'noopener,noreferrer');
670
+ }
671
+ else {
672
+ result.redirect = url;
673
+ }
674
+ }
599
675
  /**
600
676
  * Execute script action — evaluates client-side expression via ExpressionEvaluator.
601
677
  * Supports ${} template expressions referencing data, record, user context.
@@ -1098,7 +1174,7 @@ export class ActionRunner {
1098
1174
  * param. Consumers can extend by stuffing extra keys under
1099
1175
  * `context.ctx = {...}` before calling `runner.execute()`.
1100
1176
  */
1101
- interpolateTarget(target, action) {
1177
+ interpolateTarget(target, action, resultScope) {
1102
1178
  if (typeof target !== 'string' || target.indexOf('${') === -1)
1103
1179
  return target;
1104
1180
  // ── The `${param.X}` scope is an INTERNAL runtime value bag, not an
@@ -1135,10 +1211,21 @@ export class ActionRunner {
1135
1211
  const params = (action.params && typeof action.params === 'object' && !Array.isArray(action.params))
1136
1212
  ? action.params
1137
1213
  : {};
1138
- const ctx = this.buildInterpolationContext();
1139
- return target.replace(/\$\{(param|ctx)\.([\w.]+)\}/g, (_match, scope, path) => {
1140
- const root = scope === 'param' ? params : ctx;
1141
- const value = readPath(root, path);
1214
+ // The scope MAP defines the vocabulary — the pattern is built from its
1215
+ // keys, so a scope that is not in play is not a scope this call recognizes.
1216
+ //
1217
+ // `result` (the handler's own return value) is passed by exactly one caller:
1218
+ // the `ActionSchema.onSuccess` post-success hop, where a response exists.
1219
+ // Every other caller — `executeUrl`, `executeAPI` — interpolates a target
1220
+ // BEFORE the request, so there is no result to read; admitting `${result.*}`
1221
+ // there would widen the authorable surface with nothing behind it and
1222
+ // silently blank the token instead of leaving the author's mistake visible.
1223
+ const scopes = { param: params, ctx: this.buildInterpolationContext() };
1224
+ if (resultScope !== undefined)
1225
+ scopes.result = resultScope;
1226
+ const pattern = new RegExp(`\\$\\{(${Object.keys(scopes).join('|')})\\.([\\w.]+)\\}`, 'g');
1227
+ return target.replace(pattern, (_match, scope, path) => {
1228
+ const value = readPath(scopes[scope], path);
1142
1229
  if (value == null)
1143
1230
  return '';
1144
1231
  return encodeURIComponent(String(value));
@@ -1207,6 +1294,25 @@ export async function executeAction(action, context = {}) {
1207
1294
  const runner = new ActionRunner(context);
1208
1295
  return await runner.execute(action);
1209
1296
  }
1297
+ /**
1298
+ * Is this `onSuccess` the SPEC's navigation block, or this runner's older
1299
+ * chained-callback channel (`ActionDef | ActionDef[]`)?
1300
+ *
1301
+ * The test IS the spec's declaration: a non-array object carrying a STRING
1302
+ * `navigate`. Nothing else can produce that shape — the spec object is strict
1303
+ * with `navigate: z.string()` required, and on a callback `ActionDef`,
1304
+ * `navigate` is the deprecated nested navigation ENVELOPE that
1305
+ * `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
1306
+ * there has never been runnable.
1307
+ */
1308
+ export function readOnSuccessNavigation(value) {
1309
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1310
+ return null;
1311
+ const navigate = value.navigate;
1312
+ if (typeof navigate !== 'string' || navigate === '')
1313
+ return null;
1314
+ return value;
1315
+ }
1210
1316
  /**
1211
1317
  * Dot-path read used by target interpolation. Plain reduce — kept inline
1212
1318
  * to avoid pulling lodash for a 3-line helper.
@@ -106,9 +106,10 @@
106
106
  /**
107
107
  * Every property `ActionDef` declares, in declaration order.
108
108
  *
109
- * Kept as data because TypeScript types do not survive to runtime — and
110
- * `keyof ActionDef` cannot substitute for this list: the index signature widens
111
- * it to `string | number`, which is precisely the problem being worked around.
109
+ * Kept as data because TypeScript types do not survive to runtime — `keyof
110
+ * ActionDef` is erased along with the interface, so it cannot hand a runtime
111
+ * classifier a list of anything. `actionKeys.pin.test.ts` re-derives this one
112
+ * from the interface's AST, which is what keeps the data true.
112
113
  */
113
114
  export declare const ACTION_DEF_KEYS: readonly ["type", "actionType", "name", "label", "description", "confirmText", "confirm", "condition", "disabled", "api", "endpoint", "method", "navigate", "to", "external", "newTab", "replace", "onClick", "reload", "close", "redirect", "toast", "successMessage", "errorMessage", "refreshAfter", "undoable", "params", "actionParams", "resultDialog", "target", "body", "openIn", "modal", "chain", "chainMode", "onSuccess", "onFailure", "opensInNewTab", "newTabUrl", "locations", "visible", "requiredPermissions", "icon", "variant", "order", "component", "objectName", "ai", "aria", "bodyExtra", "bodyShape", "mode", "recordIdField", "recordIdParam", "requiresFeature", "shortcut", "bulkEnabled"];
114
115
  /**
@@ -125,7 +126,7 @@ export declare const ACTION_DEF_KEYS: readonly ["type", "actionType", "name", "l
125
126
  * test and wrong in shipped code — `@object-ui/types` made the same call for
126
127
  * `ActionComponent`. The pin test does the walk and fails the day this drifts.
127
128
  */
128
- export declare const SPEC_ACTION_KEYS: readonly ["_lock", "_lockDocsUrl", "_lockReason", "_lockSource", "_packageId", "_packageVersion", "_provenance", "ai", "aria", "body", "bodyExtra", "bodyShape", "bulkEnabled", "component", "confirmText", "description", "disabled", "errorMessage", "execute", "icon", "label", "locations", "method", "mode", "name", "newTabUrl", "objectName", "openIn", "opensInNewTab", "order", "params", "recordIdField", "recordIdParam", "refreshAfter", "requiredPermissions", "requiresFeature", "resultDialog", "shortcut", "successMessage", "target", "type", "undoable", "variant", "visible"];
129
+ export declare const SPEC_ACTION_KEYS: readonly ["_lock", "_lockDocsUrl", "_lockReason", "_lockSource", "_packageId", "_packageVersion", "_provenance", "ai", "aria", "body", "bodyExtra", "bodyShape", "bulkEnabled", "component", "confirmText", "description", "disabled", "errorMessage", "execute", "icon", "label", "locations", "method", "mode", "name", "newTabUrl", "objectName", "onSuccess", "openIn", "opensInNewTab", "order", "params", "recordIdField", "recordIdParam", "refreshAfter", "requiredPermissions", "requiresFeature", "resultDialog", "shortcut", "successMessage", "target", "type", "undoable", "variant", "visible"];
129
130
  /**
130
131
  * The `navigation` alias's own spelling of a target, read off the action itself
131
132
  * when no nested `navigate` object is present (`ActionRunner.executeNavigation`).
@@ -137,6 +138,54 @@ export declare const SPEC_ACTION_KEYS: readonly ["_lock", "_lockDocsUrl", "_lock
137
138
  * runner itself supports.
138
139
  */
139
140
  export declare const NAVIGATION_ALIAS_KEYS: readonly ["to", "external", "newTab", "replace"];
141
+ /**
142
+ * Keys a HOST composes on the DISPATCH OBJECT at dispatch time — chrome the
143
+ * runtime reads once on its way to the dialog, that no author ever writes and
144
+ * no store ever holds.
145
+ *
146
+ * Distinct from every other list in this module, and the distinction is the
147
+ * whole point: each of the three lists above restates one AUTHORED surface —
148
+ * what `ActionDef` itself declares, what the spec's `ActionSchema` declares,
149
+ * and the `navigation` alias's objectui dialect — so a key joining one of them
150
+ * is a key an author may legally write. This list claims no such derivation and
151
+ * must not acquire one: a key here is the opposite, and writing it in metadata
152
+ * is still a compile error that must stay one. Maintainer ruling
153
+ * 2026-08-22 fixes both halves of that: ⛔ the key is NOT declared on
154
+ * `ActionDef` and ⛔ NOT added to {@link ACTION_DEF_KEYS}; it is declared at the
155
+ * seam, on `packages/app-shell/src/consoleActionDispatch.ts`'s
156
+ * `ConsoleActionDispatch`, and appears HERE only so the dev-mode warning below
157
+ * stops calling it unknown.
158
+ *
159
+ * Why the warning has to know: `classifyActionKeys` sees the object the runner
160
+ * was handed, which is the DISPATCH, not the stored metadata row. With the key
161
+ * absent from the inventory, `warnOnUnknownActionKeys` told the author that a
162
+ * key TWO files read is one "no reader recognizes", and prescribed promoting it
163
+ * to `ActionDef` — the one shape the ruling forbids for it. A diagnostic that
164
+ * fires on the product's own privileged path is how a console gets muted.
165
+ *
166
+ * {@link HOST_STASHED_PARAM_KEYS} below is the same category one level down —
167
+ * host-composed keys inside `params` rather than on the action itself — and
168
+ * cites `DeclaredActionsBar` by name for the same reason this does. Two lists
169
+ * because the two warnings ask different questions of different objects, not
170
+ * because the categories differ.
171
+ *
172
+ * `overrideNotice` — the privileged-override safety copy (objectui#5178),
173
+ * naming the approvers about to be bypassed.
174
+ * - producer: `packages/app-shell/src/views/DeclaredActionsBar.tsx`, on the
175
+ * `can_act:false && can_override:true` branch.
176
+ * - readers: `packages/app-shell/src/hooks/useConsoleActionRuntime.tsx` and
177
+ * `packages/app-shell/src/views/RecordDetailView.tsx`, the console's two
178
+ * param-collection handlers, which render it ahead of the declared
179
+ * description in the dialog's subtitle.
180
+ *
181
+ * Anything added here must satisfy all three of: composed by a host in code,
182
+ * never read back out of stored metadata, and read by the runtime. A key an
183
+ * AUTHOR is meant to write belongs on `ActionDef` — or, when the spec owns it,
184
+ * in `@objectstack/spec` first — and not in this list. `actionKeys.pin.test.ts`
185
+ * pins the contents exactly, so growing the set is a deliberate, reviewed edit
186
+ * rather than a quiet one.
187
+ */
188
+ export declare const HOST_DISPATCH_ACTION_KEYS: readonly ["overrideNotice"];
140
189
  /**
141
190
  * Keys the spec has TOMBSTONED: still present in `ActionSchema` so the parser can
142
191
  * reject them BY NAME with a rename prescription, rather than fail with a bare
@@ -156,7 +205,16 @@ export declare const NAVIGATION_ALIAS_KEYS: readonly ["to", "external", "newTab"
156
205
  * a tombstone the spec keeps only in order to reject it by name.
157
206
  */
158
207
  export declare const RETIRED_ACTION_KEYS: Readonly<Record<string, string>>;
159
- /** Every key an action may legitimately carry today. */
208
+ /**
209
+ * Every key an action may legitimately carry today — authored or host-composed.
210
+ *
211
+ * Four inputs, three of them mirrors of an authored surface and the fourth
212
+ * ({@link HOST_DISPATCH_ACTION_KEYS}) the host-composed dispatch chrome. The
213
+ * union is what `classifyActionKeys` consults, and it is deliberately wider
214
+ * than the authored surface: the runner classifies the object it was HANDED,
215
+ * and a host hands it a dispatch. Membership here grants nothing to an author —
216
+ * `ActionDef` is what decides that, and it is closed.
217
+ */
160
218
  export declare const KNOWN_ACTION_KEYS: ReadonlySet<string>;
161
219
  /** Split an action's own keys into the two things worth saying out loud. */
162
220
  export declare function classifyActionKeys(action: object | null | undefined): {
@@ -169,9 +227,11 @@ export declare function resetActionKeyWarnings(): void;
169
227
  * Dev-mode only: name the keys an action carries that no reader will ever look
170
228
  * at. Non-breaking by construction — it changes no types and rejects nothing.
171
229
  *
172
- * Silently binding no handler is the #2169 "Mark Done does nothing" shape, and
173
- * the compiler cannot help while the index signature stands. Until it comes
174
- * down, this is the audible half.
230
+ * Silently binding no handler is the #2169 "Mark Done does nothing" shape. The
231
+ * compiler now catches it in action literals AUTHORED IN CODE — objectstack#4075
232
+ * step 3 closed `ActionDef` — but not in the actions that arrive as DATA, which
233
+ * reach the runner unparsed and which no compiler ever looked at. This is the
234
+ * audible half for that population; see this module's header.
175
235
  */
176
236
  export declare function warnOnUnknownActionKeys(action: object | null | undefined, where?: string): void;
177
237
  /**