@object-ui/core 16.0.0 → 17.1.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 (56) hide show
  1. package/CHANGELOG.md +1589 -1
  2. package/dist/actions/ActionRunner.d.ts +88 -16
  3. package/dist/actions/ActionRunner.js +164 -81
  4. package/dist/actions/actionKeys.d.ts +118 -0
  5. package/dist/actions/actionKeys.js +243 -0
  6. package/dist/actions/index.d.ts +1 -0
  7. package/dist/actions/index.js +1 -0
  8. package/dist/adapters/ApiDataSource.d.ts +11 -1
  9. package/dist/adapters/ApiDataSource.js +21 -1
  10. package/dist/adapters/ValueDataSource.d.ts +10 -2
  11. package/dist/adapters/ValueDataSource.js +9 -0
  12. package/dist/adapters/batchTransaction.d.ts +49 -0
  13. package/dist/adapters/batchTransaction.js +118 -0
  14. package/dist/adapters/index.d.ts +1 -0
  15. package/dist/adapters/index.js +1 -0
  16. package/dist/evaluator/ExpressionEvaluator.d.ts +10 -0
  17. package/dist/evaluator/ExpressionEvaluator.js +48 -0
  18. package/dist/evaluator/SafeExpressionParser.js +5 -2
  19. package/dist/evaluator/fieldRules.d.ts +0 -2
  20. package/dist/evaluator/fieldRules.js +3 -2
  21. package/dist/evaluator/optionLint.js +5 -2
  22. package/dist/evaluator/optionRules.d.ts +35 -6
  23. package/dist/evaluator/optionRules.js +18 -23
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.js +8 -0
  26. package/dist/registry/Registry.d.ts +81 -2
  27. package/dist/registry/Registry.js +102 -2
  28. package/dist/registry/public-blocks.js +52 -1
  29. package/dist/theme/ThemeEngine.js +0 -12
  30. package/dist/utils/chart-series.d.ts +28 -0
  31. package/dist/utils/chart-series.js +55 -0
  32. package/dist/utils/column-identity.d.ts +110 -0
  33. package/dist/utils/column-identity.js +210 -0
  34. package/dist/utils/dashboard-filters.d.ts +2 -2
  35. package/dist/utils/dataset-format.d.ts +18 -1
  36. package/dist/utils/dataset-format.js +14 -1
  37. package/dist/utils/expand-fields.js +8 -8
  38. package/dist/utils/export-filename.js +4 -1
  39. package/dist/utils/filter-converter.d.ts +47 -0
  40. package/dist/utils/filter-converter.js +94 -7
  41. package/dist/utils/filter-tokens.d.ts +101 -0
  42. package/dist/utils/filter-tokens.js +173 -0
  43. package/dist/utils/managedBy.d.ts +119 -0
  44. package/dist/utils/managedBy.js +147 -0
  45. package/dist/utils/normalize-list-view.d.ts +99 -0
  46. package/dist/utils/normalize-list-view.js +255 -0
  47. package/dist/utils/record-title.js +0 -0
  48. package/dist/utils/sort-values.d.ts +52 -0
  49. package/dist/utils/sort-values.js +125 -0
  50. package/dist/validation/index.d.ts +9 -1
  51. package/dist/validation/index.js +9 -1
  52. package/dist/validation/validators/index.d.ts +7 -1
  53. package/dist/validation/validators/index.js +7 -1
  54. package/dist/validation/validators/object-validation-engine.d.ts +151 -31
  55. package/dist/validation/validators/object-validation-engine.js +275 -142
  56. package/package.json +4 -4
@@ -0,0 +1,243 @@
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
+ * What an action is allowed to carry — objectstack#4075 step 1.
10
+ *
11
+ * `ActionDef` ends with `[key: string]: any`, so it accepts any key of any type.
12
+ * Concretely (objectui#2990): deleting `ActionDef.execute` produced ZERO compile
13
+ * errors even though the field had just been removed, and stale metadata still
14
+ * authoring `execute: 'markDone'` type-checks today. The same deletion against
15
+ * `@object-ui/types`' `ActionSchema` — which has no index signature — correctly
16
+ * produced `TS2353` at the authoring site. One of the two readers can catch a
17
+ * retired key; the other is structurally incapable.
18
+ *
19
+ * That asymmetry is the issue. An open key set on a DECLARED METADATA CONTRACT
20
+ * is what lets a typo (`targt`, `exectue`) and a tombstoned key (`execute`) both
21
+ * sail through to a runner that then silently does nothing — the #2169 "Mark
22
+ * Done does nothing" shape.
23
+ *
24
+ * This module is step 1 of the staged narrowing: it makes the key set VISIBLE
25
+ * and warns on anything outside it, without changing a single type. Nothing
26
+ * breaks, and an invisible failure becomes an audible one. Steps 2 and 3 —
27
+ * promoting the legitimate keys to explicit optional fields, then removing the
28
+ * index signature — are what finally let `tsc` catch typos and retired keys.
29
+ *
30
+ * The lists are pinned by `__tests__/actionKeys.pin.test.ts`, which re-derives
31
+ * each one from its actual source. A hand-maintained list that drifts from the
32
+ * interface it mirrors would be the same "declared ≠ enforced" trap this whole
33
+ * thread is about.
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:
38
+ *
39
+ * 18 keys the SPEC owns that `ActionDef` never declared — `ai`, `aria`,
40
+ * `bodyExtra`, `bodyShape`, `bulkEnabled`, `component`, `icon`, `locations`,
41
+ * `mode`, `objectName`, `order`, `recordIdField`, `recordIdParam`,
42
+ * `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.
47
+ *
48
+ * 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
49
+ * `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
50
+ * `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.
54
+ */
55
+ /**
56
+ * Every property `ActionDef` declares, in declaration order.
57
+ *
58
+ * Kept as data because TypeScript types do not survive to runtime — and
59
+ * `keyof ActionDef` cannot substitute for this list: the index signature widens
60
+ * it to `string | number`, which is precisely the problem being worked around.
61
+ */
62
+ export const ACTION_DEF_KEYS = [
63
+ 'type',
64
+ 'actionType',
65
+ 'name',
66
+ 'label',
67
+ 'confirmText',
68
+ 'confirm',
69
+ 'condition',
70
+ 'disabled',
71
+ 'api',
72
+ 'endpoint',
73
+ 'method',
74
+ 'navigate',
75
+ 'onClick',
76
+ 'reload',
77
+ 'close',
78
+ 'redirect',
79
+ 'toast',
80
+ 'successMessage',
81
+ 'errorMessage',
82
+ 'refreshAfter',
83
+ 'undoable',
84
+ 'params',
85
+ 'actionParams',
86
+ 'resultDialog',
87
+ 'target',
88
+ 'body',
89
+ 'openIn',
90
+ 'modal',
91
+ 'chain',
92
+ 'chainMode',
93
+ 'onSuccess',
94
+ 'onFailure',
95
+ 'opensInNewTab',
96
+ 'newTabUrl',
97
+ ];
98
+ /**
99
+ * Every property `@objectstack/spec`'s `ActionSchema` declares.
100
+ *
101
+ * `ActionDef` mirrors that schema, so a key the spec owns is legitimate even
102
+ * when `ActionDef` has not declared it yet — several are read today only through
103
+ * an `as any` cast (`visible` and `locations` in `ActionEngine`), which is the
104
+ * cast this list exists to eventually retire.
105
+ *
106
+ * Pinned rather than read off the schema at runtime: the spec exports
107
+ * `ActionSchema` as a lazy proxy that does not forward `.shape`, so resolving it
108
+ * means walking zod internals (`_def.in` / `_def.innerType`). That is fine in a
109
+ * test and wrong in shipped code — `@object-ui/types` made the same call for
110
+ * `ActionComponent`. The pin test does the walk and fails the day this drifts.
111
+ */
112
+ export const SPEC_ACTION_KEYS = [
113
+ 'ai',
114
+ 'aria',
115
+ 'body',
116
+ 'bodyExtra',
117
+ 'bodyShape',
118
+ 'bulkEnabled',
119
+ 'component',
120
+ 'confirmText',
121
+ 'disabled',
122
+ 'errorMessage',
123
+ 'execute',
124
+ 'icon',
125
+ 'label',
126
+ 'locations',
127
+ 'method',
128
+ 'mode',
129
+ 'name',
130
+ 'newTabUrl',
131
+ 'objectName',
132
+ 'openIn',
133
+ 'opensInNewTab',
134
+ 'order',
135
+ 'params',
136
+ 'recordIdField',
137
+ 'recordIdParam',
138
+ 'refreshAfter',
139
+ 'requiredPermissions',
140
+ 'requiresFeature',
141
+ 'resultDialog',
142
+ 'shortcut',
143
+ 'successMessage',
144
+ 'target',
145
+ 'type',
146
+ 'undoable',
147
+ 'variant',
148
+ 'visible',
149
+ ];
150
+ /**
151
+ * The `navigation` alias's own spelling of a target, read off the action itself
152
+ * when no nested `navigate` object is present (`ActionRunner.executeNavigation`).
153
+ *
154
+ * A declared objectui-side dialect, not spec vocabulary: `replace` is documented
155
+ * at its read site as "the one thing the `navigation` shape carries that
156
+ * `ActionSchema` has no field for". Listing it here is what keeps it from being
157
+ * silent dialect — the alternative is a warning that cries wolf on a shape the
158
+ * runner itself supports.
159
+ */
160
+ export const NAVIGATION_ALIAS_KEYS = ['to', 'external', 'newTab', 'replace'];
161
+ /**
162
+ * Keys the spec has TOMBSTONED: still present in `ActionSchema` so the parser can
163
+ * reject them BY NAME with a rename prescription, rather than fail with a bare
164
+ * "unrecognized key".
165
+ *
166
+ * Warned about separately from unknown keys, and more loudly: an unknown key is
167
+ * probably a typo, while a retired key is metadata that used to work. That
168
+ * distinction is why `executeScript` carries a runtime branch returning the
169
+ * rename prescription — a branch that exists solely to compensate for the index
170
+ * signature, and that can retire with it in step 3.
171
+ */
172
+ export const RETIRED_ACTION_KEYS = {
173
+ execute: '`execute` was removed in @objectstack/spec 17 (#3855) — rename the key to `target`. ' +
174
+ 'The value (a script name or expression) is unchanged. ' +
175
+ 'Run `os migrate meta --from 16` to rewrite it automatically.',
176
+ };
177
+ /** Every key an action may legitimately carry today. */
178
+ export const KNOWN_ACTION_KEYS = new Set([
179
+ ...ACTION_DEF_KEYS,
180
+ ...SPEC_ACTION_KEYS,
181
+ ...NAVIGATION_ALIAS_KEYS,
182
+ ].filter((key) => !(key in RETIRED_ACTION_KEYS)));
183
+ /** Split an action's own keys into the two things worth saying out loud. */
184
+ export function classifyActionKeys(action) {
185
+ const unknown = [];
186
+ const retired = [];
187
+ if (!action || typeof action !== 'object')
188
+ return { unknown, retired };
189
+ for (const key of Object.keys(action)) {
190
+ if (key in RETIRED_ACTION_KEYS)
191
+ retired.push(key);
192
+ else if (!KNOWN_ACTION_KEYS.has(key))
193
+ unknown.push(key);
194
+ }
195
+ return { unknown, retired };
196
+ }
197
+ // Warn once per (action, problem), not once per execution: an unrecognized key
198
+ // usually sits in metadata driving a button that gets clicked repeatedly, and a
199
+ // warning that floods the console is a warning that gets muted.
200
+ //
201
+ // Keyed by action name as well as by the keys, deliberately. Keying on the keys
202
+ // alone would report the FIRST action carrying `targt` and stay silent about
203
+ // every other one — sending the author to fix a button that was only the first
204
+ // symptom. The memo is bounded by the number of authored actions either way.
205
+ const warned = new Set();
206
+ /** Reset the warn-once memo. Exported for tests. */
207
+ export function resetActionKeyWarnings() {
208
+ warned.clear();
209
+ }
210
+ const isDev = () => globalThis.process?.env?.NODE_ENV !==
211
+ 'production';
212
+ /**
213
+ * Dev-mode only: name the keys an action carries that no reader will ever look
214
+ * at. Non-breaking by construction — it changes no types and rejects nothing.
215
+ *
216
+ * Silently binding no handler is the #2169 "Mark Done does nothing" shape, and
217
+ * the compiler cannot help while the index signature stands. Until it comes
218
+ * down, this is the audible half.
219
+ */
220
+ export function warnOnUnknownActionKeys(action, where = 'ActionRunner') {
221
+ if (!isDev())
222
+ return;
223
+ const { unknown, retired } = classifyActionKeys(action);
224
+ const name = action?.name ?? action?.type ?? '(unnamed)';
225
+ for (const key of retired) {
226
+ const memo = `retired:${String(name)}:${key}`;
227
+ if (warned.has(memo))
228
+ continue;
229
+ warned.add(memo);
230
+ console.warn(`[${where}] action "${String(name)}" carries the retired key \`${key}\`. ${RETIRED_ACTION_KEYS[key]}`);
231
+ }
232
+ if (unknown.length === 0)
233
+ return;
234
+ const memo = `unknown:${String(name)}:${unknown.slice().sort().join(',')}`;
235
+ if (warned.has(memo))
236
+ return;
237
+ warned.add(memo);
238
+ console.warn(`[${where}] action "${String(name)}" carries ${unknown.length === 1 ? 'a key' : 'keys'} no reader ` +
239
+ `recognizes: \`${unknown.join('`, `')}\`. Nothing will read ${unknown.length === 1 ? 'it' : 'them'} — ` +
240
+ 'check for a typo, or promote the key to an explicit field on `ActionDef` ' +
241
+ '(packages/core/src/actions/actionKeys.ts). Warned rather than rejected because `ActionDef` ' +
242
+ 'still carries `[key: string]: any`, so the compiler cannot see this (objectstack#4075).');
243
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export * from './ActionRunner.js';
9
9
  export * from './ActionEngine.js';
10
+ export * from './actionKeys.js';
10
11
  export * from './TransactionManager.js';
11
12
  export * from './UndoManager.js';
12
13
  export * from './bulkFastPath.js';
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export * from './ActionRunner.js';
9
9
  export * from './ActionEngine.js';
10
+ export * from './actionKeys.js';
10
11
  export * from './TransactionManager.js';
11
12
  export * from './UndoManager.js';
12
13
  export * from './bulkFastPath.js';
@@ -8,7 +8,7 @@
8
8
  * A DataSource adapter for the `provider: 'api'` ViewData mode.
9
9
  * Makes raw HTTP requests using the HttpRequest configs from ViewData.
10
10
  */
11
- import type { DataSource, QueryParams, QueryResult, HttpRequest, AggregateParams, AggregateResult } from '@object-ui/types';
11
+ import type { DataSource, BatchTransactionOperation, QueryParams, QueryResult, HttpRequest, AggregateParams, AggregateResult } from '@object-ui/types';
12
12
  export interface ApiDataSourceConfig {
13
13
  /** HttpRequest config for read operations (find, findOne) */
14
14
  read?: HttpRequest;
@@ -54,6 +54,16 @@ export declare class ApiDataSource<T = any> implements DataSource<T> {
54
54
  delete(_resource: string, id: string | number, opts?: {
55
55
  ifMatch?: string;
56
56
  }): Promise<boolean>;
57
+ /**
58
+ * Client-side (non-atomic) cross-object batch — a generic REST endpoint has
59
+ * no transactional batch primitive, so this delegates to the shared
60
+ * emulation (sequential writes + `$ref` resolution + best-effort
61
+ * compensation). Adapters fronting a server that DOES offer an atomic batch
62
+ * should override this to issue a single request.
63
+ */
64
+ batchTransaction(operations: BatchTransactionOperation[]): Promise<{
65
+ results: any[];
66
+ }>;
57
67
  getObjectSchema(_objectName: string): Promise<any>;
58
68
  getView(_objectName: string, _viewId: string): Promise<any | null>;
59
69
  getApp(_appId: string): Promise<any | null>;
@@ -8,6 +8,7 @@
8
8
  * A DataSource adapter for the `provider: 'api'` ViewData mode.
9
9
  * Makes raw HTTP requests using the HttpRequest configs from ViewData.
10
10
  */
11
+ import { emulateBatchTransaction } from './batchTransaction.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Helpers
13
14
  // ---------------------------------------------------------------------------
@@ -43,7 +44,16 @@ function queryParamsToRecord(params) {
43
44
  out.$filter = JSON.stringify(params.$filter);
44
45
  }
45
46
  if (params.$orderby) {
46
- if (Array.isArray(params.$orderby)) {
47
+ if (typeof params.$orderby === 'string') {
48
+ // Already an OData order clause ('name asc', 'a asc,b desc'). It MUST be
49
+ // passed through verbatim: the `Object.entries` branch below would walk a
50
+ // string index-by-index and emit `0 n,1 a,2 m,…`, which the server dutifully
51
+ // sorts by — columns that don't exist — and the whole page comes back empty
52
+ // (objectstack#3821). Callers that build the clause by hand (ObjectGrid's
53
+ // schema `sort`, RecipientPickerField) hit exactly that.
54
+ out.$orderby = params.$orderby;
55
+ }
56
+ else if (Array.isArray(params.$orderby)) {
47
57
  if (typeof params.$orderby[0] === 'string') {
48
58
  out.$orderby = params.$orderby.join(',');
49
59
  }
@@ -241,6 +251,16 @@ export class ApiDataSource {
241
251
  return false;
242
252
  }
243
253
  }
254
+ /**
255
+ * Client-side (non-atomic) cross-object batch — a generic REST endpoint has
256
+ * no transactional batch primitive, so this delegates to the shared
257
+ * emulation (sequential writes + `$ref` resolution + best-effort
258
+ * compensation). Adapters fronting a server that DOES offer an atomic batch
259
+ * should override this to issue a single request.
260
+ */
261
+ batchTransaction(operations) {
262
+ return emulateBatchTransaction(this, operations);
263
+ }
244
264
  async getObjectSchema(_objectName) {
245
265
  // Generic API endpoints typically don't expose schema metadata.
246
266
  // Return a minimal stub so schema-dependent components don't crash.
@@ -8,7 +8,7 @@
8
8
  * A DataSource adapter for the `provider: 'value'` ViewData mode.
9
9
  * Operates entirely on an in-memory array — no network requests.
10
10
  */
11
- import type { DataSource, MutationEvent, QueryParams, QueryResult, AggregateParams, AggregateResult } from '@object-ui/types';
11
+ import type { DataSource, BatchTransactionOperation, DataSourceMutationEvent, QueryParams, QueryResult, AggregateParams, AggregateResult } from '@object-ui/types';
12
12
  export interface ValueDataSourceConfig<T = any> {
13
13
  /** The static data array */
14
14
  items: T[];
@@ -48,11 +48,19 @@ export declare class ValueDataSource<T = any> implements DataSource<T> {
48
48
  update(_resource: string, id: string | number, data: Partial<T>): Promise<T>;
49
49
  delete(_resource: string, id: string | number): Promise<boolean>;
50
50
  bulk(_resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]>;
51
+ /**
52
+ * Client-side (non-atomic) cross-object batch — this in-memory adapter has
53
+ * no server transaction, so it delegates to the shared emulation. Per-op
54
+ * MutationEvents come from the `create`/`update`/`delete` primitives above.
55
+ */
56
+ batchTransaction(operations: BatchTransactionOperation[]): Promise<{
57
+ results: any[];
58
+ }>;
51
59
  getObjectSchema(_objectName: string): Promise<any>;
52
60
  getView(_objectName: string, _viewId: string): Promise<any | null>;
53
61
  getApp(_appId: string): Promise<any | null>;
54
62
  aggregate(_resource: string, params: AggregateParams): Promise<AggregateResult[]>;
55
- onMutation(callback: (event: MutationEvent<T>) => void): () => void;
63
+ onMutation(callback: (event: DataSourceMutationEvent<T>) => void): () => void;
56
64
  /** Get the current number of items */
57
65
  get count(): number;
58
66
  /** Get a snapshot of all items (cloned) */
@@ -8,6 +8,7 @@
8
8
  * A DataSource adapter for the `provider: 'value'` ViewData mode.
9
9
  * Operates entirely on an in-memory array — no network requests.
10
10
  */
11
+ import { emulateBatchTransaction } from './batchTransaction.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Helpers
13
14
  // ---------------------------------------------------------------------------
@@ -341,6 +342,14 @@ export class ValueDataSource {
341
342
  }
342
343
  return results;
343
344
  }
345
+ /**
346
+ * Client-side (non-atomic) cross-object batch — this in-memory adapter has
347
+ * no server transaction, so it delegates to the shared emulation. Per-op
348
+ * MutationEvents come from the `create`/`update`/`delete` primitives above.
349
+ */
350
+ batchTransaction(operations) {
351
+ return emulateBatchTransaction(this, operations);
352
+ }
344
353
  async getObjectSchema(_objectName) {
345
354
  // Infer a minimal schema from the first item
346
355
  if (this.items.length === 0)
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ObjectUI — batchTransaction emulation
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
+ * Client-side emulation of {@link DataSource.batchTransaction} for adapters
9
+ * that lack a server-side transactional batch endpoint. This is the single,
10
+ * tested home for the non-atomic fallback (ObjectStack objectui #2679 /
11
+ * framework #1604 / ADR-0034 item 4): components call `runBatchTransaction`
12
+ * unconditionally and never orchestrate cross-object writes themselves.
13
+ *
14
+ * The emulation runs operations sequentially — NOT the old `bulk('create')`
15
+ * grouping — because sequential execution is what makes `{ $ref: i }`
16
+ * resolution and ordered semantics correct (a child create must observe the
17
+ * parent's freshly-minted id). Line-item counts are small, so the extra
18
+ * round-trips are acceptable.
19
+ */
20
+ import type { BatchTransactionOperation, DataSource } from '@object-ui/types';
21
+ /**
22
+ * Emulate an atomic batch over a plain DataSource by executing its `create`/
23
+ * `update`/`delete` primitives in order.
24
+ *
25
+ * NOT atomic: on failure it best-effort compensates by deleting the records
26
+ * it created (reverse order — children before parent), then rethrows the
27
+ * original error. Updates and deletes that already ran cannot be undone; a
28
+ * compensation delete that itself fails is swallowed (`Promise.allSettled`).
29
+ * This is the documented reason a server-backed `batchTransaction` is
30
+ * strictly better — a true transaction simply never commits.
31
+ *
32
+ * MutationEvents are deliberately NOT emitted here: the underlying
33
+ * `create`/`update`/`delete` primitives already emit per-op on adapters that
34
+ * support `onMutation`, so emitting here too would double-fire.
35
+ */
36
+ export declare function emulateBatchTransaction<T = any>(dataSource: DataSource<T>, operations: BatchTransactionOperation[]): Promise<{
37
+ results: any[];
38
+ }>;
39
+ /**
40
+ * Persist an ordered cross-object batch, preferring the adapter's native
41
+ * (potentially atomic) `batchTransaction` and falling back to
42
+ * {@link emulateBatchTransaction} when the adapter does not implement it.
43
+ *
44
+ * This is the single entry point UI components should call — they stay
45
+ * ignorant of whether the underlying save is truly atomic.
46
+ */
47
+ export declare function runBatchTransaction<T = any>(dataSource: DataSource<T>, operations: BatchTransactionOperation[]): Promise<{
48
+ results: any[];
49
+ }>;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * ObjectUI — batchTransaction emulation
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
+ * Client-side emulation of {@link DataSource.batchTransaction} for adapters
9
+ * that lack a server-side transactional batch endpoint. This is the single,
10
+ * tested home for the non-atomic fallback (ObjectStack objectui #2679 /
11
+ * framework #1604 / ADR-0034 item 4): components call `runBatchTransaction`
12
+ * unconditionally and never orchestrate cross-object writes themselves.
13
+ *
14
+ * The emulation runs operations sequentially — NOT the old `bulk('create')`
15
+ * grouping — because sequential execution is what makes `{ $ref: i }`
16
+ * resolution and ordered semantics correct (a child create must observe the
17
+ * parent's freshly-minted id). Line-item counts are small, so the extra
18
+ * round-trips are acceptable.
19
+ */
20
+ /** Best-effort id extraction across the record shapes adapters return. */
21
+ function idOf(rec) {
22
+ if (rec == null)
23
+ return undefined;
24
+ return rec.id ?? rec._id ?? rec.recordId;
25
+ }
26
+ /**
27
+ * Replace top-level `{ $ref: i }` values in an op payload with the id produced
28
+ * by operation `i`. Only backward references are valid — a forward or
29
+ * unresolved ref throws (the builders only ever emit `{ $ref: 0 }` pointing at
30
+ * an already-executed parent, so this is a guard, not a normal path).
31
+ */
32
+ function resolveRefs(data, results) {
33
+ const out = {};
34
+ for (const [key, value] of Object.entries(data)) {
35
+ if (value &&
36
+ typeof value === 'object' &&
37
+ !Array.isArray(value) &&
38
+ typeof value.$ref === 'number') {
39
+ const refIndex = value.$ref;
40
+ const id = idOf(results[refIndex]);
41
+ if (id === undefined) {
42
+ throw new Error(`batchTransaction: field "${key}" references operation ${refIndex}, ` +
43
+ `which has not produced an id (forward or invalid $ref)`);
44
+ }
45
+ out[key] = id;
46
+ }
47
+ else {
48
+ out[key] = value;
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+ /**
54
+ * Emulate an atomic batch over a plain DataSource by executing its `create`/
55
+ * `update`/`delete` primitives in order.
56
+ *
57
+ * NOT atomic: on failure it best-effort compensates by deleting the records
58
+ * it created (reverse order — children before parent), then rethrows the
59
+ * original error. Updates and deletes that already ran cannot be undone; a
60
+ * compensation delete that itself fails is swallowed (`Promise.allSettled`).
61
+ * This is the documented reason a server-backed `batchTransaction` is
62
+ * strictly better — a true transaction simply never commits.
63
+ *
64
+ * MutationEvents are deliberately NOT emitted here: the underlying
65
+ * `create`/`update`/`delete` primitives already emit per-op on adapters that
66
+ * support `onMutation`, so emitting here too would double-fire.
67
+ */
68
+ export async function emulateBatchTransaction(dataSource, operations) {
69
+ const results = [];
70
+ const created = [];
71
+ try {
72
+ for (const op of operations) {
73
+ const action = op.action ?? 'create';
74
+ if (action === 'create') {
75
+ const record = await dataSource.create(op.object, resolveRefs(op.data ?? {}, results));
76
+ results.push(record);
77
+ const id = idOf(record);
78
+ if (id)
79
+ created.push({ object: op.object, id });
80
+ }
81
+ else if (action === 'update') {
82
+ if (op.id == null) {
83
+ throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
84
+ }
85
+ results.push(await dataSource.update(op.object, op.id, resolveRefs(op.data ?? {}, results)));
86
+ }
87
+ else {
88
+ if (op.id == null) {
89
+ throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
90
+ }
91
+ results.push(await dataSource.delete(op.object, op.id));
92
+ }
93
+ }
94
+ return { results };
95
+ }
96
+ catch (err) {
97
+ // Best-effort compensation: undo the creates we made, newest first, so a
98
+ // child is removed before the parent it points at. Failures are ignored —
99
+ // there is nothing more we can do client-side.
100
+ if (created.length > 0) {
101
+ await Promise.allSettled([...created].reverse().map((c) => dataSource.delete(c.object, c.id)));
102
+ }
103
+ throw err;
104
+ }
105
+ }
106
+ /**
107
+ * Persist an ordered cross-object batch, preferring the adapter's native
108
+ * (potentially atomic) `batchTransaction` and falling back to
109
+ * {@link emulateBatchTransaction} when the adapter does not implement it.
110
+ *
111
+ * This is the single entry point UI components should call — they stay
112
+ * ignorant of whether the underlying save is truly atomic.
113
+ */
114
+ export function runBatchTransaction(dataSource, operations) {
115
+ return typeof dataSource.batchTransaction === 'function'
116
+ ? dataSource.batchTransaction(operations)
117
+ : emulateBatchTransaction(dataSource, operations);
118
+ }
@@ -8,3 +8,4 @@
8
8
  export { ApiDataSource, type ApiDataSourceConfig } from './ApiDataSource.js';
9
9
  export { ValueDataSource, type ValueDataSourceConfig } from './ValueDataSource.js';
10
10
  export { resolveDataSource, type ResolveDataSourceOptions } from './resolveDataSource.js';
11
+ export { emulateBatchTransaction, runBatchTransaction } from './batchTransaction.js';
@@ -11,3 +11,4 @@
11
11
  export { ApiDataSource } from './ApiDataSource.js';
12
12
  export { ValueDataSource } from './ValueDataSource.js';
13
13
  export { resolveDataSource } from './resolveDataSource.js';
14
+ export { emulateBatchTransaction, runBatchTransaction } from './batchTransaction.js';
@@ -86,6 +86,16 @@ export declare class ExpressionEvaluator {
86
86
  dialect?: string;
87
87
  source?: string;
88
88
  }, options?: EvaluationOptions): boolean;
89
+ /**
90
+ * Evaluate a `{ dialect: 'cel' }` predicate on the canonical `@objectstack/formula`
91
+ * engine (via `evalFieldPredicate`), binding this evaluator's context: the
92
+ * `record` key as the `record` namespace and the whole context bag as top-level
93
+ * scope so `record.*`, `features.*`, `user.*`, `app.*` all resolve. Fail-soft to
94
+ * `true` (visible/enabled — the legacy default) unless the caller opted into
95
+ * `throwOnError`, in which case a *faulting* predicate (bad field / non-CEL
96
+ * syntax) throws; a genuine `false` never throws.
97
+ */
98
+ private evaluateCelCondition;
89
99
  /**
90
100
  * Update the context with new data
91
101
  */
@@ -17,6 +17,7 @@
17
17
  import { ExpressionContext } from './ExpressionContext.js';
18
18
  import { ExpressionCache } from './ExpressionCache.js';
19
19
  import { FormulaFunctions } from './FormulaFunctions.js';
20
+ import { evalFieldPredicate } from './fieldRules.js';
20
21
  /**
21
22
  * Expression evaluator for dynamic UI expressions
22
23
  */
@@ -141,6 +142,10 @@ export class ExpressionEvaluator {
141
142
  return compiled.fn(...varValues);
142
143
  }
143
144
  catch (error) {
145
+ // The original error's message is inlined below. We can't pass it as the
146
+ // `Error` `cause` option because this package targets ES2020, whose lib
147
+ // types the 1-arg `Error` constructor only; hence the scoped disable.
148
+ // eslint-disable-next-line preserve-caught-error
144
149
  throw new Error(`Failed to evaluate expression "${expression}": ${error.message}`);
145
150
  }
146
151
  }
@@ -177,6 +182,18 @@ export class ExpressionEvaluator {
177
182
  if (typeof condition === 'boolean') {
178
183
  return condition;
179
184
  }
185
+ // #2661 — a CEL-dialect envelope routes to the canonical `@objectstack/formula`
186
+ // engine (the one `fieldRules` / list conditionals already use), NOT the legacy
187
+ // JS evaluator below. This makes a component / action `visible` / `disabled`
188
+ // predicate reach the SAME verdict as server enforcement — including CEL-only
189
+ // behavior like `record.due_date == today()` (framework#3205). Bare strings and
190
+ // `${…}` templates stay on the legacy path (back-compat deprecation window);
191
+ // only an explicit `{ dialect: 'cel' }` envelope is rerouted.
192
+ if (condition && typeof condition === 'object'
193
+ && condition.dialect === 'cel'
194
+ && typeof condition.source === 'string') {
195
+ return this.evaluateCelCondition(condition.source, options);
196
+ }
180
197
  // Unwrap Expression envelope (see `evaluate` for rationale).
181
198
  if (condition && typeof condition === 'object' && typeof condition.source === 'string') {
182
199
  condition = condition.source;
@@ -215,6 +232,37 @@ export class ExpressionEvaluator {
215
232
  return true;
216
233
  }
217
234
  }
235
+ /**
236
+ * Evaluate a `{ dialect: 'cel' }` predicate on the canonical `@objectstack/formula`
237
+ * engine (via `evalFieldPredicate`), binding this evaluator's context: the
238
+ * `record` key as the `record` namespace and the whole context bag as top-level
239
+ * scope so `record.*`, `features.*`, `user.*`, `app.*` all resolve. Fail-soft to
240
+ * `true` (visible/enabled — the legacy default) unless the caller opted into
241
+ * `throwOnError`, in which case a *faulting* predicate (bad field / non-CEL
242
+ * syntax) throws; a genuine `false` never throws.
243
+ */
244
+ evaluateCelCondition(source, options) {
245
+ if (!source.trim())
246
+ return true; // no predicate → visible/enabled
247
+ const bag = this.context.toObject();
248
+ const rec = bag.record;
249
+ const record = (rec && typeof rec === 'object' && !Array.isArray(rec))
250
+ ? rec
251
+ : bag;
252
+ if (!options.throwOnError) {
253
+ // Fast path: one evaluation, fail-soft to visible/enabled (legacy parity).
254
+ return evalFieldPredicate(source, record, true, undefined, bag);
255
+ }
256
+ // Fail-closed callers need to tell a genuine `false` from a fault. The
257
+ // canonical helper fails soft to the fallback, so a value that tracks the
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);
261
+ if (asTrue !== asFalse) {
262
+ throw new Error(`CEL predicate failed to evaluate: ${source}`);
263
+ }
264
+ return asTrue;
265
+ }
218
266
  /**
219
267
  * Update the context with new data
220
268
  */