@object-ui/core 17.6.0 → 17.7.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 (134) hide show
  1. package/CHANGELOG.md +6610 -0
  2. package/README.md +101 -14
  3. package/dist/actions/ActionEngine.d.ts +4 -2
  4. package/dist/actions/ActionEngine.js +13 -8
  5. package/dist/actions/ActionRunner.d.ts +288 -27
  6. package/dist/actions/ActionRunner.js +443 -63
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/actionKeys.d.ts +9 -7
  9. package/dist/actions/actionKeys.js +57 -14
  10. package/dist/actions/actionResponse.js +2 -2
  11. package/dist/actions/index.d.ts +1 -0
  12. package/dist/actions/index.js +1 -0
  13. package/dist/actions/recordDelete.d.ts +118 -0
  14. package/dist/actions/recordDelete.js +112 -0
  15. package/dist/adapters/ValueDataSource.d.ts +6 -1
  16. package/dist/adapters/ValueDataSource.js +1090 -98
  17. package/dist/adapters/batchTransaction.d.ts +1 -1
  18. package/dist/adapters/batchTransaction.js +1 -1
  19. package/dist/builder/schema-builder.d.ts +38 -81
  20. package/dist/builder/schema-builder.js +33 -152
  21. package/dist/data-scope/ViewDataProvider.d.ts +1 -1
  22. package/dist/data-scope/ViewDataProvider.js +21 -1
  23. package/dist/data-scope/element-data-source.d.ts +261 -7
  24. package/dist/data-scope/element-data-source.js +342 -52
  25. package/dist/data-scope/index.d.ts +9 -4
  26. package/dist/data-scope/index.js +9 -4
  27. package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
  28. package/dist/evaluator/ExpressionEvaluator.js +184 -12
  29. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  30. package/dist/evaluator/FormulaFunctions.js +166 -27
  31. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  32. package/dist/evaluator/declaredPredicate.js +117 -16
  33. package/dist/evaluator/fieldRules.d.ts +88 -4
  34. package/dist/evaluator/fieldRules.js +203 -31
  35. package/dist/evaluator/index.d.ts +1 -0
  36. package/dist/evaluator/index.js +1 -0
  37. package/dist/evaluator/listConditional.d.ts +103 -34
  38. package/dist/evaluator/listConditional.js +127 -50
  39. package/dist/evaluator/optionRules.d.ts +77 -6
  40. package/dist/evaluator/optionRules.js +87 -10
  41. package/dist/evaluator/predicateInput.d.ts +15 -4
  42. package/dist/evaluator/predicateInput.js +35 -2
  43. package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
  44. package/dist/evaluator/rowPredicateCanon.js +65 -115
  45. package/dist/evaluator/subjectPermissions.d.ts +76 -0
  46. package/dist/evaluator/subjectPermissions.js +44 -0
  47. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  48. package/dist/evaluator/unevaluablePredicate.js +70 -0
  49. package/dist/index.d.ts +14 -0
  50. package/dist/index.js +80 -2
  51. package/dist/protocols/index.d.ts +0 -1
  52. package/dist/protocols/index.js +9 -1
  53. package/dist/registry/Registry.d.ts +358 -31
  54. package/dist/registry/Registry.js +263 -11
  55. package/dist/registry/WidgetRegistry.js +7 -3
  56. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  57. package/dist/registry/html-tier-intrinsics.js +110 -0
  58. package/dist/registry/public-blocks.d.ts +8 -0
  59. package/dist/registry/public-blocks.js +57 -0
  60. package/dist/theme/ThemeEngine.js +1 -1
  61. package/dist/utils/chart-category-key.d.ts +82 -0
  62. package/dist/utils/chart-category-key.js +84 -0
  63. package/dist/utils/chart-measure-key.d.ts +64 -0
  64. package/dist/utils/chart-measure-key.js +65 -0
  65. package/dist/utils/chart-presentation.d.ts +95 -112
  66. package/dist/utils/chart-presentation.js +169 -104
  67. package/dist/utils/chart-series.d.ts +131 -5
  68. package/dist/utils/chart-series.js +98 -16
  69. package/dist/utils/column-sortability.d.ts +157 -0
  70. package/dist/utils/column-sortability.js +217 -0
  71. package/dist/utils/dashboard-filters.d.ts +38 -6
  72. package/dist/utils/dashboard-filters.js +107 -61
  73. package/dist/utils/dataset-format.d.ts +211 -30
  74. package/dist/utils/dataset-format.js +389 -46
  75. package/dist/utils/date-display.d.ts +354 -0
  76. package/dist/utils/date-display.js +463 -0
  77. package/dist/utils/date-macros.js +7 -0
  78. package/dist/utils/drill-down.d.ts +78 -0
  79. package/dist/utils/drill-down.js +93 -0
  80. package/dist/utils/emptiness.d.ts +96 -0
  81. package/dist/utils/emptiness.js +101 -0
  82. package/dist/utils/expand-fields.d.ts +76 -6
  83. package/dist/utils/expand-fields.js +76 -6
  84. package/dist/utils/extract-records.d.ts +65 -5
  85. package/dist/utils/extract-records.js +65 -8
  86. package/dist/utils/filter-converter.d.ts +152 -4
  87. package/dist/utils/filter-converter.js +1092 -22
  88. package/dist/utils/filter-tokens.d.ts +49 -5
  89. package/dist/utils/filter-tokens.js +105 -33
  90. package/dist/utils/grouping-fields.d.ts +61 -0
  91. package/dist/utils/grouping-fields.js +84 -0
  92. package/dist/utils/managedBy.d.ts +3 -3
  93. package/dist/utils/managedBy.js +5 -5
  94. package/dist/utils/native-date-value.d.ts +48 -0
  95. package/dist/utils/native-date-value.js +167 -0
  96. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  97. package/dist/utils/non-grid-row-ceiling.js +141 -0
  98. package/dist/utils/normalize-list-view.d.ts +121 -1
  99. package/dist/utils/normalize-list-view.js +186 -6
  100. package/dist/utils/number-display.d.ts +63 -14
  101. package/dist/utils/number-display.js +34 -12
  102. package/dist/utils/object-aggregate-query.d.ts +113 -0
  103. package/dist/utils/object-aggregate-query.js +50 -0
  104. package/dist/utils/parent-scope.d.ts +123 -0
  105. package/dist/utils/parent-scope.js +140 -0
  106. package/dist/utils/predicate-fields.d.ts +45 -12
  107. package/dist/utils/predicate-fields.js +137 -12
  108. package/dist/utils/record-color.d.ts +75 -0
  109. package/dist/utils/record-color.js +65 -0
  110. package/dist/utils/record-source.d.ts +298 -0
  111. package/dist/utils/record-source.js +331 -0
  112. package/dist/utils/record-title.d.ts +148 -8
  113. package/dist/utils/record-title.js +220 -26
  114. package/dist/utils/reference-keys.d.ts +21 -22
  115. package/dist/utils/reference-keys.js +532 -29
  116. package/dist/utils/sort-query.d.ts +115 -15
  117. package/dist/utils/sort-query.js +240 -13
  118. package/dist/utils/text-comparand.d.ts +92 -0
  119. package/dist/utils/text-comparand.js +99 -0
  120. package/dist/utils/without-denied-fields.d.ts +49 -0
  121. package/dist/utils/without-denied-fields.js +52 -0
  122. package/dist/validation/index.d.ts +8 -4
  123. package/dist/validation/index.js +8 -4
  124. package/dist/validation/schema-validator.d.ts +1 -1
  125. package/dist/validation/schema-validator.js +200 -82
  126. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  127. package/dist/validation/validators/object-validation-engine.js +14 -3
  128. package/package.json +8 -9
  129. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  130. package/dist/data-scope/DataScopeManager.js +0 -229
  131. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  132. package/dist/protocols/ResponsiveProtocol.js +0 -158
  133. package/dist/validation/validation-engine.d.ts +0 -88
  134. package/dist/validation/validation-engine.js +0 -434
@@ -18,6 +18,77 @@ import { ExpressionContext } from './ExpressionContext.js';
18
18
  import { ExpressionCache } from './ExpressionCache.js';
19
19
  import { FormulaFunctions } from './FormulaFunctions.js';
20
20
  import { evalFieldPredicate } from './fieldRules.js';
21
+ import { isBlankPredicateText } from './declaredPredicate.js';
22
+ import { isUnevaluablePredicate, UNEVALUABLE_PREDICATE_REASON } from './unevaluablePredicate.js';
23
+ /**
24
+ * Authored sources already reported by {@link reportEvaluationFault}, keyed on
25
+ * `[site, source]`.
26
+ *
27
+ * ## Why a rate limit at all (objectui#6444)
28
+ *
29
+ * `evaluate` is the hottest of the three predicate paths in this area:
30
+ * `SchemaRenderer` calls it for every `properties.*` value, every `props.*`
31
+ * value and `content`, for every node, on every render — and both sites below
32
+ * used to log on EVERY evaluation. Measured on the built evaluator at
33
+ * `830ed5803`: three identical faulting `evaluateCondition` calls produced
34
+ * **3** console lines where the `{ dialect: 'cel' }` envelope produced **1**,
35
+ * and one broken `${…}` prop rendered across a 200-row list produced **200**
36
+ * lines — then 200 more on the next render.
37
+ *
38
+ * This is the one-per-source ceiling both sibling reporters already carry, not
39
+ * a third mechanism: `warnPredicateFailure` in `./fieldRules.js` (keyed on
40
+ * dialect + source) and `visibilityDiagnostic.ts` in `@object-ui/react` (keyed
41
+ * on type + key + source).
42
+ *
43
+ * ## Why the key is the source TEXT, and never the scope
44
+ *
45
+ * Both siblings key on the AUTHORING identity of the predicate, and neither
46
+ * keys on the data it was evaluated against: "a broken predicate is
47
+ * re-evaluated on every render/keystroke, and the point is one loud line, not
48
+ * a scrolling wall". Here that precedent is also the defect itself — the
49
+ * 200-row flood is ONE authored source evaluated against 200 DIFFERENT scopes,
50
+ * so a scope-sensitive key reproduces all 200 lines and fixes nothing.
51
+ *
52
+ * The key is JSON-encoded, never joined with a control character — that is
53
+ * what made a sibling of this file binary to grep (objectstack#5450).
54
+ *
55
+ * Unbounded, exactly like both siblings: entries are distinct AUTHORED
56
+ * expressions, a count an app's metadata bounds; a row count does not.
57
+ */
58
+ const warnedEvaluationFaults = new Set();
59
+ /**
60
+ * One fault, one report: hand the reason to the caller's {@link
61
+ * EvaluationOptions.onFault} when it supplied one, otherwise fall back to this
62
+ * class's historical `console.warn` — now at most once per authored source.
63
+ *
64
+ * Kept as a module-local function rather than repeating the ternary at each
65
+ * catch, so the "supplying `onFault` suppresses the built-in line" rule is
66
+ * stated once and cannot drift between the three sites that implement it. The
67
+ * two message texts moved in here for the same reason: the dedupe key and the
68
+ * line a reader sees are now built from the same `source`, so the stated
69
+ * ceiling cannot drift from what the console actually prints.
70
+ *
71
+ * The rate limit governs the built-in line ONLY. `onFault` still fires on
72
+ * every fault, matching the passback contract `./fieldRules.js` already
73
+ * documents for the canonical engine — "independent of … the one-time-warning
74
+ * dedupe: it fires on every fault, so a caller doing its own warn-once
75
+ * bookkeeping keeps control of it". A caller that reports per node must not
76
+ * have its faults swallowed by this module's bookkeeping.
77
+ */
78
+ function reportEvaluationFault(onFault, site, source, error) {
79
+ const reason = error instanceof Error ? error.message : String(error);
80
+ if (onFault) {
81
+ onFault(reason);
82
+ return;
83
+ }
84
+ const key = JSON.stringify([site, source]);
85
+ if (warnedEvaluationFaults.has(key))
86
+ return;
87
+ warnedEvaluationFaults.add(key);
88
+ console.warn(site === 'template-part'
89
+ ? `Expression evaluation failed for: ${source}`
90
+ : `Failed to evaluate expression: ${source}`, error);
91
+ }
21
92
  /**
22
93
  * Expression evaluator for dynamic UI expressions
23
94
  */
@@ -73,7 +144,7 @@ export class ExpressionEvaluator {
73
144
  if (typeof expression !== 'string') {
74
145
  return expression;
75
146
  }
76
- const { defaultValue, throwOnError = false, sanitize = true } = options;
147
+ const { defaultValue, throwOnError = false, sanitize = true, onFault } = options;
77
148
  try {
78
149
  // Check if string contains template expressions
79
150
  const hasTemplates = expression.includes('${');
@@ -96,7 +167,7 @@ export class ExpressionEvaluator {
96
167
  if (throwOnError) {
97
168
  throw error;
98
169
  }
99
- console.warn(`Expression evaluation failed for: ${expr}`, error);
170
+ reportEvaluationFault(onFault, 'template-part', expr, error);
100
171
  return match; // Return original if evaluation fails
101
172
  }
102
173
  });
@@ -105,7 +176,7 @@ export class ExpressionEvaluator {
105
176
  if (throwOnError) {
106
177
  throw error;
107
178
  }
108
- console.warn(`Failed to evaluate expression: ${expression}`, error);
179
+ reportEvaluationFault(onFault, 'whole-expression', expression, error);
109
180
  return defaultValue ?? expression;
110
181
  }
111
182
  }
@@ -182,6 +253,18 @@ export class ExpressionEvaluator {
182
253
  if (typeof condition === 'boolean') {
183
254
  return condition;
184
255
  }
256
+ // objectui#11358 — a gate that is DECLARED but has nothing to evaluate: an
257
+ // envelope with no string `source` (`{ dialect: 'cel', ast }`, or the
258
+ // `{ dialect: 'cel' }` the normalizer now returns for every such value), a
259
+ // number, `{}`, an array. It is a FAULT, answered on the same terms as a
260
+ // predicate that faults with text in it: thrown to a `throwOnError` caller,
261
+ // whose catch is its fail-closed answer and its report, and otherwise
262
+ // reported once and answered with the fail-soft `true`. Before, these fell
263
+ // through to `if (!condition)` / `Boolean(condition)` below and came back
264
+ // `true` in silence — the "silent `true`" ADR-0137 D4 forbids.
265
+ if (isUnevaluablePredicate(condition)) {
266
+ return this.answerUnevaluableGate(condition, options);
267
+ }
185
268
  // #2661 — a CEL-dialect envelope routes to the canonical `@objectstack/formula`
186
269
  // engine (the one `fieldRules` / list conditionals already use), NOT the legacy
187
270
  // JS evaluator below. This makes a component / action `visible` / `disabled`
@@ -198,17 +281,30 @@ export class ExpressionEvaluator {
198
281
  if (condition && typeof condition === 'object' && typeof condition.source === 'string') {
199
282
  condition = condition.source;
200
283
  }
201
- // No condition → default to visible/enabled (undefined, null, '').
284
+ // A BLANK gate — `''`, whitespace-only text, or an envelope whose `source`
285
+ // is either (unwrapped just above) — is "no gate": visible/enabled, as it
286
+ // always was here. It is answered by the SAME diagnosed guard the
287
+ // `{ dialect: 'cel' }` route above uses (objectui#11262, ADR-0137 D4: a
288
+ // blank gate predicate is "diagnosed, never a silent `true`"), so a bare
289
+ // blank, a dialect-less blank envelope and a blank CEL envelope are one
290
+ // report on one dedupe key. This used to be two silent returns — an
291
+ // `if (!condition)` and a whitespace-only `trim()` — on the path
292
+ // `SchemaRenderer`'s visibility legs reach with the raw authored value.
293
+ if (isBlankPredicateText(condition)) {
294
+ return this.answerBlankGate(condition, options);
295
+ }
296
+ // No condition at all → default to visible/enabled (`undefined`, `null`).
297
+ // Not blank TEXT, so nothing was declared here to report. Every other
298
+ // non-string value was answered above, as unevaluable (objectui#11358).
202
299
  if (!condition) {
203
300
  return true;
204
301
  }
205
302
  if (typeof condition !== 'string') {
303
+ // Unreachable since objectui#11358 — kept so a future shape that slips
304
+ // past `isUnevaluablePredicate` cannot reach the string code below.
206
305
  return Boolean(condition);
207
306
  }
208
307
  const trimmed = condition.trim();
209
- if (!trimmed) {
210
- return true; // Whitespace-only → treat as "no condition".
211
- }
212
308
  // A condition is semantically a single boolean expression. When it's a
213
309
  // `${...}` template, evaluate via the template path. Otherwise treat the
214
310
  // ENTIRE string as one expression (bare CEL like `record.status == "x"`):
@@ -229,6 +325,17 @@ export class ExpressionEvaluator {
229
325
  if (options.throwOnError) {
230
326
  throw error;
231
327
  }
328
+ // objectui#6038 — the dialect that reported NOTHING. Measured on the
329
+ // built evaluator against the other two: a `{ dialect: 'cel' }` envelope
330
+ // already warns here in production (`evalFieldPredicate`, deduped per
331
+ // source) and a `${…}` template already warns (the generic line above),
332
+ // while a BARE-STRING predicate that faults returned its fail-soft `true`
333
+ // in complete silence. That is the dialect objectstack#11254 measured a
334
+ // real gate breaking on, and the reason a node gate could stop biting
335
+ // with nothing on the console to say so. `onFault` is the only new
336
+ // channel: absent it this catch behaves exactly as it always has, so no
337
+ // existing caller's console output moves.
338
+ options.onFault?.(error instanceof Error ? error.message : String(error));
232
339
  return true;
233
340
  }
234
341
  }
@@ -242,22 +349,30 @@ export class ExpressionEvaluator {
242
349
  * syntax) throws; a genuine `false` never throws.
243
350
  */
244
351
  evaluateCelCondition(source, options) {
245
- if (!source.trim())
246
- return true; // no predicate → visible/enabled
352
+ if (isBlankPredicateText(source))
353
+ return this.answerBlankGate(source, options);
247
354
  const bag = this.context.toObject();
248
355
  const rec = bag.record;
249
356
  const record = (rec && typeof rec === 'object' && !Array.isArray(rec))
250
357
  ? rec
251
358
  : bag;
252
359
  if (!options.throwOnError) {
253
- // Fast path: one evaluation, fail-soft to visible/enabled (legacy parity).
254
- return evalFieldPredicate(source, record, true, undefined, bag);
360
+ // Fast path: ONE evaluation, fail-soft to visible/enabled (legacy parity).
361
+ //
362
+ // objectui#6038: when the caller passes `onFault`, forward it to the seam
363
+ // `evalFieldPredicate` already exposes for exactly this — `warn: false`
364
+ // plus the reason passback — rather than adding a second reporter. The
365
+ // caller then emits one line that can name the node; without `onFault`
366
+ // the built-in warning fires exactly as before. Either way this stays a
367
+ // SINGLE engine call: the `throwOnError` double-evaluation below is what
368
+ // this branch exists to avoid paying in production.
369
+ return evalFieldPredicate(source, record, true, undefined, bag, options.onFault ? { warn: false, onFault: options.onFault } : undefined);
255
370
  }
256
371
  // Fail-closed callers need to tell a genuine `false` from a fault. The
257
372
  // canonical helper fails soft to the fallback, so a value that tracks the
258
373
  // fallback in BOTH runs means the predicate faulted — then we throw.
259
374
  // `warn: false`: the throw below IS this path's diagnostic; without it one
260
- // broken predicate would log AND throw (#5149).
375
+ // broken predicate would log AND throw (objectstack-ai/objectstack#5149).
261
376
  const asTrue = evalFieldPredicate(source, record, true, undefined, bag, { warn: false });
262
377
  const asFalse = evalFieldPredicate(source, record, false, undefined, bag, { warn: false });
263
378
  if (asTrue !== asFalse) {
@@ -265,6 +380,63 @@ export class ExpressionEvaluator {
265
380
  }
266
381
  return asTrue;
267
382
  }
383
+ /**
384
+ * Answer a BLANK gate predicate — the ONE diagnosed guard of
385
+ * {@link evaluateCondition}, on both of its routes (objectui#8069 put it on
386
+ * the `{ dialect: 'cel' }` route; objectui#11262 routes the legacy path's
387
+ * blank here too, so the two are one report rather than two).
388
+ *
389
+ * The verdict is `true` — no predicate → visible/enabled, exactly as before
390
+ * (objectui#3850 / #3960's verdict) — in EVERY mode, `throwOnError` included,
391
+ * since the author wrote nothing that could fault. What it is not is SILENT
392
+ * (ADR-0137 D4: a blank gate predicate is "diagnosed, never a silent
393
+ * `true`"). The report is `evalFieldPredicate`'s own `[blank]` one — the
394
+ * channel every other predicate fault already uses — so a caller that passed
395
+ * `onFault` receives it there (and prints its own, node-named line), and
396
+ * every other caller gets the deduped built-in warning. No engine call is
397
+ * made: a blank predicate never reaches the engine.
398
+ *
399
+ * The locator names a CEL gate on both routes: a blank has no syntax to put
400
+ * it in either dialect, and a bare-string predicate is CEL by the spec's
401
+ * contract. One locator is also what keeps `''` and `{ dialect: 'cel',
402
+ * source: '' }` on one dedupe key.
403
+ */
404
+ answerBlankGate(text, options) {
405
+ evalFieldPredicate(text, {}, true, undefined, undefined, options.onFault
406
+ ? { warn: false, onFault: options.onFault }
407
+ : { context: 'a CEL gate predicate, read as no gate' });
408
+ return true;
409
+ }
410
+ /**
411
+ * Answer a gate that is DECLARED but cannot be evaluated (objectui#11358) —
412
+ * see `isUnevaluablePredicate` (`./unevaluablePredicate.ts`) for the shapes.
413
+ *
414
+ * It is a fault, not a blank: {@link answerBlankGate} answers `true` in every
415
+ * mode because a blank says nothing, while this value says "there is a gate"
416
+ * and the client cannot read it. So the modes split exactly as they do for a
417
+ * predicate that faults with text in it:
418
+ *
419
+ * - `throwOnError` → THROW. The caller opted into fail-closed semantics,
420
+ * and its catch both hides / disables and reports, naming the control —
421
+ * the same contract `evaluateCelCondition` keeps (no warning AND a throw
422
+ * for one fault, objectstack-ai/objectstack#5149).
423
+ * - otherwise → reported once through `evalFieldPredicate`'s own report
424
+ * (the `[unevaluable]` reason, beside `[blank]`), or handed to the
425
+ * caller's `onFault`, and answered with the fail-soft `true`. On a
426
+ * `disabled` / `hidden` leg that `true` is the CLOSED side (greyed out /
427
+ * hidden), on `visible` / `enabled` / `condition` the open one — each
428
+ * key's existing fault direction, unchanged.
429
+ *
430
+ * No engine call is made: there is no `source` to hand it.
431
+ */
432
+ answerUnevaluableGate(condition, options) {
433
+ if (options.throwOnError) {
434
+ throw new Error(`Gate predicate cannot be evaluated: ${UNEVALUABLE_PREDICATE_REASON}`);
435
+ }
436
+ return evalFieldPredicate(condition, {}, true, undefined, undefined, options.onFault
437
+ ? { warn: false, onFault: options.onFault }
438
+ : { context: 'a declared gate with nothing to evaluate' });
439
+ }
268
440
  /**
269
441
  * Update the context with new data
270
442
  */
@@ -5,16 +5,6 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- /**
9
- * @object-ui/core - Formula Functions
10
- *
11
- * Built-in formula functions for the expression engine.
12
- * Provides aggregation, date, logic, and string functions
13
- * compatible with low-code platform expression evaluation.
14
- *
15
- * @module evaluator
16
- * @packageDocumentation
17
- */
18
8
  /**
19
9
  * A formula function that can be registered with the expression evaluator
20
10
  */
@@ -5,6 +5,99 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ /**
9
+ * @object-ui/core - Formula Functions
10
+ *
11
+ * Built-in formula functions for the expression engine.
12
+ * Provides aggregation, date, logic, and string functions
13
+ * compatible with low-code platform expression evaluation.
14
+ *
15
+ * @module evaluator
16
+ * @packageDocumentation
17
+ */
18
+ import { isRealCalendarDate } from '../utils/date-display.js';
19
+ /** Read one date argument, or throw the function's named error. */
20
+ function readFormulaDate(fn, value) {
21
+ const date = new Date(value);
22
+ if (isNaN(date.getTime())) {
23
+ throw new Error(`${fn}: Invalid date "${value}"`);
24
+ }
25
+ return { date, day: typeof value === 'string' && isRealCalendarDate(value) };
26
+ }
27
+ /**
28
+ * The calendar fields of one argument. A day's are read with UTC getters,
29
+ * which read back the day it names in every zone; an instant's are read with
30
+ * local getters, in the zone the formula runs in, as they always were.
31
+ */
32
+ function calendarFields({ date, day }) {
33
+ return day
34
+ ? {
35
+ year: date.getUTCFullYear(),
36
+ month: date.getUTCMonth(),
37
+ date: date.getUTCDate(),
38
+ hours: date.getUTCHours(),
39
+ minutes: date.getUTCMinutes(),
40
+ seconds: date.getUTCSeconds(),
41
+ }
42
+ : {
43
+ year: date.getFullYear(),
44
+ month: date.getMonth(),
45
+ date: date.getDate(),
46
+ hours: date.getHours(),
47
+ minutes: date.getMinutes(),
48
+ seconds: date.getSeconds(),
49
+ };
50
+ }
51
+ /**
52
+ * Move `date` by `months` calendar months on the UTC calendar, clamping the
53
+ * day to the target month's last day: January 31st plus one month is February
54
+ * 28th (29th in a leap year), never an overflow into March.
55
+ *
56
+ * This is the server's rule, objectstack `@objectstack/formula`'s stdlib
57
+ * `addMonthsUtc` behind CEL `addMonths(d, n)`, written the same way: step to
58
+ * the 1st, move the month, then clamp the day. Only a calendar day is moved
59
+ * with it; an instant keeps `setMonth`, which it always had.
60
+ */
61
+ function addMonthsUtc(date, months) {
62
+ const day = date.getUTCDate();
63
+ date.setUTCDate(1);
64
+ date.setUTCMonth(date.getUTCMonth() + months);
65
+ const lastDay = new Date(date.getTime());
66
+ lastDay.setUTCMonth(lastDay.getUTCMonth() + 1, 0);
67
+ date.setUTCDate(Math.min(day, lastDay.getUTCDate()));
68
+ }
69
+ /**
70
+ * `DATEADD` on a calendar day, on the UTC calendar: days, months and years
71
+ * hand the day back as `YYYY-MM-DD`; hours and minutes give an instant, UTC
72
+ * midnight of the day moved. `undefined` for a unit `DATEADD` does not know.
73
+ */
74
+ function addToDay(date, amount, normalizedUnit) {
75
+ switch (normalizedUnit) {
76
+ case 'day':
77
+ case 'days':
78
+ date.setUTCDate(date.getUTCDate() + amount);
79
+ break;
80
+ case 'month':
81
+ case 'months':
82
+ addMonthsUtc(date, amount);
83
+ break;
84
+ case 'year':
85
+ case 'years':
86
+ addMonthsUtc(date, amount * 12);
87
+ break;
88
+ case 'hour':
89
+ case 'hours':
90
+ date.setUTCHours(date.getUTCHours() + amount);
91
+ return date.toISOString();
92
+ case 'minute':
93
+ case 'minutes':
94
+ date.setUTCMinutes(date.getUTCMinutes() + amount);
95
+ return date.toISOString();
96
+ default:
97
+ return undefined;
98
+ }
99
+ return date.toISOString().slice(0, 10);
100
+ }
8
101
  /**
9
102
  * Registry of built-in formula functions
10
103
  */
@@ -98,6 +191,24 @@ export class FormulaFunctions {
98
191
  // Date Functions
99
192
  // ==========================================================================
100
193
  registerDateFunctions() {
194
+ /**
195
+ * `TODAY()` — the current calendar day, as a date-only `YYYY-MM-DD` string.
196
+ *
197
+ * Which day that is, is ruled (objectui#10903, ruling A): a client-evaluated
198
+ * `TODAY()` names the same reference day the server resolves for that
199
+ * user, on the compute-tz axis of objectstack ADR-0053
200
+ * (`docs/adr/0053-date-and-datetime-semantics.md`, Phase 2 items 5 and 6),
201
+ * and the UTC day while no reference timezone reaches the client. The aim
202
+ * is one "today" per execution context: a client preview and the server's
203
+ * `today()` naming the same day.
204
+ *
205
+ * This implementation reads no timezone, so it answers the UTC day
206
+ * (`toISOString()`), which is the ruled fallback. It is deliberately not
207
+ * the viewer's local day. Until the reference timezone reaches the client
208
+ * (a later card), a server that resolves a reference timezone other than
209
+ * UTC can name a different day for part of every day. Once the timezone
210
+ * reaches the client, `TODAY()` names that timezone's calendar day instead.
211
+ */
101
212
  this.register('TODAY', () => {
102
213
  const now = new Date();
103
214
  return now.toISOString().split('T')[0];
@@ -105,12 +216,46 @@ export class FormulaFunctions {
105
216
  this.register('NOW', () => {
106
217
  return new Date().toISOString();
107
218
  });
219
+ /**
220
+ * `DATEADD`, `DATEDIFF` and `DATEFORMAT` do a calendar DAY's arithmetic on
221
+ * the UTC calendar, the server's (objectstack ADR-0053 D1: a calendar day
222
+ * is UTC midnight of that day), with UTC setters and getters, so what they
223
+ * answer for a day does not depend on the zone the formula runs in
224
+ * (objectui#10866, slice 3). A day is a date-only argument naming a day
225
+ * its month has.
226
+ *
227
+ * - A day is read as UTC midnight of that day. `DATEADD` hands a day moved
228
+ * by days, months or years back as `YYYY-MM-DD`; hours and minutes on a
229
+ * day give an instant, UTC midnight of the day moved.
230
+ * - A day's months clamp to the target month's last day, as the server's
231
+ * `addMonths` does; a year is twelve months on the same rule, so
232
+ * February 29th plus a year is February 28th.
233
+ * - `DATEFORMAT` prints a day as itself, and `DATEDIFF` counts months and
234
+ * years between two days' own months and years.
235
+ * - A mixed `DATEDIFF`, one day and one instant, reads each argument by its
236
+ * own kind when it counts months or years: the day on the UTC calendar,
237
+ * the instant in the zone the formula runs in (in a browser, the viewer's).
238
+ * - Anything else, a value with a time part included, is unchanged: it is
239
+ * moved and read with local setters and getters, in the zone the formula
240
+ * runs in, and `DATEADD` hands it back as an instant (`toISOString()`).
241
+ *
242
+ * A day used to take that same local path: west of UTC `DATEFORMAT`
243
+ * printed a date-only value as the day before, `DATEDIFF` in months from
244
+ * August 31st to September 1st read 0, and a `DATEADD` of a day across a
245
+ * DST change moved an hour off midnight; and a month from January 31st
246
+ * overflowed into March in every zone. Pinned in both directions by
247
+ * `__tests__/FormulaFunctions.dateOnlyZone-10866.test.ts`.
248
+ */
108
249
  this.register('DATEADD', (dateStr, amount, unit) => {
109
- const date = new Date(dateStr);
110
- if (isNaN(date.getTime())) {
111
- throw new Error(`DATEADD: Invalid date "${dateStr}"`);
112
- }
250
+ const { date, day } = readFormulaDate('DATEADD', dateStr);
113
251
  const normalizedUnit = String(unit).toLowerCase();
252
+ if (day) {
253
+ const moved = addToDay(date, amount, normalizedUnit);
254
+ if (moved === undefined) {
255
+ throw new Error(`DATEADD: Unsupported unit "${unit}"`);
256
+ }
257
+ return moved;
258
+ }
114
259
  switch (normalizedUnit) {
115
260
  case 'day':
116
261
  case 'days':
@@ -138,26 +283,23 @@ export class FormulaFunctions {
138
283
  return date.toISOString();
139
284
  });
140
285
  this.register('DATEDIFF', (dateStr1, dateStr2, unit) => {
141
- const date1 = new Date(dateStr1);
142
- const date2 = new Date(dateStr2);
143
- if (isNaN(date1.getTime())) {
144
- throw new Error(`DATEDIFF: Invalid date "${dateStr1}"`);
145
- }
146
- if (isNaN(date2.getTime())) {
147
- throw new Error(`DATEDIFF: Invalid date "${dateStr2}"`);
148
- }
149
- const diffMs = date2.getTime() - date1.getTime();
286
+ const first = readFormulaDate('DATEDIFF', dateStr1);
287
+ const second = readFormulaDate('DATEDIFF', dateStr2);
288
+ const diffMs = second.date.getTime() - first.date.getTime();
150
289
  const normalizedUnit = String(unit).toLowerCase();
151
290
  switch (normalizedUnit) {
152
291
  case 'day':
153
292
  case 'days':
154
293
  return Math.floor(diffMs / (1000 * 60 * 60 * 24));
155
294
  case 'month':
156
- case 'months':
157
- return (date2.getFullYear() - date1.getFullYear()) * 12 + (date2.getMonth() - date1.getMonth());
295
+ case 'months': {
296
+ const a = calendarFields(first);
297
+ const b = calendarFields(second);
298
+ return (b.year - a.year) * 12 + (b.month - a.month);
299
+ }
158
300
  case 'year':
159
301
  case 'years':
160
- return date2.getFullYear() - date1.getFullYear();
302
+ return calendarFields(second).year - calendarFields(first).year;
161
303
  case 'hour':
162
304
  case 'hours':
163
305
  return Math.floor(diffMs / (1000 * 60 * 60));
@@ -169,19 +311,16 @@ export class FormulaFunctions {
169
311
  }
170
312
  });
171
313
  this.register('DATEFORMAT', (dateStr, format) => {
172
- const date = new Date(dateStr);
173
- if (isNaN(date.getTime())) {
174
- throw new Error(`DATEFORMAT: Invalid date "${dateStr}"`);
175
- }
314
+ const fields = calendarFields(readFormulaDate('DATEFORMAT', dateStr));
176
315
  const pad = (n, len = 2) => String(n).padStart(len, '0');
177
316
  return format
178
- .replace('YYYY', String(date.getFullYear()))
179
- .replace('YY', String(date.getFullYear()).slice(-2))
180
- .replace('MM', pad(date.getMonth() + 1))
181
- .replace('DD', pad(date.getDate()))
182
- .replace('HH', pad(date.getHours()))
183
- .replace('mm', pad(date.getMinutes()))
184
- .replace('ss', pad(date.getSeconds()));
317
+ .replace('YYYY', String(fields.year))
318
+ .replace('YY', String(fields.year).slice(-2))
319
+ .replace('MM', pad(fields.month + 1))
320
+ .replace('DD', pad(fields.date))
321
+ .replace('HH', pad(fields.hours))
322
+ .replace('mm', pad(fields.minutes))
323
+ .replace('ss', pad(fields.seconds));
185
324
  });
186
325
  }
187
326
  // ==========================================================================
@@ -5,6 +5,34 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ /**
9
+ * Is the predicate TEXT blank — in EITHER spelling? (objectui#3960)
10
+ *
11
+ * `' '` and `{ dialect: 'cel', source: ' ' }` are the same author mistake
12
+ * written two ways, and {@link toPredicateInput} folds neither: it wraps a
13
+ * whitespace-only string into `'${ }'`, and it only folds an envelope whose
14
+ * `source` is exactly `''`. So blankness cannot be derived from the normalizer's
15
+ * answer and has to be stated once, here, for both spellings — trimming one and
16
+ * not the other is the asymmetry objectui#3960 measured (`disabled` greyed out,
17
+ * `ActionRunner` refused to execute, for a predicate that says nothing).
18
+ *
19
+ * This is not a fourth dialect of "empty": it is the SAME rule core's evaluation
20
+ * entries already apply on the value side — `evaluateCondition` on both of its
21
+ * routes (which ask THIS function: the CEL route since objectui#8069, the
22
+ * legacy path since objectui#11262), `evalRowPredicate` (`listConditional.ts`,
23
+ * whose blank reaches `evalFieldPredicate`, which asks it too) — brought to the
24
+ * one place that answers "is there a condition at all?", so the two halves
25
+ * cannot disagree about the same blank.
26
+ *
27
+ * Exported since objectui#8069 for its second consumer, `evalFieldPredicate`
28
+ * (`evaluator/fieldRules.ts`), which is the entry this docblock's list did NOT
29
+ * name because it did not apply the rule: it trimmed the STRING spelling only,
30
+ * so `{ dialect: 'cel', source: ' ' }` went to the engine and came back as a
31
+ * parse fault while `' '` returned the caller's fallback in silence. Reusing
32
+ * this definition rather than writing a fourth `trim()` is the whole point of
33
+ * the paragraph above.
34
+ */
35
+ export declare function isBlankPredicateText(value: unknown): boolean;
8
36
  /**
9
37
  * Is a predicate gate DECLARED on this value — i.e. after normalization, is
10
38
  * there still a CONDITION for {@link ExpressionEvaluator.evaluateCondition} to
@@ -38,18 +66,55 @@
38
66
  * Neither can be read off the normalizer's answer, so both are named here —
39
67
  * see {@link isBlankPredicateText} for why this is the layer that says it.
40
68
  * Core's other predicate entries already treat both as blank:
41
- * `evaluateCondition` (`if (!trimmed) return true`), `evaluateCelCondition`
42
- * (`if (!source.trim()) return true`) and `evalRowPredicate`
43
- * (`evaluator/listConditional.ts`, `if (!source.trim())`).
69
+ * `evaluateCondition` (`isBlankPredicateText`, then `true` — diagnosed on
70
+ * the CEL route since objectui#8069 and on the legacy path since
71
+ * objectui#11262) and `evalRowPredicate` (`evaluator/listConditional.ts`,
72
+ * the caller's fallback, through `evalFieldPredicate`'s `[blank]` report
73
+ * since objectui#11262).
44
74
  * - `{ dialect, source: '' }` — the empty ENVELOPE. This is not an exotic
45
75
  * spelling: `@objectstack/spec`'s `ExpressionInputSchema` normalizes every
46
76
  * authored predicate into an envelope, so "author left the predicate empty"
47
77
  * compiles to exactly this, which makes it the likeliest empty shape in real
48
78
  * metadata (objectui#3850).
49
- * - anything that is not a predicate at all (`0`, `{}`, an array): a value the
50
- * evaluator cannot read must not be the reason a control is disabled or an
51
- * action refuses to run. Fail-open on junk, which is the posture
52
- * `ActionRunner` already committed to (`catch { isDisabled = false }`).
79
+ *
80
+ * The three BLANK shapes above (`''`, whitespace-only text, and an envelope
81
+ * whose `source` is blank) are still folded to "not declared" — that is the
82
+ * verdict — but no longer in silence: each is reported once (objectui#8069,
83
+ * ADR-0137 D4), see {@link reportBlankGate}. `null` / `undefined` (no key) is
84
+ * not blank TEXT and stays silent: nothing was declared there to report.
85
+ *
86
+ * ## What is DECLARED although nothing in it can be evaluated (objectui#11358)
87
+ *
88
+ * A value that is present but is not a predicate the client can evaluate — an
89
+ * envelope with no string `source` (the `ast`-only `{ dialect: 'cel', ast }` an
90
+ * unmirrored authoring door admits), a number (`0`), an object with no `source`
91
+ * (`{}`), an array — IS a declared gate. Triage's ruling on objectui#11358:
92
+ * "declared and faulting", "reported once per predicate", and the action
93
+ * "hides". It used to be the fifth "nothing to evaluate" shape here, answered
94
+ * "not declared" in silence, so a bulk action gated that way ran over every
95
+ * selected record and the toolbars and row menu showed it to everyone.
96
+ *
97
+ * This function's CODE did not change for it: the normalizer did.
98
+ * {@link toPredicateInput} no longer folds such a value into `undefined`; it
99
+ * returns a `cel` envelope with no `source`, so `toPredicateInput(x) !==
100
+ * undefined` — the line below — answers "declared", and the derivation this
101
+ * definition rests on still holds with no second list. The state is told apart
102
+ * in one place, `isUnevaluablePredicate` (`./unevaluablePredicate.ts`).
103
+ *
104
+ * What a reader then DOES with it is the evaluation step's, not this
105
+ * function's: `ExpressionEvaluator.evaluateCondition` and `evalFieldPredicate`
106
+ * treat it as a FAULT, so each reader answers with the fail direction its key
107
+ * already has for a predicate that cannot be evaluated, and reports it there,
108
+ * once — through `evalFieldPredicate`'s report with the `[unevaluable]` reason,
109
+ * or through the caller's own fault report. That is why this function reports
110
+ * nothing for it: a declared gate always goes on to an evaluation, and two
111
+ * reports for one fault is the noise objectstack-ai/objectstack#5149 removed.
112
+ * On every action `visible` leg that direction is CLOSED (objectui#11212), on
113
+ * every `disabled` leg too (objectui#11242), and the hide-polarity `hidden` /
114
+ * `hiddenOn` legs of `SchemaRenderer` answer "hidden". The keys whose fault
115
+ * direction is OPEN keep it — a `condition` still lets the action run, a
116
+ * legacy `enabled` leaves the control enabled, an action param's `visible`
117
+ * still shows the param — now diagnosed rather than silent.
53
118
  *
54
119
  * A declared-and-`false` gate is DECLARED (`toPredicateInput` returns the
55
120
  * boolean unchanged): `disabled: false` / `visible: false` are verdicts, and
@@ -113,7 +178,26 @@
113
178
  * an empty predicate made the node VANISH (objectui#3955), and
114
179
  * `ActionEngine.getActionsForLocation`'s `visible` filter, which folded some
115
180
  * empty spellings by hand and coerced the rest with `Boolean(raw)`, so
116
- * `visible: 0` hid an action the renderers showed (objectui#3957). Nothing in the
117
- * repo now asks "is a gate declared?" anywhere but here.
181
+ * `visible: 0` hid an action the renderers showed (objectui#3957).
182
+ *
183
+ * The last one joined with objectui#11322: plugin-grid's `hasVisibilityGate`,
184
+ * on the grid's selection bar, asks it through the renderer-side name
185
+ * `hasDeclaredVisibilityGate`. It decides whether a bulk button whose `visible`
186
+ * admits no record is hidden, and `partitionBulkRows` asks it before handing a
187
+ * bulk def's `visible` to `partitionRowsByPredicate`. It had kept the old
188
+ * `!= null && !== ''` scope after every other member of the family moved here,
189
+ * so a whitespace-only `visible` counted as declared on that bar, failed closed
190
+ * for every record, and hid an action the row menu and toolbars showed.
191
+ *
192
+ * Nothing in the repo now asks "is a gate declared?" anywhere but here for
193
+ * the keys this definition serves. What stays outside is a different family,
194
+ * not a twin: the field-rule `*When` keys (ADR-0137) decide it by rules of
195
+ * their own. Examples are the built-in Edit / Delete row item's
196
+ * `visibleWhen != null` (`isBuiltinRowActionVisible`), and the opening test of
197
+ * the shared partitioner `partitionRowsByPredicate` (`null` / `undefined` /
198
+ * `''`), which is the answer its built-in Delete `visibleWhen` callers get. The
199
+ * action family asks here before it calls that partitioner.
200
+ * ⚠️ Nothing re-derives this paragraph: it was read off a source search when
201
+ * objectui#11322 landed, and no test or gate repeats that search.
118
202
  */
119
203
  export declare function hasDeclaredPredicate(value: unknown): boolean;