@object-ui/core 17.2.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 (43) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +29 -0
  3. package/dist/actions/ActionEngine.d.ts +11 -6
  4. package/dist/actions/ActionEngine.js +26 -19
  5. package/dist/actions/ActionRunner.d.ts +16 -3
  6. package/dist/actions/ActionRunner.js +2 -1
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +1 -1
  10. package/dist/actions/actionKeys.js +11 -0
  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/utils/dataset-format.d.ts +6 -1
  32. package/dist/utils/dataset-pivot.d.ts +57 -0
  33. package/dist/utils/dataset-pivot.js +57 -0
  34. package/dist/utils/filter-converter.d.ts +39 -11
  35. package/dist/utils/filter-converter.js +87 -7
  36. package/dist/utils/predicate-fields.d.ts +96 -0
  37. package/dist/utils/predicate-fields.js +163 -0
  38. package/dist/utils/predicate-record.d.ts +32 -0
  39. package/dist/utils/predicate-record.js +120 -0
  40. package/dist/utils/record-title.js +0 -0
  41. package/package.json +4 -4
  42. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  43. package/dist/protocols/NotificationProtocol.js +0 -89
@@ -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;
@@ -24,6 +24,17 @@
24
24
  * block submit) and *fail-open* for readonly (a broken predicate leaves the
25
25
  * field editable) — matching the server, which logs and allows the change
26
26
  * through.
27
+ *
28
+ * Fail-open is **loud**, not silent (objectstack#5149): a predicate that
29
+ * cannot be evaluated — parse error, unbound identifier, engine fault — logs
30
+ * one `console.warn` per predicate text with the source and the failure
31
+ * reason, then still returns the caller's fallback. Without the warning a
32
+ * broken predicate is indistinguishable from an absent one, so
33
+ * conditional-visibility bugs survive inspection indefinitely. This is the
34
+ * client half of the server's "log and allow" convention (`readonlyWhen for
35
+ * 'x' failed to evaluate — change allowed through`). The *default* stays
36
+ * fail-open on purpose — flipping it is a shipped-behavior change tracked
37
+ * separately in objectstack#5149 (appeal 1, undecided).
27
38
  */
28
39
  import { ExpressionEngine } from '@objectstack/formula';
29
40
  /** Normalize a predicate into the `Expression` shape the engine expects. */
@@ -32,6 +43,26 @@ function toExpression(pred) {
32
43
  return { dialect: 'cel', source: pred };
33
44
  return { dialect: (pred.dialect ?? 'cel'), source: pred.source };
34
45
  }
46
+ const warnedPredicates = new Set();
47
+ /**
48
+ * One-time warning for a predicate that could not be evaluated. Deduped per
49
+ * predicate TEXT (dialect + source): a broken predicate is re-evaluated on
50
+ * every render/keystroke, and the point is one loud line, not a scrolling
51
+ * wall. The dedupe key is JSON-encoded — never a control-character separator
52
+ * (objectstack#5450 made a sibling of this file binary to grep that way).
53
+ */
54
+ function warnPredicateFailure(expr, fallback, reason, context) {
55
+ const key = JSON.stringify([expr.dialect, expr.source]);
56
+ if (warnedPredicates.has(key))
57
+ return;
58
+ warnedPredicates.add(key);
59
+ console.warn('[object-ui] A conditional predicate failed to evaluate' +
60
+ (context ? ` (${context})` : '') +
61
+ ` and was treated as its safe default (${String(fallback)}): ` +
62
+ JSON.stringify(expr.source) +
63
+ `. Reason: ${reason}. ` +
64
+ "Values are bound under 'record.' (e.g. record.status) — check the field names and CEL syntax.");
65
+ }
35
66
  /**
36
67
  * Evaluate a field-rule CEL predicate against a record.
37
68
  *
@@ -46,21 +77,37 @@ function toExpression(pred) {
46
77
  * e.g. `{ parent }` so an inline line-item cell can reference
47
78
  * its header (`parent.status == 'paid'`) as well as its own
48
79
  * row (`record.quantity`). Bound via the engine's `extra`.
80
+ * @param diagnostic Failure-warning options: a `context` locator for the
81
+ * one-time warning, and `warn: false` for callers that probe
82
+ * for faults and report them themselves. The returned value
83
+ * is unaffected either way.
49
84
  */
50
- export function evalFieldPredicate(pred, record, fallback, previous, scope) {
85
+ export function evalFieldPredicate(pred, record, fallback, previous, scope, diagnostic) {
51
86
  if (pred == null || (typeof pred === 'string' && !pred.trim()))
52
87
  return fallback;
88
+ const expr = toExpression(pred);
53
89
  try {
54
- const res = ExpressionEngine.evaluate(toExpression(pred), {
90
+ const res = ExpressionEngine.evaluate(expr, {
55
91
  record,
56
92
  previous,
57
93
  ...(scope ? { extra: scope } : {}),
58
94
  });
59
- if (!res.ok)
95
+ if (!res.ok) {
96
+ // Parse error, type error, unbound identifier, engine fault … — every
97
+ // not-ok verdict resolves to the fallback, but never silently (#5149).
98
+ if (diagnostic?.warn !== false) {
99
+ warnPredicateFailure(expr, fallback, `[${res.error.kind}] ${res.error.message}`, diagnostic?.context);
100
+ }
60
101
  return fallback;
102
+ }
61
103
  return res.value === true;
62
104
  }
63
- catch {
105
+ catch (err) {
106
+ // The engine contract is "never throws"; this guard is for anything that
107
+ // slips past it. Same loud fail-open as the not-ok branch.
108
+ if (diagnostic?.warn !== false) {
109
+ warnPredicateFailure(expr, fallback, `[throw] ${err instanceof Error ? err.message : String(err)}`, diagnostic?.context);
110
+ }
64
111
  return fallback;
65
112
  }
66
113
  }
@@ -70,18 +117,26 @@ export function evalFieldPredicate(pred, record, fallback, previous, scope) {
70
117
  * present, *overrides* the static flag. A static `true` is never weakened by a
71
118
  * `false` predicate result for required/readonly — but `visibleWhen` is
72
119
  * authoritative when present (so a field can be conditionally shown/hidden).
120
+ *
121
+ * @param fieldContext Optional locator for failure warnings — e.g.
122
+ * `"field 'amount'"`. The warning then reads
123
+ * `visibleWhen of field 'amount' …`; without it, the rule
124
+ * kind alone is reported.
73
125
  */
74
- export function resolveFieldRuleState(rules, record, statics, previous, scope) {
126
+ export function resolveFieldRuleState(rules, record, statics, previous, scope, fieldContext) {
127
+ const diag = (rule) => ({
128
+ context: fieldContext ? `${rule} of ${fieldContext}` : rule,
129
+ });
75
130
  const visible = rules.visibleWhen != null
76
- ? evalFieldPredicate(rules.visibleWhen, record, true, previous, scope)
131
+ ? evalFieldPredicate(rules.visibleWhen, record, true, previous, scope, diag('visibleWhen'))
77
132
  : true;
78
133
  const readonly = statics.readonly === true ||
79
134
  (rules.readonlyWhen != null
80
- ? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope)
135
+ ? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope, diag('readonlyWhen'))
81
136
  : false);
82
137
  const required = statics.required === true ||
83
138
  (rules.requiredWhen != null
84
- ? evalFieldPredicate(rules.requiredWhen, record, false, previous, scope)
139
+ ? evalFieldPredicate(rules.requiredWhen, record, false, previous, scope, diag('requiredWhen'))
85
140
  : false);
86
141
  return { visible, readonly, required };
87
142
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
+ export * from './predicateInput.js';
10
11
  export * from './fieldRules.js';
11
12
  export * from './listConditional.js';
12
13
  export * from './optionRules.js';
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
+ export * from './predicateInput.js';
10
11
  export * from './fieldRules.js';
11
12
  export * from './listConditional.js';
12
13
  export * from './optionRules.js';
@@ -34,6 +34,7 @@
34
34
  * (or nothing) to the server's CEL engine.
35
35
  */
36
36
  import { type FieldRulePredicate } from './fieldRules.js';
37
+ import { type FieldContainerLike } from '../utils/predicate-record.js';
37
38
  /** True when a *string* predicate uses legacy-only (non-CEL) syntax. */
38
39
  export declare function isLegacyDialectSource(source: string): boolean;
39
40
  /** Options for {@link evalRowPredicate}. */
@@ -49,6 +50,14 @@ export interface RowPredicateOptions {
49
50
  warnOnError?: boolean;
50
51
  /** Label used in warning messages (e.g. an action name or rule index). */
51
52
  label?: string;
53
+ /**
54
+ * The object's field definitions (`objectSchema.fields`). Supplying them lets
55
+ * a relation field be bound as the FOREIGN KEY the server stores, rather than
56
+ * as whatever `$expand` happened to substitute for it on this surface — see
57
+ * {@link toPredicateRecord}. Omitting them keeps the payload verbatim, so a
58
+ * caller with no schema at hand never guesses which fields are relations.
59
+ */
60
+ fields?: FieldContainerLike;
52
61
  }
53
62
  /**
54
63
  * Evaluate a single boolean predicate against a row record on the canonical CEL
@@ -89,5 +98,8 @@ export type ConditionalFormattingRuleLike = {
89
98
  *
90
99
  * @param scope Extra top-level scope (host predicate scope) bound alongside
91
100
  * the row, so `features.*` predicates keep resolving.
101
+ * @param fields The object's field definitions, so a rule comparing a relation
102
+ * field sees the stored foreign key rather than the expanded
103
+ * record (see {@link RowPredicateOptions.fields}).
92
104
  */
93
- export declare function resolveConditionalFormatting(record: Record<string, unknown> | null | undefined, rules?: readonly ConditionalFormattingRuleLike[] | null, scope?: Record<string, unknown>): Record<string, string>;
105
+ export declare function resolveConditionalFormatting(record: Record<string, unknown> | null | undefined, rules?: readonly ConditionalFormattingRuleLike[] | null, scope?: Record<string, unknown>, fields?: FieldContainerLike): Record<string, string>;
Binary file
@@ -39,7 +39,11 @@ export function isOptionGroupGated(dependsOn, record) {
39
39
  export function resolveVisibleOptions(options, record, scope) {
40
40
  if (!options || options.length === 0)
41
41
  return [];
42
- return options.filter((o) => o?.visibleWhen == null ? true : evalFieldPredicate(o.visibleWhen, record, true, undefined, scope));
42
+ return options.filter((o) => o?.visibleWhen == null
43
+ ? true
44
+ : evalFieldPredicate(o.visibleWhen, record, true, undefined, scope, {
45
+ context: `visibleWhen of option '${String(o.value)}'`,
46
+ }));
43
47
  }
44
48
  /**
45
49
  * Whether a scalar field value is still valid given the currently-visible