@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
@@ -6,6 +6,10 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { toPredicateInput } from './predicateInput.js';
9
+ // A cycle with `fieldRules.ts`, which imports `isBlankPredicateText` from here.
10
+ // Benign by construction: each side reads the other only inside a function
11
+ // body, never while its module is being evaluated.
12
+ import { evalFieldPredicate } from './fieldRules.js';
9
13
  /**
10
14
  * Is the predicate TEXT blank — in EITHER spelling? (objectui#3960)
11
15
  *
@@ -18,13 +22,22 @@ import { toPredicateInput } from './predicateInput.js';
18
22
  * `ActionRunner` refused to execute, for a predicate that says nothing).
19
23
  *
20
24
  * This is not a fourth dialect of "empty": it is the SAME rule core's evaluation
21
- * entries already apply on the value side — `evaluateCondition`
22
- * (`if (!trimmed) return true`), `evaluateCelCondition` (`if (!source.trim())
23
- * return true`), `evalRowPredicate` (`listConditional.ts`) — brought to the one
24
- * place that answers "is there a condition at all?", so the two halves cannot
25
- * disagree about the same blank.
25
+ * entries already apply on the value side — `evaluateCondition` on both of its
26
+ * routes (which ask THIS function: the CEL route since objectui#8069, the
27
+ * legacy path since objectui#11262), `evalRowPredicate` (`listConditional.ts`,
28
+ * whose blank reaches `evalFieldPredicate`, which asks it too) — brought to the
29
+ * one place that answers "is there a condition at all?", so the two halves
30
+ * cannot disagree about the same blank.
31
+ *
32
+ * Exported since objectui#8069 for its second consumer, `evalFieldPredicate`
33
+ * (`evaluator/fieldRules.ts`), which is the entry this docblock's list did NOT
34
+ * name because it did not apply the rule: it trimmed the STRING spelling only,
35
+ * so `{ dialect: 'cel', source: ' ' }` went to the engine and came back as a
36
+ * parse fault while `' '` returned the caller's fallback in silence. Reusing
37
+ * this definition rather than writing a fourth `trim()` is the whole point of
38
+ * the paragraph above.
26
39
  */
27
- function isBlankPredicateText(value) {
40
+ export function isBlankPredicateText(value) {
28
41
  if (typeof value === 'string')
29
42
  return value.trim() === '';
30
43
  if (value !== null && typeof value === 'object') {
@@ -67,18 +80,55 @@ function isBlankPredicateText(value) {
67
80
  * Neither can be read off the normalizer's answer, so both are named here —
68
81
  * see {@link isBlankPredicateText} for why this is the layer that says it.
69
82
  * Core's other predicate entries already treat both as blank:
70
- * `evaluateCondition` (`if (!trimmed) return true`), `evaluateCelCondition`
71
- * (`if (!source.trim()) return true`) and `evalRowPredicate`
72
- * (`evaluator/listConditional.ts`, `if (!source.trim())`).
83
+ * `evaluateCondition` (`isBlankPredicateText`, then `true` — diagnosed on
84
+ * the CEL route since objectui#8069 and on the legacy path since
85
+ * objectui#11262) and `evalRowPredicate` (`evaluator/listConditional.ts`,
86
+ * the caller's fallback, through `evalFieldPredicate`'s `[blank]` report
87
+ * since objectui#11262).
73
88
  * - `{ dialect, source: '' }` — the empty ENVELOPE. This is not an exotic
74
89
  * spelling: `@objectstack/spec`'s `ExpressionInputSchema` normalizes every
75
90
  * authored predicate into an envelope, so "author left the predicate empty"
76
91
  * compiles to exactly this, which makes it the likeliest empty shape in real
77
92
  * metadata (objectui#3850).
78
- * - anything that is not a predicate at all (`0`, `{}`, an array): a value the
79
- * evaluator cannot read must not be the reason a control is disabled or an
80
- * action refuses to run. Fail-open on junk, which is the posture
81
- * `ActionRunner` already committed to (`catch { isDisabled = false }`).
93
+ *
94
+ * The three BLANK shapes above (`''`, whitespace-only text, and an envelope
95
+ * whose `source` is blank) are still folded to "not declared" — that is the
96
+ * verdict — but no longer in silence: each is reported once (objectui#8069,
97
+ * ADR-0137 D4), see {@link reportBlankGate}. `null` / `undefined` (no key) is
98
+ * not blank TEXT and stays silent: nothing was declared there to report.
99
+ *
100
+ * ## What is DECLARED although nothing in it can be evaluated (objectui#11358)
101
+ *
102
+ * A value that is present but is not a predicate the client can evaluate — an
103
+ * envelope with no string `source` (the `ast`-only `{ dialect: 'cel', ast }` an
104
+ * unmirrored authoring door admits), a number (`0`), an object with no `source`
105
+ * (`{}`), an array — IS a declared gate. Triage's ruling on objectui#11358:
106
+ * "declared and faulting", "reported once per predicate", and the action
107
+ * "hides". It used to be the fifth "nothing to evaluate" shape here, answered
108
+ * "not declared" in silence, so a bulk action gated that way ran over every
109
+ * selected record and the toolbars and row menu showed it to everyone.
110
+ *
111
+ * This function's CODE did not change for it: the normalizer did.
112
+ * {@link toPredicateInput} no longer folds such a value into `undefined`; it
113
+ * returns a `cel` envelope with no `source`, so `toPredicateInput(x) !==
114
+ * undefined` — the line below — answers "declared", and the derivation this
115
+ * definition rests on still holds with no second list. The state is told apart
116
+ * in one place, `isUnevaluablePredicate` (`./unevaluablePredicate.ts`).
117
+ *
118
+ * What a reader then DOES with it is the evaluation step's, not this
119
+ * function's: `ExpressionEvaluator.evaluateCondition` and `evalFieldPredicate`
120
+ * treat it as a FAULT, so each reader answers with the fail direction its key
121
+ * already has for a predicate that cannot be evaluated, and reports it there,
122
+ * once — through `evalFieldPredicate`'s report with the `[unevaluable]` reason,
123
+ * or through the caller's own fault report. That is why this function reports
124
+ * nothing for it: a declared gate always goes on to an evaluation, and two
125
+ * reports for one fault is the noise objectstack-ai/objectstack#5149 removed.
126
+ * On every action `visible` leg that direction is CLOSED (objectui#11212), on
127
+ * every `disabled` leg too (objectui#11242), and the hide-polarity `hidden` /
128
+ * `hiddenOn` legs of `SchemaRenderer` answer "hidden". The keys whose fault
129
+ * direction is OPEN keep it — a `condition` still lets the action run, a
130
+ * legacy `enabled` leaves the control enabled, an action param's `visible`
131
+ * still shows the param — now diagnosed rather than silent.
82
132
  *
83
133
  * A declared-and-`false` gate is DECLARED (`toPredicateInput` returns the
84
134
  * boolean unchanged): `disabled: false` / `visible: false` are verdicts, and
@@ -142,11 +192,62 @@ function isBlankPredicateText(value) {
142
192
  * an empty predicate made the node VANISH (objectui#3955), and
143
193
  * `ActionEngine.getActionsForLocation`'s `visible` filter, which folded some
144
194
  * empty spellings by hand and coerced the rest with `Boolean(raw)`, so
145
- * `visible: 0` hid an action the renderers showed (objectui#3957). Nothing in the
146
- * repo now asks "is a gate declared?" anywhere but here.
195
+ * `visible: 0` hid an action the renderers showed (objectui#3957).
196
+ *
197
+ * The last one joined with objectui#11322: plugin-grid's `hasVisibilityGate`,
198
+ * on the grid's selection bar, asks it through the renderer-side name
199
+ * `hasDeclaredVisibilityGate`. It decides whether a bulk button whose `visible`
200
+ * admits no record is hidden, and `partitionBulkRows` asks it before handing a
201
+ * bulk def's `visible` to `partitionRowsByPredicate`. It had kept the old
202
+ * `!= null && !== ''` scope after every other member of the family moved here,
203
+ * so a whitespace-only `visible` counted as declared on that bar, failed closed
204
+ * for every record, and hid an action the row menu and toolbars showed.
205
+ *
206
+ * Nothing in the repo now asks "is a gate declared?" anywhere but here for
207
+ * the keys this definition serves. What stays outside is a different family,
208
+ * not a twin: the field-rule `*When` keys (ADR-0137) decide it by rules of
209
+ * their own. Examples are the built-in Edit / Delete row item's
210
+ * `visibleWhen != null` (`isBuiltinRowActionVisible`), and the opening test of
211
+ * the shared partitioner `partitionRowsByPredicate` (`null` / `undefined` /
212
+ * `''`), which is the answer its built-in Delete `visibleWhen` callers get. The
213
+ * action family asks here before it calls that partitioner.
214
+ * ⚠️ Nothing re-derives this paragraph: it was read off a source search when
215
+ * objectui#11322 landed, and no test or gate repeats that search.
147
216
  */
148
217
  export function hasDeclaredPredicate(value) {
149
- if (isBlankPredicateText(value))
218
+ if (isBlankPredicateText(value)) {
219
+ reportBlankGate(value);
150
220
  return false;
221
+ }
151
222
  return toPredicateInput(value) !== undefined;
152
223
  }
224
+ /**
225
+ * Say out loud that a DECLARED gate was blank — the one side effect
226
+ * {@link hasDeclaredPredicate} has (objectui#8069).
227
+ *
228
+ * The fold above is objectui#3850 / #3960's ruled verdict and it stands: a
229
+ * blank gate is "no gate". What ADR-0137 D4 removed is the SILENCE of it — "a
230
+ * gate predicate that is blank or faulting is diagnosed, never a silent
231
+ * `true`" — and this fold is where a blank `visible` / `hidden` / `enabled` /
232
+ * `disabled` / `condition` stopped before it could reach any evaluator that
233
+ * might have said so.
234
+ *
235
+ * Reported through `evalFieldPredicate`'s own `[blank]` report — the channel
236
+ * every other predicate fault already uses, with its once-per-(text, locator)
237
+ * dedupe — and not through a warner of its own. It makes no engine call: a
238
+ * blank predicate never reaches the engine. The `false` it is handed is this
239
+ * function's answer ("not declared"); nothing reads it back.
240
+ *
241
+ * ⚠️ The locator is FIXED, because this question is key-neutral and receives
242
+ * nothing that names the node. So the dedupe is per blank SPELLING across the
243
+ * app: `''`, `' '` and a blank envelope each warn once, and a second node
244
+ * with the same blank spelling does not warn again. That is weaker than the
245
+ * field-rule path, which joins the field's locator to a blank's key, and it is
246
+ * the price of diagnosing at the one definition rather than at each of its
247
+ * callers.
248
+ */
249
+ function reportBlankGate(value) {
250
+ evalFieldPredicate(value, {}, false, undefined, undefined, {
251
+ context: 'a declared gate, read as no gate',
252
+ });
253
+ }
@@ -44,12 +44,54 @@ export interface FieldPredicateDiagnostic {
44
44
  /**
45
45
  * Evaluate a field-rule CEL predicate against a record.
46
46
  *
47
+ * ## `fallback` is a per-CALL-SITE policy, not a default this helper owns
48
+ *
49
+ * The parameter is required and carries no default value, so every call site
50
+ * states a direction of its own — and the directions in this repo do not
51
+ * agree. Five distinct fault policies share this one helper today, which is
52
+ * readable nowhere but here (objectui#8069):
53
+ *
54
+ * 1. **Fixed permissive, paired with an equal "no rule" literal.**
55
+ * {@link resolveFieldRuleState} (`true` / `false` / `false`) and
56
+ * `resolveVisibleOptions` (`evaluator/optionRules.ts`, `true`). A fault
57
+ * produces the permissive verdict, indistinguishable from the verdict for
58
+ * "the author wrote no rule" — see the `…_FAULTED` / `…_ABSENT` note below.
59
+ * 2. **Fixed permissive, unguarded — ONE literal answers absent *and*
60
+ * faulted.** Every visibility call site outside core: the form renderer
61
+ * (`components/renderers/form/form.tsx`), console's `FormPage`, app-shell's
62
+ * `ScreenView`, `plugin-form`'s `WizardForm`. All of them pass `true`, and
63
+ * none has a separate absent branch at all.
64
+ * 3. **Caller-parameterised.** `evalRowPredicate`'s single-eval fast route
65
+ * (`evaluator/listConditional.ts`) forwards `opts.fallback`, so the
66
+ * direction is the mounting surface's to state.
67
+ * 4. **Divergence probe → fault FLAG.** `evalCel`
68
+ * (`evaluator/listConditional.ts`) calls this helper once per direction with
69
+ * `warn: false` + `onFault`: a verdict that tracks the fallback in BOTH runs
70
+ * is a fault, and the caller emits its own labelled warning and returns its
71
+ * own fallback.
72
+ * 5. **Divergence probe → THROW.** `ExpressionEvaluator.evaluateCelCondition`
73
+ * under `throwOnError` runs the same two-call trick and converts the
74
+ * disagreement into `throw new Error('CEL predicate failed to evaluate: …')`.
75
+ *
76
+ * ⚠️ The last two policies exist **because** `fallback` is free to specify: they
77
+ * detect a fault by disagreeing with themselves. Fixing a direction *inside*
78
+ * this helper would remove the mechanism they are built on, which is why
79
+ * ADR-0137 D5 keeps `fallback` free and declares the direction at the
80
+ * field-rule and gate layer instead.
81
+ *
47
82
  * @param pred The `visibleWhen` / `readonlyWhen` / `requiredWhen` predicate.
48
83
  * @param record The live form values (overlays prior persisted record).
49
- * @param fallback Value to return when the predicate is absent or fails to
50
- * evaluate. Pick the *safe* default for the caller:
51
- * `false` for readonly/required (don't lock/block on error),
52
- * `true` for visibility (don't hide on error).
84
+ * @param fallback Value to return when the predicate is ABSENT (`null` /
85
+ * `undefined`), and — separately — the value returned when a
86
+ * present predicate cannot be evaluated. ⚠️ Those are two
87
+ * questions this one parameter answers with one value; a
88
+ * caller that wants different answers must branch before the
89
+ * call, as {@link resolveFieldRuleState} does. The historic
90
+ * advice is to pick the *safe* default (`false` for
91
+ * readonly/required — don't lock/block on error; `true` for
92
+ * visibility — don't hide on error). ADR-0137 kept those
93
+ * directions for RENDER (D3) and put the answer to a broken
94
+ * field rule at SUBMIT (D2) — see {@link FieldRuleFaults}.
53
95
  * @param previous The prior persisted record, if any (for `previous.*` refs).
54
96
  * @param scope Extra top-level scope variables bound alongside `record` —
55
97
  * e.g. `{ parent }` so an inline line-item cell can reference
@@ -61,6 +103,45 @@ export interface FieldPredicateDiagnostic {
61
103
  * is unaffected either way.
62
104
  */
63
105
  export declare function evalFieldPredicate(pred: FieldRulePredicate | undefined | null, record: Record<string, unknown>, fallback: boolean, previous?: Record<string, unknown>, scope?: Record<string, unknown>, diagnostic?: FieldPredicateDiagnostic): boolean;
106
+ /**
107
+ * Which of {@link resolveFieldRuleState}'s rules could NOT be evaluated, and
108
+ * why — keyed by rule, valued by the reason the one-time warning prints after
109
+ * `Reason:` (`"[kind] message"`). A key is present only for a rule that ran and
110
+ * faulted (objectui#8069).
111
+ *
112
+ * This is the report a SUBMIT path reads — ADR-0137 D2, "at submit time, a
113
+ * field-rule predicate that cannot be evaluated refuses the write and names the
114
+ * field and the rule" — so that the refusal is decided by the very evaluation
115
+ * that drew the verdict, never by a second one with its own record assembly.
116
+ * Which rules a submit path refuses on is that path's ruling, not this
117
+ * function's: under objectui#8069's ruling (Q1 = B, one judge per rule) the
118
+ * client refuses `visibleWhen` only, because no server evaluates it, and leaves
119
+ * `readonlyWhen` / `requiredWhen` to the server's own D2 refusal. The other two
120
+ * keys are reported all the same, so the report does not encode that ruling.
121
+ *
122
+ * A BLANK predicate IS in it, as the `[blank]` reason. ADR-0137 makes a
123
+ * declared-but-blank field rule a third state, not a spelling of "no rule":
124
+ * "A blank predicate takes this path too, wherever one is already stored — D1
125
+ * keeps new ones from being authored, and D2 is what a stored one meets" (D2),
126
+ * and its Alternatives refuse "blank means no rule" for this triad by name. So
127
+ * a stored blank `visibleWhen` is refused at submit like any other fault. The
128
+ * trap that would otherwise make — an authorable value that no submit can ever
129
+ * pass — is closed at AUTHORING: the triad's form wire refuses a blank at parse
130
+ * (`FieldRulePredicateWireSchema` in `@object-ui/types`' `form.zod.ts`, D1).
131
+ * The render verdict of a blank rule is unchanged (D3: the permissive one).
132
+ * A blank GATE is a different thing: `hasDeclaredPredicate` still folds it to
133
+ * "no gate" and only diagnoses it (D4) — it never reaches this function.
134
+ *
135
+ * Deliberately NOT in it: a rule the verdict did not need. `readonlyWhen` under
136
+ * a static `readonly: true`, and `requiredWhen` under `serverOwnedValue` or a
137
+ * static `required: true`, are short-circuited and never run, so they cannot
138
+ * fault.
139
+ */
140
+ export interface FieldRuleFaults {
141
+ visibleWhen?: string;
142
+ readonlyWhen?: string;
143
+ requiredWhen?: string;
144
+ }
64
145
  /**
65
146
  * Resolve the effective `{ visible, readonly, required }` state for a field
66
147
  * given its conditional rules and the live record. Each `*When` rule, when
@@ -90,6 +171,8 @@ export declare function evalFieldPredicate(pred: FieldRulePredicate | undefined
90
171
  * `"field 'amount'"`. The warning then reads
91
172
  * `visibleWhen of field 'amount' …`; without it, the rule
92
173
  * kind alone is reported.
174
+ * @returns The three verdicts, plus `faults` — see {@link FieldRuleFaults} for
175
+ * what a submit path reads from it and what it leaves out.
93
176
  */
94
177
  export declare function resolveFieldRuleState(rules: {
95
178
  visibleWhen?: FieldRulePredicate;
@@ -103,4 +186,5 @@ export declare function resolveFieldRuleState(rules: {
103
186
  visible: boolean;
104
187
  readonly: boolean;
105
188
  required: boolean;
189
+ faults: FieldRuleFaults;
106
190
  };
@@ -61,8 +61,22 @@
61
61
  * logs `treating the field as LOCKED` instead). The *default* stays
62
62
  * fail-open on purpose — flipping it is a shipped-behavior change tracked
63
63
  * separately in objectstack#5149 (appeal 1, undecided).
64
+ *
65
+ * A predicate the author DECLARED and left BLANK is loud too, since
66
+ * objectui#8069 — and it did not used to be. It is a third state, not a
67
+ * spelling of either neighbour: the key is present (so it is not "no rule")
68
+ * and nothing evaluates (so no engine fault is raised), and it used to return
69
+ * the caller's permissive fallback before any warning could fire. `''` and
70
+ * `' '` are authorable — `ExpressionWireSchema` is a bare `z.string()`, and
71
+ * `resolveFieldRuleState`'s own guard is `!= null` — so the one state an author
72
+ * reaches by *starting* a rule and not finishing it was the one state that said
73
+ * nothing at all. Both spellings now report `[blank]` through the same single
74
+ * site as every other fault; the VERDICT is unchanged for every input.
64
75
  */
65
76
  import { ExpressionEngine } from '@objectstack/formula';
77
+ import { subjectPermissionsOf } from './subjectPermissions.js';
78
+ import { isBlankPredicateText } from './declaredPredicate.js';
79
+ import { isUnevaluablePredicate, UNEVALUABLE_PREDICATE_REASON } from './unevaluablePredicate.js';
66
80
  /** Normalize a predicate into the `Expression` shape the engine expects. */
67
81
  function toExpression(pred) {
68
82
  if (typeof pred === 'string')
@@ -70,15 +84,35 @@ function toExpression(pred) {
70
84
  return { dialect: (pred.dialect ?? 'cel'), source: pred.source };
71
85
  }
72
86
  const warnedPredicates = new Set();
87
+ /**
88
+ * The `reason` reported for a predicate the author DECLARED and left blank.
89
+ *
90
+ * Tagged like an engine reason (`[parse]`, `[type]`, `[throw]`) because it
91
+ * travels the same two channels — the built-in warning and the `onFault`
92
+ * passback — and a caller that routes on the tag must be able to tell this
93
+ * apart from a predicate that faulted with text in it: the fix for a blank one
94
+ * is to finish it or delete the key, never to debug its syntax.
95
+ */
96
+ const BLANK_PREDICATE_REASON = '[blank] the predicate is declared but empty — nothing to evaluate';
73
97
  /**
74
98
  * One-time warning for a predicate that could not be evaluated. Deduped per
75
99
  * predicate TEXT (dialect + source): a broken predicate is re-evaluated on
76
100
  * every render/keystroke, and the point is one loud line, not a scrolling
77
101
  * wall. The dedupe key is JSON-encoded — never a control-character separator
78
102
  * (objectstack#5450 made a sibling of this file binary to grep that way).
103
+ *
104
+ * ⚠️ One class joins the LOCATOR to that key: a BLANK predicate. The text is
105
+ * what identifies the authoring site for every other fault — it carries the
106
+ * typo — but every blank predicate in an app shares the key `""`, so text
107
+ * alone would let the first blank rule silence every other author's. Non-blank
108
+ * keys are unchanged (objectui#8069).
79
109
  */
80
110
  function warnPredicateFailure(expr, fallback, reason, context) {
81
- const key = JSON.stringify([expr.dialect, expr.source]);
111
+ // `Expression['source']` is optional in the spec; an absent source is blank
112
+ // by the same rule as a whitespace-only one, and lands in the same key shape.
113
+ const key = expr.source?.trim()
114
+ ? JSON.stringify([expr.dialect, expr.source])
115
+ : JSON.stringify([expr.dialect, expr.source, context ?? '']);
82
116
  if (warnedPredicates.has(key))
83
117
  return;
84
118
  warnedPredicates.add(key);
@@ -92,12 +126,54 @@ function warnPredicateFailure(expr, fallback, reason, context) {
92
126
  /**
93
127
  * Evaluate a field-rule CEL predicate against a record.
94
128
  *
129
+ * ## `fallback` is a per-CALL-SITE policy, not a default this helper owns
130
+ *
131
+ * The parameter is required and carries no default value, so every call site
132
+ * states a direction of its own — and the directions in this repo do not
133
+ * agree. Five distinct fault policies share this one helper today, which is
134
+ * readable nowhere but here (objectui#8069):
135
+ *
136
+ * 1. **Fixed permissive, paired with an equal "no rule" literal.**
137
+ * {@link resolveFieldRuleState} (`true` / `false` / `false`) and
138
+ * `resolveVisibleOptions` (`evaluator/optionRules.ts`, `true`). A fault
139
+ * produces the permissive verdict, indistinguishable from the verdict for
140
+ * "the author wrote no rule" — see the `…_FAULTED` / `…_ABSENT` note below.
141
+ * 2. **Fixed permissive, unguarded — ONE literal answers absent *and*
142
+ * faulted.** Every visibility call site outside core: the form renderer
143
+ * (`components/renderers/form/form.tsx`), console's `FormPage`, app-shell's
144
+ * `ScreenView`, `plugin-form`'s `WizardForm`. All of them pass `true`, and
145
+ * none has a separate absent branch at all.
146
+ * 3. **Caller-parameterised.** `evalRowPredicate`'s single-eval fast route
147
+ * (`evaluator/listConditional.ts`) forwards `opts.fallback`, so the
148
+ * direction is the mounting surface's to state.
149
+ * 4. **Divergence probe → fault FLAG.** `evalCel`
150
+ * (`evaluator/listConditional.ts`) calls this helper once per direction with
151
+ * `warn: false` + `onFault`: a verdict that tracks the fallback in BOTH runs
152
+ * is a fault, and the caller emits its own labelled warning and returns its
153
+ * own fallback.
154
+ * 5. **Divergence probe → THROW.** `ExpressionEvaluator.evaluateCelCondition`
155
+ * under `throwOnError` runs the same two-call trick and converts the
156
+ * disagreement into `throw new Error('CEL predicate failed to evaluate: …')`.
157
+ *
158
+ * ⚠️ The last two policies exist **because** `fallback` is free to specify: they
159
+ * detect a fault by disagreeing with themselves. Fixing a direction *inside*
160
+ * this helper would remove the mechanism they are built on, which is why
161
+ * ADR-0137 D5 keeps `fallback` free and declares the direction at the
162
+ * field-rule and gate layer instead.
163
+ *
95
164
  * @param pred The `visibleWhen` / `readonlyWhen` / `requiredWhen` predicate.
96
165
  * @param record The live form values (overlays prior persisted record).
97
- * @param fallback Value to return when the predicate is absent or fails to
98
- * evaluate. Pick the *safe* default for the caller:
99
- * `false` for readonly/required (don't lock/block on error),
100
- * `true` for visibility (don't hide on error).
166
+ * @param fallback Value to return when the predicate is ABSENT (`null` /
167
+ * `undefined`), and — separately — the value returned when a
168
+ * present predicate cannot be evaluated. ⚠️ Those are two
169
+ * questions this one parameter answers with one value; a
170
+ * caller that wants different answers must branch before the
171
+ * call, as {@link resolveFieldRuleState} does. The historic
172
+ * advice is to pick the *safe* default (`false` for
173
+ * readonly/required — don't lock/block on error; `true` for
174
+ * visibility — don't hide on error). ADR-0137 kept those
175
+ * directions for RENDER (D3) and put the answer to a broken
176
+ * field rule at SUBMIT (D2) — see {@link FieldRuleFaults}.
101
177
  * @param previous The prior persisted record, if any (for `previous.*` refs).
102
178
  * @param scope Extra top-level scope variables bound alongside `record` —
103
179
  * e.g. `{ parent }` so an inline line-item cell can reference
@@ -109,30 +185,63 @@ function warnPredicateFailure(expr, fallback, reason, context) {
109
185
  * is unaffected either way.
110
186
  */
111
187
  export function evalFieldPredicate(pred, record, fallback, previous, scope, diagnostic) {
112
- if (pred == null || (typeof pred === 'string' && !pred.trim()))
188
+ if (pred == null)
113
189
  return fallback;
114
190
  const expr = toExpression(pred);
115
- // The two fault sources — a not-ok verdict and a throw that slipped past the
116
- // engine's "never throws" contract — converge on one reason string and one
117
- // reporting site below, so the built-in warning and the `onFault` passback
118
- // can never describe the failure differently.
191
+ // The fault sources — a blank predicate, a not-ok verdict, and a throw that
192
+ // slipped past the engine's "never throws" contract — converge on one reason
193
+ // string and one reporting site below, so the built-in warning and the
194
+ // `onFault` passback can never describe the failure differently.
119
195
  let reason;
120
196
  let value = fallback;
121
- try {
122
- const res = ExpressionEngine.evaluate(expr, {
123
- record,
124
- previous,
125
- ...(scope ? { extra: scope } : {}),
126
- });
127
- // Parse error, type error, unbound identifier, engine fault … — every
128
- // not-ok verdict resolves to the fallback, but never silently (#5149).
129
- if (!res.ok)
130
- reason = `[${res.error.kind}] ${res.error.message}`;
131
- else
132
- value = res.value === true;
197
+ if (isBlankPredicateText(pred)) {
198
+ // Declared-and-blank, in EITHER spelling. Answered here rather than by the
199
+ // engine for two reasons: the bare-string spelling never reached the engine
200
+ // at all (this line used to `return fallback` before any warning could
201
+ // fire — objectui#8069), and the envelope spelling reached it and came back
202
+ // with `AST-only evaluation not yet supported; persist \`source\`` for
203
+ // `{ source: '' }`, a reason that sends the author looking for a
204
+ // serialization bug. `isBlankPredicateText` is the repo's one definition of
205
+ // this question (objectui#3960) — a fourth local `trim()` here is exactly
206
+ // the drift it was consolidated to stop.
207
+ reason = BLANK_PREDICATE_REASON;
133
208
  }
134
- catch (err) {
135
- reason = `[throw] ${err instanceof Error ? err.message : String(err)}`;
209
+ else if (isUnevaluablePredicate(pred)) {
210
+ // Declared, and nothing here can be evaluated: an envelope with no string
211
+ // `source` (an `ast`-only one), a number, `{}`, an array (objectui#11358).
212
+ // Answered here, beside the blank, for the blank's reasons: there is no
213
+ // `source` to hand the engine, and the engine's answer for one —
214
+ // `AST-only evaluation not yet supported; persist \`source\`` — was what
215
+ // a `0` reported too, which sends its author looking for a serializer.
216
+ // One reason, one tag, the same two channels.
217
+ reason = UNEVALUABLE_PREDICATE_REASON;
218
+ }
219
+ else {
220
+ try {
221
+ // objectui#4421 — the acting subject's effective object permissions, the
222
+ // one input `current_user.can(object, verb)` is answered from. They ride
223
+ // on the subject the scope already carries (`subjectPermissions.ts`),
224
+ // never under a scope key, because every key of `scope` is a CEL root
225
+ // and the engine keeps this map out of the variable namespace. A subject
226
+ // with none passes none, and `can` then refuses loudly: that fault goes
227
+ // through the caller's `fallback` like any other.
228
+ const permissions = subjectPermissionsOf(scope?.current_user);
229
+ const res = ExpressionEngine.evaluate(expr, {
230
+ record,
231
+ previous,
232
+ ...(scope ? { extra: scope } : {}),
233
+ ...(permissions !== undefined ? { permissions } : {}),
234
+ });
235
+ // Parse error, type error, unbound identifier, engine fault … — every
236
+ // not-ok verdict resolves to the fallback, but never silently (objectstack-ai/objectstack#5149).
237
+ if (!res.ok)
238
+ reason = `[${res.error.kind}] ${res.error.message}`;
239
+ else
240
+ value = res.value === true;
241
+ }
242
+ catch (err) {
243
+ reason = `[throw] ${err instanceof Error ? err.message : String(err)}`;
244
+ }
136
245
  }
137
246
  if (reason !== undefined) {
138
247
  if (diagnostic?.warn !== false) {
@@ -145,6 +254,60 @@ export function evalFieldPredicate(pred, record, fallback, previous, scope, diag
145
254
  }
146
255
  return value;
147
256
  }
257
+ /**
258
+ * The verdict {@link resolveFieldRuleState} applies when a rule's predicate
259
+ * CANNOT BE EVALUATED — parse error, unbound identifier, engine fault, or a
260
+ * predicate the author left blank.
261
+ *
262
+ * ⚠️ These answer **"what should the form do when this rule is BROKEN?"** —
263
+ * *not* "what should the form do when the author declared NO rule?" That
264
+ * second question is answered separately, by the `…_ABSENT` set below. The two
265
+ * sets hold pairwise EQUAL values today, and that equality is a copy, not an
266
+ * implication: each `rules.<key> != null` ternary answers the absent case with
267
+ * its own literal and hands `evalFieldPredicate` the same literal as the fault
268
+ * fallback beside it, so every permissive value is written twice and the "it
269
+ * broke" answer was chosen by aligning it with the "it is absent" answer next
270
+ * to it. Nothing was *inherited* from a shared default — `evalFieldPredicate`'s
271
+ * `fallback` parameter is required and has none (objectui#8069 measured both
272
+ * halves).
273
+ *
274
+ * All three directions are the PERMISSIVE one, so a single mistyped column in
275
+ * one authored predicate yields a form that shows more, locks less and demands
276
+ * less — three faults from one typo, composing rather than cancelling.
277
+ * ADR-0137 answered that without moving any of the three: they stay the RENDER
278
+ * direction (D3 — a faulting `visibleWhen` shows the field), and the composite
279
+ * is closed at SUBMIT instead (D2), from the `faults` report
280
+ * {@link resolveFieldRuleState} returns beside the verdicts.
281
+ *
282
+ * **What the history records** (read on full, unshallowed history — a shallow
283
+ * clone answers this with one commit and no warning): the direction *was*
284
+ * reasoned about, once, when the helper landed (objectui#1578). This module's
285
+ * head and ADR-0036 both record the same rationale — the fallbacks are "chosen
286
+ * so a fault is *safe*: `true` for visibility (don't hide content on error),
287
+ * `false` for required/readonly (don't block submit or lock a field on
288
+ * error)". What no commit records is the COMPOSITION: every recorded argument
289
+ * is per-key, and the case where all three faults arrive from one typo was
290
+ * never put. objectstack#5149 then removed the SILENCE and left the direction
291
+ * explicitly undecided ("appeal 1").
292
+ *
293
+ * ⛔ These values are shipped behaviour, declared by ADR-0137 D3. Changing one
294
+ * is not a refactor — and `6a449fc49` leans on the `visibleWhen` half staying
295
+ * fail-open (a broken predicate must never silently null a stored column).
296
+ */
297
+ const VISIBLE_WHEN_FAULTED = true;
298
+ const READONLY_WHEN_FAULTED = false;
299
+ const REQUIRED_WHEN_FAULTED = false;
300
+ /**
301
+ * The verdict {@link resolveFieldRuleState} applies when the author declared NO
302
+ * rule for that key at all — the `: <literal>` arm of each `!= null` ternary.
303
+ *
304
+ * Spelled apart from the `…_FAULTED` set above because they answer a different
305
+ * question, not because they differ: changing one of THESE changes what an
306
+ * unconditional field does, which is a louder and quite separate decision.
307
+ */
308
+ const VISIBLE_WHEN_ABSENT = true;
309
+ const READONLY_WHEN_ABSENT = false;
310
+ const REQUIRED_WHEN_ABSENT = false;
148
311
  /**
149
312
  * Resolve the effective `{ visible, readonly, required }` state for a field
150
313
  * given its conditional rules and the live record. Each `*When` rule, when
@@ -174,18 +337,27 @@ export function evalFieldPredicate(pred, record, fallback, previous, scope, diag
174
337
  * `"field 'amount'"`. The warning then reads
175
338
  * `visibleWhen of field 'amount' …`; without it, the rule
176
339
  * kind alone is reported.
340
+ * @returns The three verdicts, plus `faults` — see {@link FieldRuleFaults} for
341
+ * what a submit path reads from it and what it leaves out.
177
342
  */
178
343
  export function resolveFieldRuleState(rules, record, statics, previous, scope, fieldContext) {
344
+ const faults = {};
345
+ // `onFault` fires beside the built-in warning, not instead of it (`warn`
346
+ // stays on), so recording the report costs no diagnostic and no second
347
+ // engine call.
179
348
  const diag = (rule) => ({
180
349
  context: fieldContext ? `${rule} of ${fieldContext}` : rule,
350
+ onFault: (reason) => {
351
+ faults[rule] = reason;
352
+ },
181
353
  });
182
354
  const visible = rules.visibleWhen != null
183
- ? evalFieldPredicate(rules.visibleWhen, record, true, previous, scope, diag('visibleWhen'))
184
- : true;
355
+ ? evalFieldPredicate(rules.visibleWhen, record, VISIBLE_WHEN_FAULTED, previous, scope, diag('visibleWhen'))
356
+ : VISIBLE_WHEN_ABSENT;
185
357
  const readonly = statics.readonly === true ||
186
358
  (rules.readonlyWhen != null
187
- ? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope, diag('readonlyWhen'))
188
- : false);
359
+ ? evalFieldPredicate(rules.readonlyWhen, record, READONLY_WHEN_FAULTED, previous, scope, diag('readonlyWhen'))
360
+ : READONLY_WHEN_ABSENT);
189
361
  // Short-circuited, not evaluated-and-discarded: the verdict cannot depend on
190
362
  // the predicate, so running it would only spend an engine call per field per
191
363
  // render. A broken `requiredWhen` still warns wherever it decides something
@@ -195,7 +367,7 @@ export function resolveFieldRuleState(rules, record, statics, previous, scope, f
195
367
  ? false
196
368
  : statics.required === true ||
197
369
  (rules.requiredWhen != null
198
- ? evalFieldPredicate(rules.requiredWhen, record, false, previous, scope, diag('requiredWhen'))
199
- : false);
200
- return { visible, readonly, required };
370
+ ? evalFieldPredicate(rules.requiredWhen, record, REQUIRED_WHEN_FAULTED, previous, scope, diag('requiredWhen'))
371
+ : REQUIRED_WHEN_ABSENT);
372
+ return { visible, readonly, required, faults };
201
373
  }
@@ -10,6 +10,7 @@ export * from './ExpressionEvaluator.js';
10
10
  export * from './predicateInput.js';
11
11
  export * from './declaredPredicate.js';
12
12
  export * from './fieldRules.js';
13
+ export * from './subjectPermissions.js';
13
14
  export * from './rowPredicateCanon.js';
14
15
  export * from './listConditional.js';
15
16
  export * from './optionRules.js';
@@ -10,6 +10,7 @@ export * from './ExpressionEvaluator.js';
10
10
  export * from './predicateInput.js';
11
11
  export * from './declaredPredicate.js';
12
12
  export * from './fieldRules.js';
13
+ export * from './subjectPermissions.js';
13
14
  export * from './rowPredicateCanon.js';
14
15
  export * from './listConditional.js';
15
16
  export * from './optionRules.js';