@object-ui/core 17.1.0 → 17.3.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 (66) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/README.md +29 -0
  3. package/dist/actions/ActionEngine.d.ts +22 -7
  4. package/dist/actions/ActionEngine.js +40 -22
  5. package/dist/actions/ActionRunner.d.ts +133 -13
  6. package/dist/actions/ActionRunner.js +27 -4
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +29 -12
  10. package/dist/actions/actionKeys.js +61 -10
  11. package/dist/actions/actionResponse.d.ts +42 -0
  12. package/dist/actions/actionResponse.js +104 -0
  13. package/dist/actions/index.d.ts +3 -0
  14. package/dist/actions/index.js +3 -0
  15. package/dist/actions/serverActionHandler.d.ts +143 -0
  16. package/dist/actions/serverActionHandler.js +137 -0
  17. package/dist/evaluator/ExpressionEvaluator.js +4 -2
  18. package/dist/evaluator/fieldRules.d.ts +26 -2
  19. package/dist/evaluator/fieldRules.js +63 -8
  20. package/dist/evaluator/index.d.ts +1 -0
  21. package/dist/evaluator/index.js +1 -0
  22. package/dist/evaluator/listConditional.d.ts +13 -1
  23. package/dist/evaluator/listConditional.js +0 -0
  24. package/dist/evaluator/optionRules.js +5 -1
  25. package/dist/evaluator/predicateInput.d.ts +84 -0
  26. package/dist/evaluator/predicateInput.js +72 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +12 -0
  29. package/dist/protocols/index.d.ts +0 -1
  30. package/dist/protocols/index.js +8 -1
  31. package/dist/query/query-ast.d.ts +6 -6
  32. package/dist/query/query-ast.js +2 -2
  33. package/dist/registry/PluginSystem.d.ts +15 -5
  34. package/dist/registry/PluginSystem.js +1 -1
  35. package/dist/registry/WidgetRegistry.d.ts +7 -7
  36. package/dist/registry/WidgetRegistry.js +1 -1
  37. package/dist/styling/scoped-styles.d.ts +14 -8
  38. package/dist/utils/chart-series.d.ts +14 -2
  39. package/dist/utils/dashboard-filters.d.ts +9 -0
  40. package/dist/utils/dashboard-filters.js +99 -4
  41. package/dist/utils/dataset-format.d.ts +18 -1
  42. package/dist/utils/dataset-format.js +10 -2
  43. package/dist/utils/dataset-pivot.d.ts +57 -0
  44. package/dist/utils/dataset-pivot.js +57 -0
  45. package/dist/utils/filter-converter.d.ts +39 -11
  46. package/dist/utils/filter-converter.js +87 -7
  47. package/dist/utils/filter-tokens.d.ts +13 -6
  48. package/dist/utils/filter-tokens.js +13 -6
  49. package/dist/utils/freeze-schema.d.ts +14 -6
  50. package/dist/utils/freeze-schema.js +14 -6
  51. package/dist/utils/managedBy.d.ts +78 -50
  52. package/dist/utils/managedBy.js +75 -52
  53. package/dist/utils/normalize-list-view.d.ts +12 -2
  54. package/dist/utils/predicate-fields.d.ts +96 -0
  55. package/dist/utils/predicate-fields.js +163 -0
  56. package/dist/utils/predicate-record.d.ts +32 -0
  57. package/dist/utils/predicate-record.js +120 -0
  58. package/dist/utils/record-title.js +0 -0
  59. package/dist/validation/index.d.ts +1 -0
  60. package/dist/validation/index.js +1 -0
  61. package/dist/validation/required-presence.d.ts +31 -0
  62. package/dist/validation/required-presence.js +36 -0
  63. package/dist/validation/schema-validator.d.ts +24 -8
  64. package/package.json +4 -4
  65. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  66. package/dist/protocols/NotificationProtocol.js +0 -89
@@ -32,25 +32,42 @@
32
32
  * interface it mirrors would be the same "declared ≠ enforced" trap this whole
33
33
  * thread is about.
34
34
  *
35
- * ── What the inventory found, i.e. step 2's worklist ─────────────────────────
36
- * `ActionDef` declares 34 keys, the spec's `ActionSchema` declares 36, and they
37
- * overlap on 17. The two halves of the difference are different problems:
35
+ * ── What the inventory found, and what step 2 did with it ────────────────────
36
+ * Step 1 found `ActionDef` declaring 34 keys against the spec `ActionSchema`'s
37
+ * 36, overlapping on 17. The two halves of the difference were different
38
+ * problems, and step 2 (objectstack#4075) treated them differently:
38
39
  *
39
40
  * 18 keys the SPEC owns that `ActionDef` never declared — `ai`, `aria`,
40
41
  * `bodyExtra`, `bodyShape`, `bulkEnabled`, `component`, `icon`, `locations`,
41
42
  * `mode`, `objectName`, `order`, `recordIdField`, `recordIdParam`,
42
43
  * `requiredPermissions`, `requiresFeature`, `shortcut`, `variant`, `visible`.
43
- * These are authored today and reach readers through the index signature.
44
- * `ActionEngine` reads two of them (`visible` at the location filter, `locations`
45
- * at registration) through an `as any` cast — a cast that exists ONLY because the
46
- * field is undeclared, and that goes away when step 2 promotes them.
44
+ * These are authored today and used to reach readers through the index
45
+ * signature. **Step 2 promoted all 18 to explicit optional fields**, typed by
46
+ * DERIVATION from `@objectstack/spec`'s `ActionInput` rather than by hand-copied
47
+ * shapes. The `as any` casts `ActionEngine` needed to read `visible`,
48
+ * `locations` and `requiredPermissions` are gone with them — those casts existed
49
+ * only because the fields were undeclared.
50
+ *
51
+ * Two of the 18 landed as `undefined` rather than as usable types, which is the
52
+ * correct outcome: spec 17 retired `shortcut` and `bulkEnabled` as `retiredKey()`
53
+ * tombstones (`z.never()`), so deriving turns "authoring this is a parse
54
+ * rejection" into a compile error at no cost. Hand-copying would have quietly
55
+ * re-legitimized two dead keys — which is why the types are derived.
47
56
  *
48
57
  * 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
49
58
  * `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
50
59
  * `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
51
- * `actionParams`. Several are already marked legacy at their declaration. These
52
- * are objectui's own dialect and need the #4115 treatment: kept and documented as
53
- * deliberate, or retired — but not silently carried.
60
+ * `actionParams`. Step 2 marked `@deprecated`, with the spec spelling to use
61
+ * instead, ONLY the four the runner itself proves are aliases: `actionType` (→
62
+ * `type`), `api` and `endpoint` (→ `target`; `executeAPI` resolves
63
+ * `api || endpoint || target`), and `navigate` (→ flat `target`/`openIn`;
64
+ * `executeNavigation` already falls back to them). The rest are runner mechanics
65
+ * with no spec counterpart — chaining, toasts, post-execution reload/close — and
66
+ * deprecating them toward a spelling that does not exist would be worse than
67
+ * leaving them declared.
68
+ *
69
+ * Step 3 remains: remove the index signature, at which point `tsc` catches both
70
+ * typos and retired keys and the dev-mode warning below can retire with it.
54
71
  */
55
72
  /**
56
73
  * Every property `ActionDef` declares, in declaration order.
@@ -94,6 +111,29 @@ export const ACTION_DEF_KEYS = [
94
111
  'onFailure',
95
112
  'opensInNewTab',
96
113
  'newTabUrl',
114
+ // Promoted out of the index signature by objectstack#4075 step 2 — the 18 keys
115
+ // the spec owns that `ActionDef` had never declared. Their types are derived
116
+ // from `@objectstack/spec`'s `ActionInput`, so this list is the only
117
+ // hand-maintained half, and `actionKeys.pin.test.ts` re-derives it from the
118
+ // interface's AST.
119
+ 'locations',
120
+ 'visible',
121
+ 'requiredPermissions',
122
+ 'icon',
123
+ 'variant',
124
+ 'order',
125
+ 'component',
126
+ 'objectName',
127
+ 'ai',
128
+ 'aria',
129
+ 'bodyExtra',
130
+ 'bodyShape',
131
+ 'mode',
132
+ 'recordIdField',
133
+ 'recordIdParam',
134
+ 'requiresFeature',
135
+ 'shortcut',
136
+ 'bulkEnabled',
97
137
  ];
98
138
  /**
99
139
  * Every property `@objectstack/spec`'s `ActionSchema` declares.
@@ -110,6 +150,17 @@ export const ACTION_DEF_KEYS = [
110
150
  * `ActionComponent`. The pin test does the walk and fails the day this drifts.
111
151
  */
112
152
  export const SPEC_ACTION_KEYS = [
153
+ // The package-lock / provenance envelope, added to `ActionSchema` in spec
154
+ // 17.0.0-rc.2. Not authorable action vocabulary — it is stamped on a record
155
+ // that came from an installed package — but it IS declared by the schema, so
156
+ // an action carrying it must not be reported as having unknown keys.
157
+ '_lock',
158
+ '_lockDocsUrl',
159
+ '_lockReason',
160
+ '_lockSource',
161
+ '_packageId',
162
+ '_packageVersion',
163
+ '_provenance',
113
164
  'ai',
114
165
  'aria',
115
166
  'body',
@@ -0,0 +1,42 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ export interface ActionResponseOutcome {
9
+ ok: boolean;
10
+ /** Human-readable failure text. Always a string when `ok` is false. */
11
+ error?: string;
12
+ /**
13
+ * The ACTION ENVELOPE (`body.data`) — `{ success, data }`. Kept as-is
14
+ * because `ActionResult.data` has always carried this level and
15
+ * `resultDialog` field paths in the wild may compensate for it; see the
16
+ * note in {@link readActionPayload}.
17
+ */
18
+ envelope?: unknown;
19
+ /** What the handler actually returned (`body.data.data`). */
20
+ payload?: unknown;
21
+ }
22
+ /**
23
+ * The handler's own return value — one level deeper than the action envelope.
24
+ *
25
+ * `body.data` is `{ success, data }`; the handler's value is `body.data.data`.
26
+ * Reading the shallower level is why an action returning `{ redirectUrl }` was
27
+ * silently ignored: `body.data.redirectUrl` is never set by anything, because
28
+ * that level is constructed by the server and only ever holds `success`/`data`.
29
+ */
30
+ export declare function readActionPayload(envelope: unknown): unknown;
31
+ /** The pre-objectstack#3962 inner envelope, detected narrowly. */
32
+ export declare function isLegacyActionEnvelope(v: unknown): boolean;
33
+ /**
34
+ * Classify an `/actions` response. `label` names the action for the fallback
35
+ * message (e.g. `Action "log_call" failed`).
36
+ *
37
+ * `json` is the parsed body, or `null` when it could not be parsed.
38
+ */
39
+ export declare function interpretActionResponse(res: {
40
+ ok: boolean;
41
+ status: number;
42
+ }, json: any, label: string): ActionResponseOutcome;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * The ONE place a `POST /api/v1/actions/...` response is interpreted.
10
+ *
11
+ * This existed twice — `useConsoleActionRuntime.serverActionHandler` and
12
+ * `RecordDetailView`'s copy of the same handler — and the two drifted, which is
13
+ * the whole reason objectstack#3913 was filed against the console: one copy
14
+ * learned to inspect the inner envelope and the other did not, so a failed
15
+ * action fired a green "completed" toast on every list/page surface. Two copies
16
+ * of a subtle envelope rule is a bug generator; this is the rule, once.
17
+ *
18
+ * Moved from `@object-ui/app-shell` (`utils/actionResponse`) into core by
19
+ * objectui#2904: `createServerActionHandler` — the dispatch every consumer
20
+ * registers as its `script` handler — lives here and must apply the rule, so
21
+ * the rule lives beside it.
22
+ *
23
+ * ## The legacy envelope was DOUBLE, and that was the trap (pre-objectstack#3962)
24
+ *
25
+ * ```
26
+ * { ← transport envelope
27
+ * success: true,
28
+ * data: { ← action envelope
29
+ * success: true,
30
+ * data: { … } ← what the handler actually returned
31
+ * }
32
+ * }
33
+ * ```
34
+ *
35
+ * Unlike `/api/v1/<anything-else>`, which wraps ONCE, `/actions` wraps twice —
36
+ * the route's own `{success, data}` sits inside the dispatcher's. Reading one
37
+ * level (the natural instinct, and what the other handlers correctly do) lands
38
+ * on the action envelope, not the payload.
39
+ *
40
+ * ## Failure has three shapes, and `res.ok` alone catches only one
41
+ *
42
+ * - **dispatch failure** — 404 no such action, 403 denied, 400 wrong action
43
+ * type, 503 unavailable, 500 the handler crashed. `res.ok` is false.
44
+ * - **business rejection** — the handler ran and said no. HTTP **200** with
45
+ * `data.success === false`. `res.ok` is TRUE; only the inner envelope shows
46
+ * it. This is the one that got missed.
47
+ * - **transport-level `success: false`** — the outer envelope reporting a
48
+ * failure directly.
49
+ */
50
+ import { actionErrorDetail } from './actionErrorDetail';
51
+ /**
52
+ * The handler's own return value — one level deeper than the action envelope.
53
+ *
54
+ * `body.data` is `{ success, data }`; the handler's value is `body.data.data`.
55
+ * Reading the shallower level is why an action returning `{ redirectUrl }` was
56
+ * silently ignored: `body.data.redirectUrl` is never set by anything, because
57
+ * that level is constructed by the server and only ever holds `success`/`data`.
58
+ */
59
+ export function readActionPayload(envelope) {
60
+ // objectstack#3962 servers single-wrap: `body.data` IS the handler value,
61
+ // so most of the time this is the identity function. Only the exact LEGACY
62
+ // action envelope (pre-#3962: a boolean `success` and no keys beyond the
63
+ // envelope's own) is unwrapped one more level — a handler value that
64
+ // merely contains a `success` key passes through untouched.
65
+ if (isLegacyActionEnvelope(envelope)) {
66
+ return envelope.data;
67
+ }
68
+ return envelope;
69
+ }
70
+ const LEGACY_ENVELOPE_KEYS = new Set(['success', 'data', 'error', 'code', 'fields']);
71
+ /** The pre-objectstack#3962 inner envelope, detected narrowly. */
72
+ export function isLegacyActionEnvelope(v) {
73
+ return !!v && typeof v === 'object' && !Array.isArray(v)
74
+ && typeof v.success === 'boolean'
75
+ && Object.keys(v).every((k) => LEGACY_ENVELOPE_KEYS.has(k));
76
+ }
77
+ /**
78
+ * Classify an `/actions` response. `label` names the action for the fallback
79
+ * message (e.g. `Action "log_call" failed`).
80
+ *
81
+ * `json` is the parsed body, or `null` when it could not be parsed.
82
+ */
83
+ export function interpretActionResponse(res, json, label) {
84
+ const envelope = json?.data;
85
+ // Pre-#3962 servers reported a rejection as HTTP 200 with the inner
86
+ // `{success:false, error}`; current servers answer a real status, which
87
+ // `res.ok` catches first. Detected narrowly so a handler value that merely
88
+ // contains `success: false` is not misread as a failure.
89
+ const innerFailed = isLegacyActionEnvelope(envelope) && envelope.success === false;
90
+ if (!res.ok || (json && json.success === false) || innerFailed) {
91
+ // A business rejection carries its message on the INNER envelope; a
92
+ // dispatch failure carries it on the outer one (as a nested
93
+ // `{message, code}` object, which `actionErrorDetail` resolves to a
94
+ // string — handing that object to `toast.error()` as a React child
95
+ // crashes the page, React #31).
96
+ return {
97
+ ok: false,
98
+ error: innerFailed
99
+ ? actionErrorDetail(envelope, `${label} failed`)
100
+ : actionErrorDetail(json, `${label} failed (HTTP ${res.status})`),
101
+ };
102
+ }
103
+ return { ok: true, envelope, payload: readActionPayload(envelope) };
104
+ }
@@ -11,3 +11,6 @@ export * from './actionKeys.js';
11
11
  export * from './TransactionManager.js';
12
12
  export * from './UndoManager.js';
13
13
  export * from './bulkFastPath.js';
14
+ export * from './actionErrorDetail.js';
15
+ export * from './actionResponse.js';
16
+ export * from './serverActionHandler.js';
@@ -11,3 +11,6 @@ export * from './actionKeys.js';
11
11
  export * from './TransactionManager.js';
12
12
  export * from './UndoManager.js';
13
13
  export * from './bulkFastPath.js';
14
+ export * from './actionErrorDetail.js';
15
+ export * from './actionResponse.js';
16
+ export * from './serverActionHandler.js';
@@ -0,0 +1,143 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * `createServerActionHandler` — the core server-action dispatcher factory
10
+ * (objectui#2904, the follow-up #2896 deferred).
11
+ *
12
+ * `ActionSchema.body` (HookBodySchema — L1 expression / L2 sandboxed JS) is the
13
+ * spec's preferred binding for script actions, and bodies execute SERVER-side:
14
+ * `POST /api/v1/actions/{object}/{action}` → the runtime sandbox. This client
15
+ * never interprets a body (L2 needs an isolated VM enforcing capabilities /
16
+ * `timeoutMs` / `memoryMb`, which a browser cannot provide; L1 is formula-engine
17
+ * CEL, a different dialect from this package's `${…}` evaluator). The client
18
+ * DISPATCHES; it does not interpret — see the `ActionDef.body` doc and #2896.
19
+ *
20
+ * Until this factory existed, only the console could dispatch: app-shell's
21
+ * `useConsoleActionRuntime` and `RecordDetailView` each hand-rolled the same
22
+ * POST (and drifted — objectstack#3913, framework#3935), while every other
23
+ * `@object-ui/core` consumer fell through to the built-in `executeScript` and
24
+ * hit a dead end. The factory is deliberately opinion-free about the three
25
+ * things core has no business deciding (#2904):
26
+ *
27
+ * - **auth** — inject `fetch` (an authenticated wrapper: Bearer, tenant,
28
+ * cookies — whatever the host's transport needs);
29
+ * - **base URL** — inject `baseUrl` (a string or a thunk; core inherits no
30
+ * bundler env convention like `VITE_SERVER_URL`);
31
+ * - **object scope** — inject `resolveObject` for the fallback when the action
32
+ * declares no `objectName` of its own.
33
+ *
34
+ * Everything protocol-shaped lives here, once: name-only action identity
35
+ * (ADR-0110 D1), the record-id resolution dance (`_rowRecord`, `recordIdField`,
36
+ * aggregate `_selectedIds`, toolbar-selection fallback with its guards), the
37
+ * re-entrancy guard, the POST body shape `{ recordId, params }`, and the
38
+ * `/actions` envelope rule (`interpretActionResponse`).
39
+ *
40
+ * Standalone usage:
41
+ *
42
+ * ```ts
43
+ * import { createServerActionHandler } from '@object-ui/core';
44
+ *
45
+ * const script = createServerActionHandler({
46
+ * fetch: myAuthenticatedFetch,
47
+ * baseUrl: 'https://api.example.com',
48
+ * resolveObject: () => currentObjectName,
49
+ * onRefresh: () => notifyDataChanged(),
50
+ * });
51
+ * // Register it — registered handlers beat the built-in executeScript:
52
+ * // <ActionProvider handlers={{ script }} … /> (or runner.registerHandler('script', script))
53
+ * ```
54
+ */
55
+ import type { ActionContext, ActionDef, ActionResult } from './ActionRunner';
56
+ /** The response surface the dispatcher needs — satisfied by a WHATWG `Response`. */
57
+ export interface ServerActionFetchResponse {
58
+ ok: boolean;
59
+ status: number;
60
+ json(): Promise<any>;
61
+ }
62
+ /**
63
+ * The injected transport. Core has no auth opinion: pass an authenticated
64
+ * wrapper (e.g. app-shell's `createAuthenticatedFetch()`), not the bare global
65
+ * `fetch`, unless your deployment really needs no credentials.
66
+ */
67
+ export type ServerActionFetch = (url: string, init: {
68
+ method: 'POST';
69
+ headers: Record<string, string>;
70
+ body: string;
71
+ }) => Promise<ServerActionFetchResponse>;
72
+ /**
73
+ * What a record-id resolver reports: a record id (absent for object-scoped
74
+ * dispatches), or a blocking error the handler returns without POSTing.
75
+ */
76
+ export interface ServerActionRecordIdResolution {
77
+ recordId?: unknown;
78
+ error?: string;
79
+ }
80
+ export type ServerActionRecordIdResolver = (action: ActionDef, context: ActionContext | undefined) => ServerActionRecordIdResolution;
81
+ /**
82
+ * A dispatch handler produced by {@link createServerActionHandler}. Assignable
83
+ * to `ActionRunnerHandler` — register it under `'script'` (and, where the host
84
+ * routes them server-side, other action types).
85
+ */
86
+ export type ServerActionHandler = (action: ActionDef, context?: ActionContext) => Promise<ActionResult>;
87
+ export interface ServerActionHandlerConfig {
88
+ /** Authenticated transport — see {@link ServerActionFetch}. */
89
+ fetch: ServerActionFetch;
90
+ /**
91
+ * Server origin prefix (`''`/omitted = same-origin). A thunk is read per
92
+ * dispatch, so hosts with late-bound configuration can inject one.
93
+ */
94
+ baseUrl?: string | (() => string);
95
+ /**
96
+ * Fallback object scope when the action declares no `objectName` — e.g. the
97
+ * current view's object. When neither yields a name the dispatch is `global`.
98
+ */
99
+ resolveObject?: (action: ActionDef, context: ActionContext | undefined) => string | undefined;
100
+ /**
101
+ * Replace the standard record-id dance ({@link resolveServerActionRecordId})
102
+ * wholesale — e.g. a record page resolves against ITS record, not a grid
103
+ * selection.
104
+ */
105
+ resolveRecordId?: ServerActionRecordIdResolver;
106
+ /**
107
+ * Invoked after a successful dispatch that requests a refresh
108
+ * (`refreshAfter !== false`) — wire your data invalidation here.
109
+ */
110
+ onRefresh?: () => void;
111
+ }
112
+ /**
113
+ * An action that also mounts on list rows (`list_item`) or record pages is
114
+ * designed to run against a single record. When such an action is launched
115
+ * from the list toolbar with nothing selected, there is no record context to
116
+ * resolve — block up front instead of triggering a run that fails at its
117
+ * first record-bound step (#2210: "Update requires an ID"). Actions declaring
118
+ * only object-level locations (e.g. `['list_toolbar']`) are left alone: they
119
+ * legitimately run without a record.
120
+ */
121
+ export declare function isRecordScopedAction(action: ActionDef): boolean;
122
+ /**
123
+ * The standard record-id resolution dance, shared by the dispatcher and by
124
+ * consumers that need the id BEFORE dispatching (e.g. app-shell's zero-roundtrip
125
+ * `newTabUrl` fast path). Pure — reads the action/context, mutates nothing.
126
+ *
127
+ * Order: explicit `params.recordId` → the stashed `_rowRecord`'s
128
+ * `recordIdField` (list_item invocations) → the grid's checkbox selection from
129
+ * the runner context (`selectedRecords`, list_toolbar invocations). An
130
+ * AGGREGATE bulk dispatch (objectui#3139) carries the whole selection as
131
+ * `params._selectedIds` — the server reads that array, not `recordId`, so the
132
+ * selection fallback must not touch it (resolving a recordId would mislabel
133
+ * the run as record-scoped, and the multi-select guard would block exactly the
134
+ * selection the dispatch exists to carry). Multi-select is ambiguous for a
135
+ * single-record action — block with a message; so is zero selection when the
136
+ * action is record-scoped (see {@link isRecordScopedAction}).
137
+ */
138
+ export declare function resolveServerActionRecordId(action: ActionDef, context: ActionContext | undefined): ServerActionRecordIdResolution;
139
+ /**
140
+ * Build a server-action dispatch handler. See the module doc for what the
141
+ * factory owns and what the config injects.
142
+ */
143
+ export declare function createServerActionHandler(config: ServerActionHandlerConfig): ServerActionHandler;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ import { interpretActionResponse } from './actionResponse';
9
+ /**
10
+ * An action that also mounts on list rows (`list_item`) or record pages is
11
+ * designed to run against a single record. When such an action is launched
12
+ * from the list toolbar with nothing selected, there is no record context to
13
+ * resolve — block up front instead of triggering a run that fails at its
14
+ * first record-bound step (#2210: "Update requires an ID"). Actions declaring
15
+ * only object-level locations (e.g. `['list_toolbar']`) are left alone: they
16
+ * legitimately run without a record.
17
+ */
18
+ export function isRecordScopedAction(action) {
19
+ const locations = action.locations;
20
+ if (!Array.isArray(locations))
21
+ return false;
22
+ return locations.some((l) => l === 'list_item' || l === 'record_header' || l === 'record_more' || l === 'record_section');
23
+ }
24
+ /**
25
+ * The standard record-id resolution dance, shared by the dispatcher and by
26
+ * consumers that need the id BEFORE dispatching (e.g. app-shell's zero-roundtrip
27
+ * `newTabUrl` fast path). Pure — reads the action/context, mutates nothing.
28
+ *
29
+ * Order: explicit `params.recordId` → the stashed `_rowRecord`'s
30
+ * `recordIdField` (list_item invocations) → the grid's checkbox selection from
31
+ * the runner context (`selectedRecords`, list_toolbar invocations). An
32
+ * AGGREGATE bulk dispatch (objectui#3139) carries the whole selection as
33
+ * `params._selectedIds` — the server reads that array, not `recordId`, so the
34
+ * selection fallback must not touch it (resolving a recordId would mislabel
35
+ * the run as record-scoped, and the multi-select guard would block exactly the
36
+ * selection the dispatch exists to carry). Multi-select is ambiguous for a
37
+ * single-record action — block with a message; so is zero selection when the
38
+ * action is record-scoped (see {@link isRecordScopedAction}).
39
+ */
40
+ export function resolveServerActionRecordId(action, context) {
41
+ const params = (action.params && !Array.isArray(action.params))
42
+ ? action.params
43
+ : {};
44
+ const rowRecord = params._rowRecord;
45
+ const recordIdField = action.recordIdField || 'id';
46
+ let recordId = params.recordId ?? rowRecord?.[recordIdField];
47
+ const isAggregateDispatch = Array.isArray(params._selectedIds);
48
+ if (recordId == null && !isAggregateDispatch) {
49
+ const selected = Array.isArray(context?.selectedRecords) ? context.selectedRecords : [];
50
+ if (selected.length === 1) {
51
+ recordId = selected[0]?.[recordIdField];
52
+ }
53
+ else if (selected.length > 1) {
54
+ // The runner's post-execution hook surfaces `error` as a toast.
55
+ return { error: 'This action runs on a single record — select exactly one row.' };
56
+ }
57
+ else if (isRecordScopedAction(action)) {
58
+ return { error: 'This action runs on a single record — select a row first.' };
59
+ }
60
+ }
61
+ return { recordId: recordId ?? undefined };
62
+ }
63
+ /**
64
+ * Build a server-action dispatch handler. See the module doc for what the
65
+ * factory owns and what the config injects.
66
+ */
67
+ export function createServerActionHandler(config) {
68
+ const { fetch: dispatchFetch, resolveObject, onRefresh } = config;
69
+ const resolveRecordId = config.resolveRecordId ?? resolveServerActionRecordId;
70
+ // Guards against double-firing a server action (slow SSO handoff, etc.).
71
+ // Handler-instance state: keep the produced handler stable for its guard to
72
+ // span invocations.
73
+ const inFlight = new Set();
74
+ return async function serverActionDispatch(action, context) {
75
+ // [ADR-0110 D1] The URL identifies the action by its declarative `name`,
76
+ // never by `target`. `target` is a BINDING EXPRESSION — the handler's
77
+ // registration key here, but a URL / flow id / FormView name for other
78
+ // types, `${param.X}`-interpolatable, and legitimately non-unique — so it
79
+ // cannot identify a declaration. Posting it (the old `target || name`)
80
+ // meant the server resolved NO declaration for a target-bound action and
81
+ // silently skipped both the ADR-0066 D4 capability gate and the ADR-0104
82
+ // param contract (framework#3935). The server derives the handler key from
83
+ // the declaration it resolves by name.
84
+ const actionName = action.name;
85
+ if (!actionName) {
86
+ return { success: false, error: 'Action has no name — a server action must declare one' };
87
+ }
88
+ // The row record is a CLIENT-side stash (list_item invocations park it
89
+ // under `params._rowRecord` for param defaults and id resolution) — never
90
+ // part of the server contract, so it is stripped from the POSTed params.
91
+ const params = (action.params && !Array.isArray(action.params))
92
+ ? { ...action.params }
93
+ : {};
94
+ delete params._rowRecord;
95
+ const resolution = resolveRecordId(action, context);
96
+ if (resolution.error) {
97
+ return { success: false, error: resolution.error };
98
+ }
99
+ const recordId = resolution.recordId;
100
+ // Re-entrancy guard.
101
+ const inflightKey = `${actionName}:${recordId ?? ''}`;
102
+ if (inFlight.has(inflightKey)) {
103
+ return { success: false, error: 'Action already in progress' };
104
+ }
105
+ inFlight.add(inflightKey);
106
+ try {
107
+ const baseUrl = typeof config.baseUrl === 'function' ? config.baseUrl() : (config.baseUrl ?? '');
108
+ const objectName = action.objectName || resolveObject?.(action, context) || 'global';
109
+ const res = await dispatchFetch(`${baseUrl}/api/v1/actions/${encodeURIComponent(objectName)}/${encodeURIComponent(actionName)}`, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ recordId, params }),
113
+ });
114
+ const json = await res.json().catch(() => null);
115
+ // The `/actions` envelope rule, once — see ./actionResponse.
116
+ const outcome = interpretActionResponse(res, json, `Action "${actionName}"`);
117
+ if (!outcome.ok) {
118
+ // Don't toast here — the ActionRunner's post-execution hook surfaces
119
+ // `error` as a toast; a handler-side toast double-fires it.
120
+ return { success: false, error: outcome.error };
121
+ }
122
+ const shouldRefresh = action.refreshAfter !== false;
123
+ if (shouldRefresh)
124
+ onRefresh?.();
125
+ // `data` carries the ACTION ENVELOPE (`{ success, data }`), the level
126
+ // `ActionResult.data` has always carried; consumers reading the
127
+ // handler's own return value go through `readActionPayload`.
128
+ return { success: true, data: outcome.envelope, reload: shouldRefresh };
129
+ }
130
+ catch (error) {
131
+ return { success: false, error: error.message };
132
+ }
133
+ finally {
134
+ inFlight.delete(inflightKey);
135
+ }
136
+ };
137
+ }
@@ -256,8 +256,10 @@ export class ExpressionEvaluator {
256
256
  // Fail-closed callers need to tell a genuine `false` from a fault. The
257
257
  // canonical helper fails soft to the fallback, so a value that tracks the
258
258
  // fallback in BOTH runs means the predicate faulted — then we throw.
259
- const asTrue = evalFieldPredicate(source, record, true, undefined, bag);
260
- const asFalse = evalFieldPredicate(source, record, false, undefined, bag);
259
+ // `warn: false`: the throw below IS this path's diagnostic; without it one
260
+ // broken predicate would log AND throw (#5149).
261
+ const asTrue = evalFieldPredicate(source, record, true, undefined, bag, { warn: false });
262
+ const asFalse = evalFieldPredicate(source, record, false, undefined, bag, { warn: false });
261
263
  if (asTrue !== asFalse) {
262
264
  throw new Error(`CEL predicate failed to evaluate: ${source}`);
263
265
  }
@@ -10,6 +10,21 @@ export type FieldRulePredicate = string | {
10
10
  dialect?: string;
11
11
  source: string;
12
12
  };
13
+ /** Diagnostic options for {@link evalFieldPredicate} evaluation failures. */
14
+ export interface FieldPredicateDiagnostic {
15
+ /**
16
+ * Human-readable locator included in the one-time failure warning — e.g.
17
+ * `"visibleWhen of field 'amount'"`. Pass whatever the call site knows; the
18
+ * predicate source text and failure reason are always included.
19
+ */
20
+ context?: string;
21
+ /**
22
+ * Set `false` when the caller deliberately probes for faults (evaluating
23
+ * twice with both fallbacks) and surfaces its own diagnostic — prevents two
24
+ * warnings for one broken predicate. Defaults to `true`.
25
+ */
26
+ warn?: boolean;
27
+ }
13
28
  /**
14
29
  * Evaluate a field-rule CEL predicate against a record.
15
30
  *
@@ -24,14 +39,23 @@ export type FieldRulePredicate = string | {
24
39
  * e.g. `{ parent }` so an inline line-item cell can reference
25
40
  * its header (`parent.status == 'paid'`) as well as its own
26
41
  * row (`record.quantity`). Bound via the engine's `extra`.
42
+ * @param diagnostic Failure-warning options: a `context` locator for the
43
+ * one-time warning, and `warn: false` for callers that probe
44
+ * for faults and report them themselves. The returned value
45
+ * is unaffected either way.
27
46
  */
28
- export declare function evalFieldPredicate(pred: FieldRulePredicate | undefined | null, record: Record<string, unknown>, fallback: boolean, previous?: Record<string, unknown>, scope?: Record<string, unknown>): boolean;
47
+ export declare function evalFieldPredicate(pred: FieldRulePredicate | undefined | null, record: Record<string, unknown>, fallback: boolean, previous?: Record<string, unknown>, scope?: Record<string, unknown>, diagnostic?: FieldPredicateDiagnostic): boolean;
29
48
  /**
30
49
  * Resolve the effective `{ visible, readonly, required }` state for a field
31
50
  * given its conditional rules and the live record. Each `*When` rule, when
32
51
  * present, *overrides* the static flag. A static `true` is never weakened by a
33
52
  * `false` predicate result for required/readonly — but `visibleWhen` is
34
53
  * authoritative when present (so a field can be conditionally shown/hidden).
54
+ *
55
+ * @param fieldContext Optional locator for failure warnings — e.g.
56
+ * `"field 'amount'"`. The warning then reads
57
+ * `visibleWhen of field 'amount' …`; without it, the rule
58
+ * kind alone is reported.
35
59
  */
36
60
  export declare function resolveFieldRuleState(rules: {
37
61
  visibleWhen?: FieldRulePredicate;
@@ -40,7 +64,7 @@ export declare function resolveFieldRuleState(rules: {
40
64
  }, record: Record<string, unknown>, statics: {
41
65
  required?: boolean;
42
66
  readonly?: boolean;
43
- }, previous?: Record<string, unknown>, scope?: Record<string, unknown>): {
67
+ }, previous?: Record<string, unknown>, scope?: Record<string, unknown>, fieldContext?: string): {
44
68
  visible: boolean;
45
69
  readonly: boolean;
46
70
  required: boolean;