@object-ui/core 17.5.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 (157) hide show
  1. package/CHANGELOG.md +7404 -0
  2. package/README.md +127 -26
  3. package/dist/actions/ActionEngine.d.ts +5 -3
  4. package/dist/actions/ActionEngine.js +14 -10
  5. package/dist/actions/ActionRunner.d.ts +363 -30
  6. package/dist/actions/ActionRunner.js +544 -58
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/actionKeys.d.ts +75 -13
  9. package/dist/actions/actionKeys.js +131 -16
  10. package/dist/actions/actionResponse.js +3 -3
  11. package/dist/actions/index.d.ts +2 -0
  12. package/dist/actions/index.js +2 -0
  13. package/dist/actions/recordDelete.d.ts +118 -0
  14. package/dist/actions/recordDelete.js +112 -0
  15. package/dist/actions/recordIdParam.d.ts +77 -0
  16. package/dist/actions/recordIdParam.js +37 -0
  17. package/dist/actions/serverActionHandler.d.ts +1 -1
  18. package/dist/actions/serverActionHandler.js +1 -1
  19. package/dist/adapters/ValueDataSource.d.ts +6 -1
  20. package/dist/adapters/ValueDataSource.js +1090 -98
  21. package/dist/adapters/batchTransaction.d.ts +1 -1
  22. package/dist/adapters/batchTransaction.js +1 -1
  23. package/dist/builder/schema-builder.d.ts +38 -81
  24. package/dist/builder/schema-builder.js +33 -152
  25. package/dist/data-scope/ViewDataProvider.d.ts +1 -1
  26. package/dist/data-scope/ViewDataProvider.js +21 -1
  27. package/dist/data-scope/element-data-source.d.ts +261 -7
  28. package/dist/data-scope/element-data-source.js +342 -52
  29. package/dist/data-scope/index.d.ts +9 -4
  30. package/dist/data-scope/index.js +9 -4
  31. package/dist/evaluator/ExpressionEvaluator.d.ts +128 -1
  32. package/dist/evaluator/ExpressionEvaluator.js +226 -13
  33. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  34. package/dist/evaluator/FormulaFunctions.js +166 -27
  35. package/dist/evaluator/SafeExpressionParser.js +0 -4
  36. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  37. package/dist/evaluator/declaredPredicate.js +118 -17
  38. package/dist/evaluator/fieldRules.d.ts +107 -4
  39. package/dist/evaluator/fieldRules.js +231 -35
  40. package/dist/evaluator/index.d.ts +2 -0
  41. package/dist/evaluator/index.js +2 -0
  42. package/dist/evaluator/listConditional.d.ts +125 -9
  43. package/dist/evaluator/listConditional.js +167 -26
  44. package/dist/evaluator/optionRules.d.ts +126 -0
  45. package/dist/evaluator/optionRules.js +141 -4
  46. package/dist/evaluator/predicateInput.d.ts +15 -4
  47. package/dist/evaluator/predicateInput.js +35 -2
  48. package/dist/evaluator/rowPredicateCanon.d.ts +56 -0
  49. package/dist/evaluator/rowPredicateCanon.js +158 -0
  50. package/dist/evaluator/subjectPermissions.d.ts +76 -0
  51. package/dist/evaluator/subjectPermissions.js +44 -0
  52. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  53. package/dist/evaluator/unevaluablePredicate.js +70 -0
  54. package/dist/index.d.ts +18 -0
  55. package/dist/index.js +97 -2
  56. package/dist/protocols/index.d.ts +0 -1
  57. package/dist/protocols/index.js +9 -1
  58. package/dist/registry/Registry.d.ts +397 -48
  59. package/dist/registry/Registry.js +263 -11
  60. package/dist/registry/WidgetRegistry.js +7 -3
  61. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  62. package/dist/registry/html-tier-intrinsics.js +110 -0
  63. package/dist/registry/public-blocks.d.ts +8 -0
  64. package/dist/registry/public-blocks.js +57 -0
  65. package/dist/theme/ThemeEngine.js +1 -1
  66. package/dist/theme/index.d.ts +1 -1
  67. package/dist/theme/index.js +1 -1
  68. package/dist/types/index.d.ts +92 -13
  69. package/dist/utils/chart-category-key.d.ts +82 -0
  70. package/dist/utils/chart-category-key.js +84 -0
  71. package/dist/utils/chart-measure-key.d.ts +64 -0
  72. package/dist/utils/chart-measure-key.js +65 -0
  73. package/dist/utils/chart-presentation.d.ts +188 -0
  74. package/dist/utils/chart-presentation.js +345 -0
  75. package/dist/utils/chart-series.d.ts +271 -31
  76. package/dist/utils/chart-series.js +478 -55
  77. package/dist/utils/column-identity.d.ts +43 -0
  78. package/dist/utils/column-identity.js +47 -1
  79. package/dist/utils/column-sortability.d.ts +157 -0
  80. package/dist/utils/column-sortability.js +217 -0
  81. package/dist/utils/dashboard-filters.d.ts +38 -6
  82. package/dist/utils/dashboard-filters.js +107 -61
  83. package/dist/utils/dataset-format.d.ts +211 -30
  84. package/dist/utils/dataset-format.js +407 -52
  85. package/dist/utils/date-display.d.ts +354 -0
  86. package/dist/utils/date-display.js +463 -0
  87. package/dist/utils/date-macros.js +7 -0
  88. package/dist/utils/debug.js +7 -0
  89. package/dist/utils/drill-down.d.ts +78 -0
  90. package/dist/utils/drill-down.js +93 -0
  91. package/dist/utils/emptiness.d.ts +96 -0
  92. package/dist/utils/emptiness.js +101 -0
  93. package/dist/utils/expand-fields.d.ts +171 -11
  94. package/dist/utils/expand-fields.js +171 -11
  95. package/dist/utils/extract-records.d.ts +65 -5
  96. package/dist/utils/extract-records.js +65 -8
  97. package/dist/utils/filter-converter.d.ts +152 -4
  98. package/dist/utils/filter-converter.js +1092 -22
  99. package/dist/utils/filter-tokens.d.ts +49 -5
  100. package/dist/utils/filter-tokens.js +105 -34
  101. package/dist/utils/freeze-schema.d.ts +38 -1
  102. package/dist/utils/freeze-schema.js +10 -0
  103. package/dist/utils/grouping-fields.d.ts +61 -0
  104. package/dist/utils/grouping-fields.js +84 -0
  105. package/dist/utils/humanize-label.d.ts +60 -0
  106. package/dist/utils/humanize-label.js +62 -0
  107. package/dist/utils/managedBy.d.ts +30 -5
  108. package/dist/utils/managedBy.js +5 -5
  109. package/dist/utils/native-date-value.d.ts +48 -0
  110. package/dist/utils/native-date-value.js +167 -0
  111. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  112. package/dist/utils/non-grid-row-ceiling.js +141 -0
  113. package/dist/utils/normalize-list-view.d.ts +131 -1
  114. package/dist/utils/normalize-list-view.js +248 -6
  115. package/dist/utils/number-display.d.ts +70 -17
  116. package/dist/utils/number-display.js +34 -12
  117. package/dist/utils/object-aggregate-query.d.ts +113 -0
  118. package/dist/utils/object-aggregate-query.js +50 -0
  119. package/dist/utils/parent-scope.d.ts +123 -0
  120. package/dist/utils/parent-scope.js +140 -0
  121. package/dist/utils/predicate-fields.d.ts +62 -4
  122. package/dist/utils/predicate-fields.js +169 -4
  123. package/dist/utils/record-color.d.ts +75 -0
  124. package/dist/utils/record-color.js +65 -0
  125. package/dist/utils/record-source.d.ts +298 -0
  126. package/dist/utils/record-source.js +331 -0
  127. package/dist/utils/record-title.d.ts +148 -8
  128. package/dist/utils/record-title.js +220 -26
  129. package/dist/utils/reference-keys.d.ts +21 -22
  130. package/dist/utils/reference-keys.js +532 -29
  131. package/dist/utils/retired-field-types.d.ts +90 -0
  132. package/dist/utils/retired-field-types.js +124 -0
  133. package/dist/utils/sort-query.d.ts +128 -26
  134. package/dist/utils/sort-query.js +240 -13
  135. package/dist/utils/text-comparand.d.ts +92 -0
  136. package/dist/utils/text-comparand.js +99 -0
  137. package/dist/utils/unmaterialized-fields.d.ts +52 -0
  138. package/dist/utils/unmaterialized-fields.js +57 -0
  139. package/dist/utils/without-denied-fields.d.ts +49 -0
  140. package/dist/utils/without-denied-fields.js +52 -0
  141. package/dist/validation/index.d.ts +9 -4
  142. package/dist/validation/index.js +9 -4
  143. package/dist/validation/schema-validator.d.ts +1 -1
  144. package/dist/validation/schema-validator.js +305 -82
  145. package/dist/validation/server-owned-value.d.ts +62 -0
  146. package/dist/validation/server-owned-value.js +99 -0
  147. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  148. package/dist/validation/validators/object-validation-engine.js +14 -5
  149. package/package.json +8 -9
  150. package/dist/__benchmarks__/core.bench.d.ts +0 -8
  151. package/dist/__benchmarks__/core.bench.js +0 -53
  152. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  153. package/dist/data-scope/DataScopeManager.js +0 -229
  154. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  155. package/dist/protocols/ResponsiveProtocol.js +0 -158
  156. package/dist/validation/validation-engine.d.ts +0 -88
  157. package/dist/validation/validation-engine.js +0 -434
@@ -34,8 +34,7 @@
34
34
  * `stripReadonlyWhenFieldsMulti` on the bulk path — DELETES that key from the
35
35
  * UPDATE payload. This file keeps the same fault fail-OPEN. For that one class
36
36
  * the two ends therefore point in OPPOSITE directions, deliberately: the
37
- * framework's ADR-0057 D10 — server enforces, client is courtesy (framework
38
- * numbering; this repo's own ADR-0057 is an unrelated document) — makes the
37
+ * framework's ADR-0124 D1 — server enforces, client is courtesy — makes the
39
38
  * server the authority, so the courtesy layer does not get to guess "locked"
40
39
  * and grey out a field the server might have accepted.
41
40
  *
@@ -62,8 +61,22 @@
62
61
  * logs `treating the field as LOCKED` instead). The *default* stays
63
62
  * fail-open on purpose — flipping it is a shipped-behavior change tracked
64
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.
65
75
  */
66
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';
67
80
  /** Normalize a predicate into the `Expression` shape the engine expects. */
68
81
  function toExpression(pred) {
69
82
  if (typeof pred === 'string')
@@ -71,15 +84,35 @@ function toExpression(pred) {
71
84
  return { dialect: (pred.dialect ?? 'cel'), source: pred.source };
72
85
  }
73
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';
74
97
  /**
75
98
  * One-time warning for a predicate that could not be evaluated. Deduped per
76
99
  * predicate TEXT (dialect + source): a broken predicate is re-evaluated on
77
100
  * every render/keystroke, and the point is one loud line, not a scrolling
78
101
  * wall. The dedupe key is JSON-encoded — never a control-character separator
79
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).
80
109
  */
81
110
  function warnPredicateFailure(expr, fallback, reason, context) {
82
- 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 ?? '']);
83
116
  if (warnedPredicates.has(key))
84
117
  return;
85
118
  warnedPredicates.add(key);
@@ -93,12 +126,54 @@ function warnPredicateFailure(expr, fallback, reason, context) {
93
126
  /**
94
127
  * Evaluate a field-rule CEL predicate against a record.
95
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
+ *
96
164
  * @param pred The `visibleWhen` / `readonlyWhen` / `requiredWhen` predicate.
97
165
  * @param record The live form values (overlays prior persisted record).
98
- * @param fallback Value to return when the predicate is absent or fails to
99
- * evaluate. Pick the *safe* default for the caller:
100
- * `false` for readonly/required (don't lock/block on error),
101
- * `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}.
102
177
  * @param previous The prior persisted record, if any (for `previous.*` refs).
103
178
  * @param scope Extra top-level scope variables bound alongside `record` —
104
179
  * e.g. `{ parent }` so an inline line-item cell can reference
@@ -110,30 +185,63 @@ function warnPredicateFailure(expr, fallback, reason, context) {
110
185
  * is unaffected either way.
111
186
  */
112
187
  export function evalFieldPredicate(pred, record, fallback, previous, scope, diagnostic) {
113
- if (pred == null || (typeof pred === 'string' && !pred.trim()))
188
+ if (pred == null)
114
189
  return fallback;
115
190
  const expr = toExpression(pred);
116
- // The two fault sources — a not-ok verdict and a throw that slipped past the
117
- // engine's "never throws" contract — converge on one reason string and one
118
- // reporting site below, so the built-in warning and the `onFault` passback
119
- // 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.
120
195
  let reason;
121
196
  let value = fallback;
122
- try {
123
- const res = ExpressionEngine.evaluate(expr, {
124
- record,
125
- previous,
126
- ...(scope ? { extra: scope } : {}),
127
- });
128
- // Parse error, type error, unbound identifier, engine fault … — every
129
- // not-ok verdict resolves to the fallback, but never silently (#5149).
130
- if (!res.ok)
131
- reason = `[${res.error.kind}] ${res.error.message}`;
132
- else
133
- 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;
134
208
  }
135
- catch (err) {
136
- 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
+ }
137
245
  }
138
246
  if (reason !== undefined) {
139
247
  if (diagnostic?.warn !== false) {
@@ -146,6 +254,60 @@ export function evalFieldPredicate(pred, record, fallback, previous, scope, diag
146
254
  }
147
255
  return value;
148
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;
149
311
  /**
150
312
  * Resolve the effective `{ visible, readonly, required }` state for a field
151
313
  * given its conditional rules and the live record. Each `*When` rule, when
@@ -153,25 +315,59 @@ export function evalFieldPredicate(pred, record, fallback, previous, scope, diag
153
315
  * `false` predicate result for required/readonly — but `visibleWhen` is
154
316
  * authoritative when present (so a field can be conditionally shown/hidden).
155
317
  *
318
+ * ## `statics.serverOwnedValue` — the one thing that outranks BOTH
319
+ *
320
+ * A CREATE form leaves a producer-owned control empty on purpose and omits the
321
+ * key, so the server resolves the declared runtime default (#4069). Required-
322
+ * ness is then not the client's question at all, and this is the single place
323
+ * that answers it: `required` resolves to `false` whatever `statics.required`
324
+ * says and whatever `requiredWhen` evaluates to.
325
+ *
326
+ * Both spellings had to land HERE, not in a gate a layer up. Since
327
+ * objectui#4201 the submit-time check and the required marker read one live
328
+ * verdict — this function's — so a suppression applied anywhere else would
329
+ * show a field without an asterisk and still refuse the submit, which is the
330
+ * exact two-layer disagreement #4201 removed. #4085 is the conditional half:
331
+ * `requiredWhen` resolving TRUE on a create form re-required a control the
332
+ * renderer deliberately left empty, with nothing the user could type to
333
+ * unblock it. See `isServerOwnedValue` in `../validation/server-owned-value.js`
334
+ * for who computes the fact and why the mode is the caller's to state.
335
+ *
156
336
  * @param fieldContext Optional locator for failure warnings — e.g.
157
337
  * `"field 'amount'"`. The warning then reads
158
338
  * `visibleWhen of field 'amount' …`; without it, the rule
159
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.
160
342
  */
161
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.
162
348
  const diag = (rule) => ({
163
349
  context: fieldContext ? `${rule} of ${fieldContext}` : rule,
350
+ onFault: (reason) => {
351
+ faults[rule] = reason;
352
+ },
164
353
  });
165
354
  const visible = rules.visibleWhen != null
166
- ? evalFieldPredicate(rules.visibleWhen, record, true, previous, scope, diag('visibleWhen'))
167
- : true;
355
+ ? evalFieldPredicate(rules.visibleWhen, record, VISIBLE_WHEN_FAULTED, previous, scope, diag('visibleWhen'))
356
+ : VISIBLE_WHEN_ABSENT;
168
357
  const readonly = statics.readonly === true ||
169
358
  (rules.readonlyWhen != null
170
- ? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope, diag('readonlyWhen'))
171
- : false);
172
- const required = statics.required === true ||
173
- (rules.requiredWhen != null
174
- ? evalFieldPredicate(rules.requiredWhen, record, false, previous, scope, diag('requiredWhen'))
175
- : false);
176
- return { visible, readonly, required };
359
+ ? evalFieldPredicate(rules.readonlyWhen, record, READONLY_WHEN_FAULTED, previous, scope, diag('readonlyWhen'))
360
+ : READONLY_WHEN_ABSENT);
361
+ // Short-circuited, not evaluated-and-discarded: the verdict cannot depend on
362
+ // the predicate, so running it would only spend an engine call per field per
363
+ // render. A broken `requiredWhen` still warns wherever it decides something
364
+ // — the same object's EDIT form, and any create form where the field is not
365
+ // server-owned.
366
+ const required = statics.serverOwnedValue === true
367
+ ? false
368
+ : statics.required === true ||
369
+ (rules.requiredWhen != null
370
+ ? evalFieldPredicate(rules.requiredWhen, record, REQUIRED_WHEN_FAULTED, previous, scope, diag('requiredWhen'))
371
+ : REQUIRED_WHEN_ABSENT);
372
+ return { visible, readonly, required, faults };
177
373
  }
@@ -10,6 +10,8 @@ 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';
14
+ export * from './rowPredicateCanon.js';
13
15
  export * from './listConditional.js';
14
16
  export * from './optionRules.js';
15
17
  export * from './optionLint.js';
@@ -10,6 +10,8 @@ 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';
14
+ export * from './rowPredicateCanon.js';
13
15
  export * from './listConditional.js';
14
16
  export * from './optionRules.js';
15
17
  export * from './optionLint.js';
@@ -44,13 +44,39 @@ export interface RowPredicateOptions {
44
44
  * row-action `visible`/`disabled`; `false` (no style) for formatting. */
45
45
  fallback?: boolean;
46
46
  /** Extra top-level scope merged alongside the row — e.g. the global predicate
47
- * scope (`features` / `user` / `app`) a host shell provides. The row wins on
48
- * collision: a `record` or `data` key here never shadows the row. */
47
+ * scope (`features` / `user`) a host shell provides. The row wins on
48
+ * collision: a `record` key here never shadows the row. Every OTHER key —
49
+ * a host's own `data` included — reaches the predicate as the host's own
50
+ * (`83fe6e741`: `data` no longer names the row on a record surface). */
49
51
  scope?: Record<string, unknown>;
50
52
  /** When true, log a one-time warning if a *present* predicate faults. */
51
53
  warnOnError?: boolean;
52
54
  /** Label used in warning messages (e.g. an action name or rule index). */
53
55
  label?: string;
56
+ /**
57
+ * This surface has **no row of its own** — bind NOTHING for the row instead
58
+ * of binding an empty one, so a `record` the HOST SCOPE carries survives to
59
+ * the predicate (objectui#4640).
60
+ *
61
+ * The default (`false`) is this function's whole subject rule: the row is
62
+ * pinned over the scope as `record`, on both dialect paths, so `record`
63
+ * always names THIS row even when the host scope carries a key of that name
64
+ * (objectui#3796). That is right for every row surface — and exactly wrong
65
+ * for a surface that has no row, because the pin then writes an EMPTY
66
+ * `record` over the host's real one and every `record.*` predicate faults
67
+ * with "No such key". "This surface has no row of its own" and "this
68
+ * surface's row is empty" are different facts; only the latter is entitled to
69
+ * shadow the scope. Same distinction, same words, as `usePredicateRecordContext`
70
+ * in `@object-ui/react` (objectui#4075) — which is the BINDING half of this
71
+ * rule for the `useCondition` tier; this is the evaluation-entry half.
72
+ *
73
+ * Set it where the predicate is scoped to a *dialog / shell / page* rather
74
+ * than to a record — `ActionParamDialog`'s param `visible` gates are the
75
+ * first caller (objectui#4640): their scope IS the host predicate scope,
76
+ * `data` and all. `opts.fields` is meaningless here (there is no row to
77
+ * collapse relations on) and is ignored.
78
+ */
79
+ rowless?: boolean;
54
80
  /**
55
81
  * The object's field definitions (`objectSchema.fields`). Supplying them lets
56
82
  * a relation field be bound as the FOREIGN KEY the server stores, rather than
@@ -62,15 +88,105 @@ export interface RowPredicateOptions {
62
88
  }
63
89
  /**
64
90
  * Evaluate a single boolean predicate against a row record on the canonical CEL
65
- * engine (with a legacy-dialect fallback — see the module note). The row's
66
- * fields are bound three ways so every authoring convention resolves:
67
- * `record.status` (spec/canonical), bare `status` (row-action shorthand), and
68
- * `data.status` (legacy). The optional `scope` (host predicate scope) is bound
69
- * alongside so `features.*` / `user.*` predicates keep working — but the row is
70
- * the subject: `record` and `data` always name THIS row, on both dialect paths,
71
- * even when the host scope carries keys of those names (objectui#3796).
91
+ * engine (with a legacy-dialect fallback — see the module note). The row is
92
+ * bound ONE way: as `record.*` — **the canon** (maintainer ruling 2026-08-20 on
93
+ * objectui#5330, option B; Phase 2 executed by `83fe6e741`).
94
+ *
95
+ * Until Phase 2 the row was also bound as bare fields (`status`, the row-action
96
+ * shorthand) and as `data.*`, and Phase 1 (`d1ab06f0f`) warned once per
97
+ * non-canonical spelling. Both bindings and that warning are gone. A bare-field
98
+ * or `data.*` predicate on a record surface now FAULTS here exactly as it always
99
+ * did on the server — measured on `@objectstack/formula@17.1.0`,
100
+ * `buildScope({ record })` mounts exactly `['record']`, so a bare field faults
101
+ * `Unknown variable: status` and `data.*` faults `Unknown variable: data` — and
102
+ * takes this function's EXISTING fault policy: the caller's `fallback`, reported
103
+ * once by `warnEvalError` (when `warnOnError` is set) or by the canonical
104
+ * helper's own one-time warning, either of which names the unknown variable.
105
+ * Nothing detects a retired spelling on this path; it is simply unbound. The
106
+ * same holds on the legacy `${…}` path below — one scope shape per surface,
107
+ * both dialects — so `${data.x}` / `${x}` strings on a row surface fault there
108
+ * too, and land in the same fallback / warning.
109
+ *
110
+ * The retirement is scoped to the RUNTIME RECORD layer. `data` is the canonical
111
+ * root one layer over, in a metadata-editing form (ADR-0089 D3,
112
+ * `CANONICAL_ROOT_BY_LAYER` = `{ runtime: 'record', metadata: 'data' }`), and
113
+ * that layer evaluates through its own entry (`app-shell`'s metadata-admin
114
+ * `predicate.ts`), never through this one. See {@link ./rowPredicateCanon.ts}
115
+ * for the canon statement, the server measurement and the offline detector.
116
+ *
117
+ * The optional `scope` (host predicate scope) is bound alongside so
118
+ * `features.*` / `user.*` predicates keep working — but the row is the subject:
119
+ * `record` always names THIS row, on both dialect paths, even when the host
120
+ * scope carries a key of that name (objectui#3796). Every OTHER host key —
121
+ * a host's own `data` included — reaches the predicate as the host's own.
122
+ *
123
+ * A caller with no row at all passes {@link RowPredicateOptions.rowless}, and
124
+ * then nothing is bound over the scope — see that option for why an absent row
125
+ * must not be spelled as an empty one.
72
126
  */
73
127
  export declare function evalRowPredicate(pred: FieldRulePredicate | undefined | null, row: Record<string, unknown> | null | undefined, opts?: RowPredicateOptions): boolean;
128
+ /** The two halves of a {@link partitionRowsByPredicate} verdict over a selection. */
129
+ export interface RowPartition<TRow> {
130
+ /** Records whose predicate passed — the ones an operation may act on. */
131
+ eligible: TRow[];
132
+ /**
133
+ * How many records were filtered out. A caller that shrinks a user's
134
+ * selection owes them this number: a run over fewer records than they picked
135
+ * must say so rather than quietly shrinking.
136
+ */
137
+ skipped: number;
138
+ }
139
+ /**
140
+ * Split a set of records by one per-record predicate — the SET-shaped
141
+ * counterpart of {@link evalRowPredicate}, and the reason a bulk gate never
142
+ * needs a hook.
143
+ *
144
+ * A bulk affordance spans N records, so its gate is a **loop**, and React
145
+ * forbids calling `useRowPredicate` (or any hook) per iteration. Every bulk
146
+ * surface therefore reaches for `evalRowPredicate` directly; this wrapper is
147
+ * that loop written once, with the three cases each caller was otherwise
148
+ * re-deriving:
149
+ *
150
+ * - **absent** (`null` / `undefined` / `''`) — nothing is gated, so `rows` is
151
+ * returned BY REFERENCE and `skipped` is 0. The common case allocates
152
+ * nothing and a caller's downstream memo still holds. Whitespace-only text
153
+ * and an envelope whose `source` is blank are NOT absent here: they are
154
+ * evaluated, and fail closed like any other predicate that cannot run.
155
+ * - **boolean** — a verdict, not an expression, short-circuited exactly as
156
+ * `useCondition` / `useRowPredicate` do. Handing `true` to the engine
157
+ * produces `{ dialect: 'cel', source: undefined }`, which faults and — on
158
+ * this fail-closed path — would disqualify every record, so the most
159
+ * explicit way to say "always" would exclude everyone (objectui#3492).
160
+ * - **expression** — evaluated once per record with that record in scope,
161
+ * **fail-closed**: a predicate that faults must not hand the caller records
162
+ * it was written to exclude. `warnOnError` (default `true`) makes the
163
+ * resulting exclusion diagnosable once per predicate instead of silent.
164
+ *
165
+ * The fail-closed default is what separates this from a lenient record-free
166
+ * evaluation, and the difference is not mere strictness: evaluated with NO
167
+ * record bound, `record.status != 'paid'` returns `true` for every row —
168
+ * including the ones it was written to exclude — so an authored gate is not
169
+ * weakened, it is inverted for half its inputs (objectui#3067).
170
+ *
171
+ * ## Who calls it, and who decides "is a gate declared?" (objectui#11322)
172
+ *
173
+ * This is a shared partitioner, and its opening test is not the action
174
+ * family's "is a gate declared?" question. Its callers:
175
+ *
176
+ * - the bulk ACTION fold, plugin-grid's `partitionBulkRows` (a bulk def's
177
+ * `visible`). The action family decides "declared?" BEFORE calling here,
178
+ * through `hasDeclaredVisibilityGate`, and hands an undeclared gate over as
179
+ * `undefined`. So a whitespace-only `visible` or a blank-`source` envelope
180
+ * never reaches the test below from that caller.
181
+ * - the built-in Delete's `userActions.delete.visibleWhen`, a field-rule key,
182
+ * from plugin-grid's `ObjectGrid` and from plugin-list's `ListView`
183
+ * (`bulkDeleteEligibility`). The test below is THEIR answer: a blank one
184
+ * that is not `''` is evaluated and excludes every record.
185
+ *
186
+ * A caller that needs the action family's answer asks it first; it does not
187
+ * write a second blank test in front of this one.
188
+ */
189
+ export declare function partitionRowsByPredicate<TRow extends Record<string, unknown>>(pred: FieldRulePredicate | boolean | undefined | null, rows: readonly TRow[], opts?: Omit<RowPredicateOptions, 'fallback' | 'rowless'>): RowPartition<TRow>;
74
190
  /** A conditional-formatting rule in any of the three historical shapes. */
75
191
  export type ConditionalFormattingRuleLike = {
76
192
  /** Spec shape: a CEL predicate (`ExpressionInputSchema`). */