@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.
- package/CHANGELOG.md +1589 -1
- package/dist/actions/ActionRunner.d.ts +88 -16
- package/dist/actions/ActionRunner.js +164 -81
- package/dist/actions/actionKeys.d.ts +118 -0
- package/dist/actions/actionKeys.js +243 -0
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/adapters/ApiDataSource.d.ts +11 -1
- package/dist/adapters/ApiDataSource.js +21 -1
- package/dist/adapters/ValueDataSource.d.ts +10 -2
- package/dist/adapters/ValueDataSource.js +9 -0
- package/dist/adapters/batchTransaction.d.ts +49 -0
- package/dist/adapters/batchTransaction.js +118 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +1 -0
- package/dist/evaluator/ExpressionEvaluator.d.ts +10 -0
- package/dist/evaluator/ExpressionEvaluator.js +48 -0
- package/dist/evaluator/SafeExpressionParser.js +5 -2
- package/dist/evaluator/fieldRules.d.ts +0 -2
- package/dist/evaluator/fieldRules.js +3 -2
- package/dist/evaluator/optionLint.js +5 -2
- package/dist/evaluator/optionRules.d.ts +35 -6
- package/dist/evaluator/optionRules.js +18 -23
- package/dist/index.d.ts +5 -0
- package/dist/index.js +8 -0
- package/dist/registry/Registry.d.ts +81 -2
- package/dist/registry/Registry.js +102 -2
- package/dist/registry/public-blocks.js +52 -1
- package/dist/theme/ThemeEngine.js +0 -12
- package/dist/utils/chart-series.d.ts +28 -0
- package/dist/utils/chart-series.js +55 -0
- package/dist/utils/column-identity.d.ts +110 -0
- package/dist/utils/column-identity.js +210 -0
- package/dist/utils/dashboard-filters.d.ts +2 -2
- package/dist/utils/dataset-format.d.ts +18 -1
- package/dist/utils/dataset-format.js +14 -1
- package/dist/utils/expand-fields.js +8 -8
- package/dist/utils/export-filename.js +4 -1
- package/dist/utils/filter-converter.d.ts +47 -0
- package/dist/utils/filter-converter.js +94 -7
- package/dist/utils/filter-tokens.d.ts +101 -0
- package/dist/utils/filter-tokens.js +173 -0
- package/dist/utils/managedBy.d.ts +119 -0
- package/dist/utils/managedBy.js +147 -0
- package/dist/utils/normalize-list-view.d.ts +99 -0
- package/dist/utils/normalize-list-view.js +255 -0
- package/dist/utils/record-title.js +0 -0
- package/dist/utils/sort-values.d.ts +52 -0
- package/dist/utils/sort-values.js +125 -0
- package/dist/validation/index.d.ts +9 -1
- package/dist/validation/index.js +9 -1
- package/dist/validation/validators/index.d.ts +7 -1
- package/dist/validation/validators/index.js +7 -1
- package/dist/validation/validators/object-validation-engine.d.ts +151 -31
- package/dist/validation/validators/object-validation-engine.js +275 -142
- 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
|
+
}
|
package/dist/actions/index.d.ts
CHANGED
package/dist/actions/index.js
CHANGED
|
@@ -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 (
|
|
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,
|
|
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:
|
|
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
|
+
}
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/adapters/index.js
CHANGED
|
@@ -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
|
*/
|