@object-ui/core 17.6.0 → 18.0.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 (147) hide show
  1. package/CHANGELOG.md +7260 -0
  2. package/README.md +228 -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 +473 -69
  6. package/dist/actions/ActionRunner.js +792 -125
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/UndoManager.d.ts +8 -13
  9. package/dist/actions/actionKeys.d.ts +9 -7
  10. package/dist/actions/actionKeys.js +67 -14
  11. package/dist/actions/actionResponse.js +2 -2
  12. package/dist/actions/index.d.ts +2 -0
  13. package/dist/actions/index.js +2 -0
  14. package/dist/actions/navigationTarget.d.ts +41 -0
  15. package/dist/actions/navigationTarget.js +20 -0
  16. package/dist/actions/recordDelete.d.ts +154 -0
  17. package/dist/actions/recordDelete.js +110 -0
  18. package/dist/adapters/ApiDataSource.d.ts +8 -4
  19. package/dist/adapters/ApiDataSource.js +19 -7
  20. package/dist/adapters/ValueDataSource.d.ts +6 -1
  21. package/dist/adapters/ValueDataSource.js +1090 -98
  22. package/dist/adapters/batchTransaction.d.ts +1 -1
  23. package/dist/adapters/batchTransaction.js +1 -1
  24. package/dist/adapters/resolveDataSource.d.ts +9 -6
  25. package/dist/adapters/resolveDataSource.js +9 -12
  26. package/dist/builder/schema-builder.d.ts +38 -81
  27. package/dist/builder/schema-builder.js +33 -152
  28. package/dist/data-scope/ViewDataProvider.d.ts +8 -25
  29. package/dist/data-scope/ViewDataProvider.js +27 -54
  30. package/dist/data-scope/element-data-source.d.ts +261 -7
  31. package/dist/data-scope/element-data-source.js +342 -52
  32. package/dist/data-scope/index.d.ts +9 -4
  33. package/dist/data-scope/index.js +9 -4
  34. package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
  35. package/dist/evaluator/ExpressionEvaluator.js +184 -12
  36. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  37. package/dist/evaluator/FormulaFunctions.js +166 -27
  38. package/dist/evaluator/deadlineSettled.d.ts +40 -0
  39. package/dist/evaluator/deadlineSettled.js +45 -0
  40. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  41. package/dist/evaluator/declaredPredicate.js +117 -16
  42. package/dist/evaluator/fieldRules.d.ts +88 -4
  43. package/dist/evaluator/fieldRules.js +208 -31
  44. package/dist/evaluator/index.d.ts +2 -0
  45. package/dist/evaluator/index.js +2 -0
  46. package/dist/evaluator/listConditional.d.ts +103 -34
  47. package/dist/evaluator/listConditional.js +127 -50
  48. package/dist/evaluator/optionRules.d.ts +77 -6
  49. package/dist/evaluator/optionRules.js +87 -10
  50. package/dist/evaluator/predicateInput.d.ts +15 -4
  51. package/dist/evaluator/predicateInput.js +35 -2
  52. package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
  53. package/dist/evaluator/rowPredicateCanon.js +65 -115
  54. package/dist/evaluator/subjectPermissions.d.ts +105 -0
  55. package/dist/evaluator/subjectPermissions.js +86 -0
  56. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  57. package/dist/evaluator/unevaluablePredicate.js +70 -0
  58. package/dist/index.d.ts +15 -0
  59. package/dist/index.js +83 -2
  60. package/dist/protocols/index.d.ts +0 -1
  61. package/dist/protocols/index.js +9 -1
  62. package/dist/registry/Registry.d.ts +382 -34
  63. package/dist/registry/Registry.js +247 -12
  64. package/dist/registry/WidgetRegistry.js +7 -3
  65. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  66. package/dist/registry/html-tier-intrinsics.js +110 -0
  67. package/dist/registry/public-blocks.d.ts +8 -0
  68. package/dist/registry/public-blocks.js +57 -0
  69. package/dist/theme/ThemeEngine.js +1 -1
  70. package/dist/utils/affordanceGrants.d.ts +371 -0
  71. package/dist/utils/affordanceGrants.js +252 -0
  72. package/dist/utils/chart-category-key.d.ts +82 -0
  73. package/dist/utils/chart-category-key.js +84 -0
  74. package/dist/utils/chart-measure-key.d.ts +64 -0
  75. package/dist/utils/chart-measure-key.js +65 -0
  76. package/dist/utils/chart-presentation.d.ts +95 -112
  77. package/dist/utils/chart-presentation.js +169 -104
  78. package/dist/utils/chart-series.d.ts +165 -5
  79. package/dist/utils/chart-series.js +123 -18
  80. package/dist/utils/column-sortability.d.ts +157 -0
  81. package/dist/utils/column-sortability.js +217 -0
  82. package/dist/utils/dashboard-filters.d.ts +38 -6
  83. package/dist/utils/dashboard-filters.js +107 -61
  84. package/dist/utils/dataset-format.d.ts +211 -30
  85. package/dist/utils/dataset-format.js +389 -46
  86. package/dist/utils/date-display.d.ts +346 -0
  87. package/dist/utils/date-display.js +644 -0
  88. package/dist/utils/date-macros.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 +81 -9
  94. package/dist/utils/expand-fields.js +81 -9
  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 +158 -6
  98. package/dist/utils/filter-converter.js +1098 -24
  99. package/dist/utils/filter-tokens.d.ts +49 -5
  100. package/dist/utils/filter-tokens.js +105 -33
  101. package/dist/utils/grouping-fields.d.ts +61 -0
  102. package/dist/utils/grouping-fields.js +84 -0
  103. package/dist/utils/managedBy.d.ts +3 -3
  104. package/dist/utils/managedBy.js +5 -5
  105. package/dist/utils/native-date-value.d.ts +48 -0
  106. package/dist/utils/native-date-value.js +167 -0
  107. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  108. package/dist/utils/non-grid-row-ceiling.js +141 -0
  109. package/dist/utils/normalize-list-view.d.ts +135 -11
  110. package/dist/utils/normalize-list-view.js +243 -64
  111. package/dist/utils/number-display.d.ts +63 -14
  112. package/dist/utils/number-display.js +34 -12
  113. package/dist/utils/object-aggregate-query.d.ts +113 -0
  114. package/dist/utils/object-aggregate-query.js +50 -0
  115. package/dist/utils/parent-scope.d.ts +123 -0
  116. package/dist/utils/parent-scope.js +140 -0
  117. package/dist/utils/predicate-fields.d.ts +45 -12
  118. package/dist/utils/predicate-fields.js +141 -12
  119. package/dist/utils/record-color.d.ts +75 -0
  120. package/dist/utils/record-color.js +65 -0
  121. package/dist/utils/record-source.d.ts +298 -0
  122. package/dist/utils/record-source.js +331 -0
  123. package/dist/utils/record-title.d.ts +148 -8
  124. package/dist/utils/record-title.js +220 -26
  125. package/dist/utils/reference-keys.d.ts +21 -22
  126. package/dist/utils/reference-keys.js +532 -29
  127. package/dist/utils/sort-query.d.ts +115 -15
  128. package/dist/utils/sort-query.js +240 -13
  129. package/dist/utils/text-comparand.d.ts +92 -0
  130. package/dist/utils/text-comparand.js +99 -0
  131. package/dist/utils/without-denied-fields.d.ts +49 -0
  132. package/dist/utils/without-denied-fields.js +52 -0
  133. package/dist/validation/index.d.ts +8 -4
  134. package/dist/validation/index.js +8 -4
  135. package/dist/validation/schema-validator.d.ts +1 -1
  136. package/dist/validation/schema-validator.js +225 -83
  137. package/dist/validation/server-owned-value.d.ts +5 -1
  138. package/dist/validation/server-owned-value.js +5 -1
  139. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  140. package/dist/validation/validators/object-validation-engine.js +14 -3
  141. package/package.json +8 -9
  142. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  143. package/dist/data-scope/DataScopeManager.js +0 -229
  144. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  145. package/dist/protocols/ResponsiveProtocol.js +0 -158
  146. package/dist/validation/validation-engine.d.ts +0 -88
  147. package/dist/validation/validation-engine.js +0 -434
@@ -11,13 +11,9 @@
11
11
  * Shared utilities for converting MongoDB-like filter operators
12
12
  * to ObjectStack FilterNode AST format.
13
13
  */
14
- import { normalizeFilterOperator } from '@objectstack/spec/ui';
15
- /**
16
- * Map MongoDB-like operators to ObjectStack filter operators.
17
- *
18
- * @param operator - MongoDB-style operator (e.g., '$gte', '$in')
19
- * @returns ObjectStack operator or null if not recognized
20
- */
14
+ import { normalizeFilterOperator, VIEW_FILTER_OPERATORS, VIEW_FILTER_LIST_VALUE_OPERATORS, VIEW_FILTER_PAIR_VALUE_OPERATORS, } from '@objectstack/spec/ui';
15
+ import { isAcceptedFilterComparand, ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE } from '@objectstack/spec/data';
16
+ import { isRefusedTextComparand, textComparandRefusalReason } from './text-comparand.js';
21
17
  /**
22
18
  * A filter operator this layer will not translate.
23
19
  *
@@ -27,7 +23,7 @@ import { normalizeFilterOperator } from '@objectstack/spec/ui';
27
23
  * fault, which is the one thing this is definitely not.
28
24
  */
29
25
  export class FilterOperatorError extends Error {
30
- constructor(message) {
26
+ constructor(message, subject) {
31
27
  super(message);
32
28
  Object.defineProperty(this, "code", {
33
29
  enumerable: true,
@@ -41,13 +37,75 @@ export class FilterOperatorError extends Error {
41
37
  writable: true,
42
38
  value: 400
43
39
  });
40
+ /** @see FilterRefusalSubject */
41
+ Object.defineProperty(this, "operator", {
42
+ enumerable: true,
43
+ configurable: true,
44
+ writable: true,
45
+ value: void 0
46
+ });
47
+ /** @see FilterRefusalSubject */
48
+ Object.defineProperty(this, "field", {
49
+ enumerable: true,
50
+ configurable: true,
51
+ writable: true,
52
+ value: void 0
53
+ });
44
54
  this.name = 'FilterOperatorError';
55
+ this.operator = subject?.operator;
56
+ this.field = subject?.field;
45
57
  }
46
58
  }
59
+ /**
60
+ * The one token a "this view's filter is malformed" state names.
61
+ *
62
+ * Operator first, because that is what the ruling asks the state to name and
63
+ * what an author can act on — `$regex` is not supported, `$startswith` is
64
+ * retired. Field second, for the refusals that have no operator to name — the
65
+ * two comparand arms and the empty operator map. `undefined` for neither, which no arm produces today and which a
66
+ * renderer must still handle rather than print "undefined" at a user.
67
+ */
68
+ export function filterRefusalSubject(error) {
69
+ return error.operator ?? error.field ?? undefined;
70
+ }
71
+ /**
72
+ * The four lowercase `$`-dialect spellings this file used to accept "for
73
+ * tolerance", and the canonical spelling each author meant.
74
+ *
75
+ * RETIRED by objectui#8568, on the maintainer's ruling that this project follows
76
+ * the ObjectStack protocol and its documentation follows the implementation.
77
+ * They were a renderer-side second dialect — exactly what AGENTS.md #0.1
78
+ * refuses, and the same ground objectui#8447 stood on when it declined to teach
79
+ * the in-memory matcher the same arms ("would fossilise a second dialect").
80
+ * Until now `convertFiltersToAST` accepted them while `ValueDataSource` refused
81
+ * them, so ONE authored filter had two fates depending on which data source was
82
+ * behind the view. One dialect, one spelling.
83
+ *
84
+ * Refused BY NAME rather than dropped into the unknown-operator arm below,
85
+ * because the two answers cost an author very different amounts: "unknown
86
+ * operator, here is the supported list" makes them diff two lists and guess
87
+ * which entry they meant, while "write `$startsWith`" IS the repair. There is no
88
+ * deprecation window (maintainer, 2026-08-27), so this message is the entire
89
+ * migration aid — the reason it is worth spelling out.
90
+ *
91
+ * ⛔ Not a lowering table in disguise: nothing reads the value as an operator.
92
+ * Every VALUE is a member of the spec's `FILTER_OPERATORS` and every KEY is
93
+ * absent from it, and both directions are EXECUTED against
94
+ * `@objectstack/spec` in `filter-alias-retirement-8568.test.ts` rather than
95
+ * restated here, so this map cannot drift into prescribing a spelling the spec
96
+ * does not declare.
97
+ */
98
+ const RETIRED_OPERATOR_ALIASES = {
99
+ '$notin': '$nin',
100
+ '$notcontains': '$notContains',
101
+ '$startswith': '$startsWith',
102
+ '$endswith': '$endsWith',
103
+ };
47
104
  export function convertOperatorToAST(operator) {
48
105
  // Spec reference: framework/packages/spec/src/data/filter.zod.ts
49
- // Canonical MongoDB-style keys are camelCase ($startsWith, $endsWith, $notContains).
50
- // Lowercase aliases are accepted for tolerance.
106
+ // Every key below is a canonical `FILTER_OPERATORS` spelling and nothing else.
107
+ // The four lowercase aliases that used to sit here are retired and refused by
108
+ // name (objectui#8568) — see RETIRED_OPERATOR_ALIASES above.
51
109
  const operatorMap = {
52
110
  '$eq': '=',
53
111
  '$ne': '!=',
@@ -57,18 +115,357 @@ export function convertOperatorToAST(operator) {
57
115
  '$lte': '<=',
58
116
  '$in': 'in',
59
117
  '$nin': 'nin',
60
- '$notin': 'nin',
61
118
  '$between': 'between',
62
119
  '$contains': 'contains',
63
120
  '$notContains': 'notcontains',
64
- '$notcontains': 'notcontains',
65
121
  '$startsWith': 'startswith',
66
- '$startswith': 'startswith',
67
122
  '$endsWith': 'endswith',
68
- '$endswith': 'endswith',
123
+ // Case-insensitive contains. A canonical `FILTER_OPERATORS` member that
124
+ // `ValueDataSource` executes and `FilterConditionField` emits (for its
125
+ // `icontains` builder row — spelled `containsCaseInsensitive` until
126
+ // objectui#9306), while this map refused it with the
127
+ // generic unknown-operator paragraph — so ONE authored filter selected rows
128
+ // through the in-memory matcher and 400'd on the ObjectStack lowering path
129
+ // (objectui#8976). The other direction of the same split objectui#8568 fixed:
130
+ // there the map was MORE tolerant than the matcher, here it was LESS tolerant
131
+ // than the contract. Restored by aligning the map with the spec, per AGENTS.md
132
+ // #0 — not by removing the operator from the builder, which the spec declares.
133
+ //
134
+ // The VALUE is an IDENTITY, and that is not a typo: `icontains` is itself a
135
+ // member of the spec's `VALID_AST_OPERATORS`, so unlike its camelCase siblings
136
+ // above there is no case to squash. Same identity row, for the same stated
137
+ // reason, that `FILTER_OPERATOR_ALIASES` carries in
138
+ // `packages/data-objectstack/src/index.ts`.
139
+ '$icontains': 'icontains',
69
140
  };
70
141
  return operatorMap[operator] || null;
71
142
  }
143
+ /**
144
+ * The `FilterCondition` combinators this lowering can carry, and the AST
145
+ * keyword each becomes.
146
+ *
147
+ * The vocabulary is the spec's, not a second list: `FILTER_ARRAY_LOGIC_KEYWORDS`
148
+ * (`@objectstack/spec/data`) is `['and', 'or']` — measured — and those two are
149
+ * exactly the heads `isFilterAST` opens a group on. `$not` is absent from it,
150
+ * which is why it has no row here and is refused below rather than translated.
151
+ */
152
+ const AST_LOGIC_KEYWORD = {
153
+ $and: 'and',
154
+ $or: 'or',
155
+ };
156
+ /**
157
+ * Lower ONE `$and` / `$or` group to an AST group node.
158
+ *
159
+ * Returns `undefined` when the group is the TRUE identity and therefore
160
+ * constrains nothing, so the caller drops it instead of emitting a childless
161
+ * `['and']` — which is NOT the same thing. Measured against the spec's own
162
+ * doors: `isFilterAST(['and'])` is `false` and `parseFilterAST(['and'])` is
163
+ * `undefined`, i.e. NO FILTER — every row. A combinator that reduces to "no
164
+ * constraint" must therefore disappear at THIS level; emitting an empty group
165
+ * would widen the result set, which is the one failure direction this file
166
+ * exists to avoid.
167
+ *
168
+ * `objectstack-ai/objectstack#5322` (maintainer ruling 2026-08-04, recorded on `FilterConditionSchema`)
169
+ * fixes the identities: `{ $and: [] }` is TRUE, `{ $or: [] }` is FALSE, and a
170
+ * `{}` disjunct is TRUE and ABSORBS its `$or`. TRUE is expressible here — it is
171
+ * the absence of a constraint. FALSE is not: the AST has no contradiction
172
+ * literal, so `{ $or: [] }` keeps the emission it already had (see
173
+ * {@link falseIdentityLeaf}).
174
+ */
175
+ function lowerLogicalGroup(field, keyword, value) {
176
+ if (!Array.isArray(value)) {
177
+ throw new FilterOperatorError(`[ObjectUI] The '${field}' filter combinator takes an ARRAY of conditions. ` +
178
+ `Received ${typeof value === 'object' ? 'an object' : typeof value}: ` +
179
+ `${JSON.stringify(value)}. Spec: FilterCondition declares ` +
180
+ `'${field}?: FilterCondition[]' (data/filter.zod.ts).`, { operator: field });
181
+ }
182
+ if (value.length === 0) {
183
+ return keyword === 'and' ? undefined : falseIdentityLeaf(field, value);
184
+ }
185
+ const children = [];
186
+ // Set when an `$or` member is the TRUE identity. Recorded, not returned
187
+ // on the spot (objectui#10789): the members AFTER it still have to be read,
188
+ // or a malformed one is refused only when it happens to come FIRST —
189
+ // `{ $or: [{ a: {} }, {}] }` refused while `{ $or: [{}, { a: {} }] }`
190
+ // answered TRUE, the same two members with two fates. The absorption itself
191
+ // is unchanged; it is decided after the loop, once every member was judged.
192
+ let absorbed = false;
193
+ for (const child of value) {
194
+ if (child === null || typeof child !== 'object' || Array.isArray(child)) {
195
+ throw new FilterOperatorError(`[ObjectUI] Every member of '${field}' must be a filter condition OBJECT. ` +
196
+ `Received ${JSON.stringify(child)}. Spec: FilterCondition declares ` +
197
+ `'${field}?: FilterCondition[]' (data/filter.zod.ts).`, { operator: field });
198
+ }
199
+ const lowered = convertFiltersToAST(child);
200
+ if (!Array.isArray(lowered)) {
201
+ // A child that produced no conditions comes back as a NON-ARRAY, in one
202
+ // of two spellings: the ORIGINAL OBJECT for a `{}` disjunct, and
203
+ // `undefined` for a child that is itself a TRUE-identity group
204
+ // (`{ $and: [{ $and: [] }] }` — objectui#8770's tail below) or holds only
205
+ // null/undefined values (objectui#9020). A child that is an EMPTY
206
+ // OPERATOR MAP (`{ a: {} }`) never gets here: the same tail refuses it,
207
+ // and the refusal propagates out of the whole filter (objectui#9164).
208
+ // Both spellings mean the same thing here, which is
209
+ // why the test is `Array.isArray` and not a comparison against either
210
+ // spelling: that child is the TRUE identity (objectstack-ai/objectstack#5322), so it absorbs an
211
+ // `$or` outright and drops out of an `$and`. It must not be pushed as a
212
+ // child either way: an object in AST child position makes `isFilterAST`
213
+ // false (measured), and the wire face answers `400 INVALID_FILTER` for
214
+ // the whole filter.
215
+ if (keyword === 'or')
216
+ absorbed = true;
217
+ continue;
218
+ }
219
+ children.push(lowered);
220
+ }
221
+ // A TRUE disjunct absorbs its `$or` (objectstack-ai/objectstack#5322) — now that every member was read.
222
+ if (absorbed)
223
+ return undefined;
224
+ // Every conjunct reduced to TRUE, so the `$and` constrains nothing.
225
+ if (children.length === 0)
226
+ return undefined;
227
+ // A one-child group is emitted as a group, not unwrapped. `['or', node]` is
228
+ // accepted by `isFilterAST` (length >= 2 = keyword + one condition) and
229
+ // `parseFilterAST` reduces it to the child, so the extra hop costs nothing
230
+ // and keeps this function's output shape a function of the INPUT shape.
231
+ return [keyword, ...children];
232
+ }
233
+ /**
234
+ * `{ $or: [] }` — FALSE, the OR identity (objectstack-ai/objectstack#5322) — as the leaf this file has
235
+ * always emitted for it.
236
+ *
237
+ * Deliberately unchanged, and deliberately not a group node. Three measurements
238
+ * against `@objectstack/spec` 17.3.0 and this repo's own evaluator decide it:
239
+ *
240
+ * - `parseFilterAST(['$or', '=', []])` is `{ $or: [] }` — the FilterCondition
241
+ * the author wrote, which every backend reduces to zero rows. Correct.
242
+ * - `ValueDataSource`'s matcher reads it as a comparison on a field named
243
+ * `$or`, which no record has, so it excludes every row. Also correct, and
244
+ * the same answer.
245
+ * - `['or']` — the "obvious" empty group — is `isFilterAST` FALSE and
246
+ * `parseFilterAST` `undefined`: no filter at all, i.e. EVERY row. That is
247
+ * the widening direction, on a filter whose whole purpose is to hide rows
248
+ * (objectstack-ai/objectstack#5134), so it is the one shape that must not be emitted.
249
+ *
250
+ * A leaf naming `$or` as a field is not a shape to be proud of; it is the shape
251
+ * that answers FALSE at both consumers, which the alternatives do not.
252
+ */
253
+ function falseIdentityLeaf(field, value) {
254
+ return [field, '=', value];
255
+ }
256
+ /**
257
+ * Is `value` an OPERATOR MAP — the shape the operator loop reads — or a
258
+ * comparand the author wrote?
259
+ *
260
+ * The loop's premise is that an object which is not an accepted comparand is a
261
+ * map of `$` operators. That premise holds for exactly one population: a PLAIN
262
+ * object, which is what an author writes and what `JSON.parse` of stored
263
+ * metadata produces. An exotic instance — `RegExp`, `Set`, `Map`, `URL`, a class
264
+ * instance — is not a map of operators under any reading; it is a VALUE, and
265
+ * `Object.entries` of each of those is `[]` (measured), which is why the loop
266
+ * used to run zero times and push NO condition for the field.
267
+ *
268
+ * The PROTOTYPE is the test, deliberately, and not `Object.keys(value).length
269
+ * === 0`: zero own entries is exactly what `{}` and `/x/` have in COMMON, and
270
+ * they need different answers — `{}` is an operator map that names no
271
+ * operator, refused as such (objectui#9164 / objectui#10788), while `/x/` is a
272
+ * VALUE the spec does not accept as a comparand, refused as that — and a keys
273
+ * count cannot tell the two apart. Prototype can. (Until objectui#10788 `{}`
274
+ * beside a key that lowers was the TRUE identity that constrained nothing,
275
+ * pinned in filter-date-comparand-8555.test.ts section 4; that row now pins
276
+ * the refusal.)
277
+ *
278
+ * The second hop admits a CROSS-REALM plain object (an iframe, a `vm` context),
279
+ * whose prototype is that realm's `Object.prototype` rather than this one's: a
280
+ * plain object's prototype has a null prototype, while a `RegExp`'s chain is one
281
+ * link longer. So the test is about SHAPE, not about object identity.
282
+ *
283
+ * A plain object with no entries stays an operator map with no operators,
284
+ * whatever else it happens to be. That boundary is deliberate:
285
+ * this predicate separates values from operator maps, and it is not a second
286
+ * opinion about the identity objectui#5322 already ruled on. What the converter
287
+ * then DOES with an operator map that has no operators is decided by its
288
+ * caller, not here: it is refused wherever it appears (objectui#9164 alone,
289
+ * objectui#10788 beside a key that lowers; see refuseEmptyOperatorMap).
290
+ */
291
+ function isOperatorMap(value) {
292
+ const proto = Object.getPrototypeOf(value);
293
+ return proto === null || proto === Object.prototype || Object.getPrototypeOf(proto) === null;
294
+ }
295
+ /**
296
+ * Name the offending comparand for the refusal message.
297
+ *
298
+ * `JSON.stringify` is what every other message in this file uses, and it is
299
+ * useless here: it renders a `RegExp`, a `Set` and a `Map` all as `{}` — the
300
+ * spec's own refusal prints `({})` for a RegExp for exactly this reason. The
301
+ * constructor name plus `String(value)` prints `/abc/` for a pattern and falls
302
+ * back to the type alone when the value has no useful text form.
303
+ *
304
+ * Every read is guarded: a refusal message must never itself throw, or the
305
+ * loud failure this arm exists to produce becomes a different, confusing one.
306
+ */
307
+ function describeExoticComparand(value) {
308
+ let name;
309
+ let text;
310
+ try {
311
+ const proto = Object.getPrototypeOf(value);
312
+ name = proto?.constructor?.name;
313
+ }
314
+ catch {
315
+ name = undefined;
316
+ }
317
+ if (typeof name !== 'string' || name === '')
318
+ return 'an object of an unrecognised type';
319
+ try {
320
+ text = String(value);
321
+ }
322
+ catch {
323
+ text = undefined;
324
+ }
325
+ return text && text !== `[object ${name}]` ? `a ${name} instance (${text})` : `a ${name} instance`;
326
+ }
327
+ /**
328
+ * An `$icontains` comparand that is not a NON-EMPTY STRING —
329
+ * `{ name: { $icontains: '' } }`, `{ name: { $icontains: 42 } }` (objectui#9001).
330
+ *
331
+ * ## Not this file's ruling, and not a new one
332
+ *
333
+ * `FILTER_TEXT_CASES` (`@objectstack/spec/data`) carries both shapes as
334
+ * REJECTION rows rather than as row-set expectations, each with
335
+ * `code: 'INVALID_FILTER'` and `mustMention: ['$icontains']`:
336
+ *
337
+ * > *an empty `$icontains` comparand is REFUSED* — "Every row contains the
338
+ * > empty substring, so evaluating it is a predicate that constrains nothing —
339
+ * > the widening [objectstack-ai/objectstack#5240] refused `{ field: {} }` over, one level in."
340
+ *
341
+ * > *a non-string `$icontains` comparand is REFUSED* — "Coercing 42 to `"42"`
342
+ * > would answer a query nobody wrote; the declared comparand type is string."
343
+ *
344
+ * `ValueDataSource` has answered both since objectui#8748
345
+ * (`refuseTextComparand`), so this is a PORT of a shipped, reviewed
346
+ * implementation one data source over — deliberately NOT a second design.
347
+ * Measured on `origin/main` `152f0a700` (objectui#8996 and objectui#9019 both
348
+ * landed), both faces in one process:
349
+ *
350
+ * ```
351
+ * $icontains '' LOWER=["name","icontains",""] MATCH=[] REFUSED(1)
352
+ * $icontains 42 LOWER=["name","icontains",42] MATCH=[] REFUSED(1)
353
+ * ```
354
+ *
355
+ * One authored filter, refused by the in-memory matcher and lowered onto the
356
+ * wire by the ObjectStack path — the same acceptance-set split objectui#8568 and
357
+ * objectui#8976 each closed on the operator-KEY axis, here on the COMPARAND
358
+ * axis. ⚠️ objectui#8996 did not open this door: before it the `$` dialect was
359
+ * stopped by the generic unknown-operator arm, an accident rather than a
360
+ * comparand ruling, while the object-form dialect
361
+ * (`FILTER_OPERATOR_ALIASES` in `@object-ui/data-objectstack`) already lowered
362
+ * the same node with no converter involved.
363
+ *
364
+ * ## What transfers from the sibling, and what cannot
365
+ *
366
+ * The DISCRIMINATION and the MESSAGE transfer verbatim, and the message is
367
+ * load-bearing rather than cosmetic: `mustMention: ['$icontains']` means a
368
+ * differently-worded refusal is a different failure to honour the same
369
+ * contract, not a stylistic variant.
370
+ * `filter-text-comparand-9001.test.ts` pins the two messages against each other
371
+ * by DRIVING both faces and asserting this one contains the sibling's refusal
372
+ * text, so the mirror cannot drift in silence.
373
+ *
374
+ * ⭐ Since objectui#9048 "transfer verbatim" is no longer a claim about two
375
+ * copies agreeing: both halves are ONE implementation in `text-comparand.ts`,
376
+ * and this function is the envelope that seats it. The pin above is kept, and
377
+ * is now a pin on the SEATING rather than on a transcription.
378
+ *
379
+ * The DELIVERY cannot transfer, and that is the card's own Q1 answered by the
380
+ * two call sites rather than by a fresh ruling. `ValueDataSource`
381
+ * excludes-and-logs because it is deciding about one ROW and HAS a row to
382
+ * exclude; this function is the PRODUCER deciding whether to send a query at
383
+ * all, and has none. Its declared refusal shape is {@link FilterOperatorError},
384
+ * which carries exactly the `INVALID_FILTER` / 400 envelope the spec rows
385
+ * declare and which this file already uses for `$regex`, `$not`, the retired
386
+ * aliases and three comparand shapes. Same code, same message, each face's own
387
+ * envelope.
388
+ *
389
+ * ## Scope
390
+ *
391
+ * Only `$icontains`, because only `$icontains` is what the table declares. The
392
+ * sibling positive operators (`$contains` / `$startsWith` / `$endsWith`) have no
393
+ * such row and keep the answer they have always given here — the same boundary
394
+ * `refuseTextComparand`'s own docblock draws one data source over. Widening it
395
+ * by analogy is the published table's decision, not this file's, and the
396
+ * asymmetry is pinned so the next reader sees a scope boundary rather than an
397
+ * oversight.
398
+ */
399
+ function refuseTextComparand(field, operator, target) {
400
+ const ported = `It is refused here rather than lowered onto the wire (objectui#9001; ported from `
401
+ + `ValueDataSource's refuseTextComparand, objectui#8748).`;
402
+ throw new FilterOperatorError(`[ObjectUI] The ${textComparandRefusalReason(field, operator, target)}. ${ported}`, { operator, field });
403
+ }
404
+ /**
405
+ * A field that names NO operator — `{ a: {} }` — wherever it appears in a
406
+ * filter: alone (objectui#9164) or beside a key that lowers (objectui#10788).
407
+ *
408
+ * ## What it used to do
409
+ *
410
+ * The loop ENTERS the key — the value is a plain object, so it is an operator
411
+ * map — and the operator loop runs zero times, so NO condition is pushed for
412
+ * the field. What happened next depended on the field's SIBLINGS, the hazard
413
+ * objectui#8555 named on this file:
414
+ *
415
+ * - beside a key that lowers, the sibling's condition carried the filter and
416
+ * the field VANISHED from it: `{ status: 'a', created: {} }` lowered to
417
+ * `['status', '=', 'a']`, byte-identical to `{ status: 'a' }` on both
418
+ * `find()` routes of `@object-ui/data-objectstack` — a result set WIDER than
419
+ * the author wrote, silently (objectui#10788);
420
+ * - alone, the general tail handed back the CALLER'S ORIGINAL OBJECT, which
421
+ * reached those two routes as two different wrong requests: the plain route
422
+ * spread it into query parameters (`?a=[object Object]`, and no `filter`
423
+ * parameter at all), the `$expand` / `$search` route serialised it into
424
+ * `filter={"a":{}}` (objectui#9164).
425
+ *
426
+ * ## Why a refusal, and not a fold or a lowering
427
+ *
428
+ * - There is nothing to LOWER it to. `@objectstack/spec` ruled
429
+ * `{ field: {} }` REJECTED (objectstack#5240, recorded on
430
+ * `FilterConditionSchema`) in every position, so the request above is one
431
+ * the platform refuses, and inventing a node for it would invent semantics.
432
+ * - FOLDING it — dropping the key, which is what the beside-a-key case did —
433
+ * silently drops a filter the author wrote, and "no constraint on this
434
+ * field" widens the result on a slot whose purpose can be to hide rows.
435
+ * - A refusal is what this function already answers for the bare-array and the
436
+ * exotic-comparand arms, for the same reason: the author learned nothing at
437
+ * lowering time.
438
+ *
439
+ * ## Where — the operator-map arm, not the tail
440
+ *
441
+ * It is refused where it is met, in the loop, like the two comparand arms.
442
+ * objectui#9164 judged it in the tail and so reached it only when nothing else
443
+ * produced a condition; beside a key that lowers the tail is never entered,
444
+ * which is exactly the case objectui#10788 closes. One throw site serves both,
445
+ * so the alone case is not refused twice over and cannot drift from the
446
+ * beside case. A `$and` / `$or` member is converted by this same function, so
447
+ * a member that is or carries an empty operator map is refused too, once
448
+ * `lowerLogicalGroup` converts it (a TRUE disjunct earlier in an `$or` still
449
+ * returns first; objectui#10789).
450
+ *
451
+ * `field` is the only handle, so it travels as the refusal's subject and no
452
+ * `operator` is set: the author wrote none, and naming one would send them
453
+ * looking for a key their filter does not contain.
454
+ */
455
+ function refuseEmptyOperatorMap(field) {
456
+ throw new FilterOperatorError(`[ObjectUI] The filter on field '${field}' is an EMPTY operator map — ` +
457
+ `{ ${field}: {} } names the field and no operator. It cannot be lowered: the ` +
458
+ `ObjectQL filter AST has no node for a field with zero operators, and ` +
459
+ `'@objectstack/spec' ruled the shape REJECTED wherever it appears ` +
460
+ `(objectstack#5240, recorded on FilterConditionSchema). It is refused here ` +
461
+ `rather than dropped: beside a key that lowers, the '${field}' condition used ` +
462
+ `to vanish from the filter and silently WIDEN the result set (objectui#10788), ` +
463
+ `and alone it was handed back as the caller's object, which reached the wire ` +
464
+ `as ?${field}=[object Object] on one route and as a filter the server refuses ` +
465
+ `on the other (objectui#9164). Choose an operator — ` +
466
+ `{ ${field}: { $eq: ... } }, { ${field}: { $in: [...] } }, ` +
467
+ `{ ${field}: { $null: true } } — or remove the key.`, { field });
468
+ }
72
469
  /**
73
470
  * Convert object-based filters to ObjectStack FilterNode AST format.
74
471
  * Converts MongoDB-like operators to ObjectStack filter expressions.
@@ -91,17 +488,257 @@ export function convertOperatorToAST(operator) {
91
488
  * convertFiltersToAST({ age: { $gte: 18, $lte: 65 }, status: 'active' })
92
489
  * // => ['and', ['age', '>=', 18], ['age', '<=', 65], ['status', '=', 'active']]
93
490
  *
94
- * @throws {Error} If an unknown operator is encountered
491
+ * @example
492
+ * // Logical combinators (objectui#6948) — children lower recursively
493
+ * convertFiltersToAST({ $or: [{ status: 'open' }, { status: 'blocked' }] })
494
+ * // => ['or', ['status', '=', 'open'], ['status', '=', 'blocked']]
495
+ *
496
+ * @example
497
+ * // A Date is a comparand, not an operator map (objectui#8555). It lowers as
498
+ * // the Date INSTANCE — the spec accepts one, and the operator form already
499
+ * // emits one.
500
+ * convertFiltersToAST({ created: new Date('2026-01-01') })
501
+ * // => ['created', '=', Date(2026-01-01)]
502
+ *
503
+ * @throws {FilterOperatorError} If an unknown operator is encountered, if
504
+ * `$not` is used — see the `$not` arm for why the AST cannot carry it — if a
505
+ * field's value is a bare ARRAY (`{ tags: ['a', 'b'] }`) — see the array arm for
506
+ * why that is refused rather than read as `$in` — or if a field's value is an
507
+ * EXOTIC object the spec does not accept as a comparand (`{ name: /abc/ }`,
508
+ * a `Set`, a `Map`): see the exotic-comparand arm for why that is refused
509
+ * rather than dropped (objectui#8567), or if an `$icontains` comparand is not a
510
+ * NON-EMPTY STRING (`{ name: { $icontains: '' } }`, `{ name: { $icontains: 42 } }`)
511
+ * — two shapes `@objectstack/spec`'s `FILTER_TEXT_CASES` declares REFUSED and
512
+ * `ValueDataSource` has refused since objectui#8748; see
513
+ * {@link refuseTextComparand} (objectui#9001), or if a field is an EMPTY
514
+ * operator map (`{ a: {} }`) — alone (objectui#9164) or
515
+ * beside a key that lowers (objectui#10788): see {@link refuseEmptyOperatorMap},
516
+ * or if a `$empty` flag is not a boolean (`{ a: { $empty: 'yes' } }`,
517
+ * objectui#11094).
518
+ *
519
+ * @example
520
+ * // A field with NO operator (objectui#9164)
521
+ * convertFiltersToAST({ a: {} })
522
+ * // => throws FilterOperatorError (INVALID_FILTER / 400), field 'a'
523
+ *
524
+ * @example
525
+ * // … and beside a key that lowers, where it used to vanish and widen the
526
+ * // result to `['status', '=', 'a']` (objectui#10788)
527
+ * convertFiltersToAST({ status: 'a', created: {} })
528
+ * // => throws FilterOperatorError (INVALID_FILTER / 400), field 'created'
529
+ *
530
+ * @example
531
+ * // A filter that is NOTHING BUT combinators reducing to the TRUE identity
532
+ * // constrains nothing, and says so (objectui#8770). Callers skip the slot.
533
+ * convertFiltersToAST({ $and: [] })
534
+ * // => undefined
535
+ *
536
+ * @example
537
+ * // A filter whose EVERY key is skipped for a null/undefined value also
538
+ * // constrains nothing — the same answer this function already gives that key
539
+ * // when a sibling survives (objectui#9020). Callers skip the slot.
540
+ * convertFiltersToAST({ a: null })
541
+ * // => undefined
542
+ *
543
+ * @example
544
+ * // … and a filter that MIXES the two constrains nothing either, because the
545
+ * // identity fold counts only the keys the loop actually PROCESSED
546
+ * // (objectui#9030). A skipped key used to be counted against that fold, so a
547
+ * // filter whose every key folds alone did not fold together.
548
+ * convertFiltersToAST({ $and: [], b: undefined })
549
+ * // => undefined
95
550
  */
96
551
  export function convertFiltersToAST(filter) {
97
552
  const conditions = [];
553
+ /**
554
+ * How many keys were a `$and` / `$or` group that reduced to the TRUE identity
555
+ * — see the tail for why a COUNT rather than a flag.
556
+ */
557
+ let trueIdentityGroups = 0;
558
+ /**
559
+ * How many keys the loop SKIPPED for a `null` / `undefined` value — a SECOND
560
+ * count, deliberately not folded into the one above. The tail says why the
561
+ * two "this constrains nothing" states are counted apart even though they
562
+ * reach the same answer (objectui#9020).
563
+ */
564
+ let skippedNullKeys = 0;
98
565
  for (const [field, value] of Object.entries(filter)) {
99
- if (value === null || value === undefined)
566
+ if (value === null || value === undefined) {
567
+ skippedNullKeys += 1;
568
+ continue;
569
+ }
570
+ // Logical combinators are read BEFORE the field/operator machinery below,
571
+ // because they are not fields and their value is not an operator map.
572
+ // Without this arm `$and` / `$or` reached the simple-equality branch (their
573
+ // value is an array, so the operator loop was skipped) and became a leaf
574
+ // naming a field literally called `$and` / `$or`, while `$not` entered the
575
+ // operator loop with its OWN nested object's keys read as operator names.
576
+ const logicKeyword = AST_LOGIC_KEYWORD[field];
577
+ if (logicKeyword) {
578
+ const group = lowerLogicalGroup(field, logicKeyword, value);
579
+ if (group !== undefined)
580
+ conditions.push(group);
581
+ else
582
+ trueIdentityGroups += 1;
100
583
  continue;
584
+ }
585
+ if (field === '$not') {
586
+ throw new FilterOperatorError(`[ObjectUI] The '$not' filter combinator cannot be lowered to the ObjectQL ` +
587
+ `filter AST. '@objectstack/spec' declares it on FilterCondition, but the AST ` +
588
+ `this layer emits has no negation keyword (FILTER_ARRAY_LOGIC_KEYWORDS is ` +
589
+ `['and', 'or']), and rewriting the negation inward is not available either — ` +
590
+ `'startswith', 'endswith', 'between' and 'icontains' have no negated ` +
591
+ `counterpart in VALID_AST_OPERATORS, so the rewrite would be silently ` +
592
+ `partial. Express the negation with a negated operator instead ($ne, $nin, ` +
593
+ `$notContains); note those follow each operator's own answer for a missing ` +
594
+ `value rather than $not's NULL-safe rule (objectstack#5146). ` +
595
+ `Value: ${JSON.stringify(value)}.`, { operator: '$not' });
596
+ }
597
+ // A bare ARRAY in comparand position is the third shape this file cannot
598
+ // lower, after `$regex` and `$not` (objectui#8530). The equality `else`
599
+ // below used to catch it and emit `[field, '=', [...]]` — an array in a
600
+ // scalar-equality slot. The spec's own doors pass that node through
601
+ // unjudged (`isFilterAST` is true and `parseFilterAST` hands back
602
+ // `{ field: [...] }`; measured against @objectstack/spec 17.3.0, whose
603
+ // `assertListComparandShapes` rules only on `$in` / `$nin` / `$between`),
604
+ // so the refusal arrived two layers later: `@objectstack/driver-sql`
605
+ // answers 400 INVALID_FILTER, and the in-memory matchers
606
+ // (`@objectstack/formula`, `ValueDataSource` since objectui#8514) refuse
607
+ // the node and exclude every row. Either way the author learned nothing at
608
+ // lowering time. Refused HERE instead, where the field name and the
609
+ // offending value are both still in hand.
610
+ //
611
+ // NOT lowered to `in`. `{ tags: ['a', 'b'] }` and `{ tags: { $in: ['a', 'b'] } }`
612
+ // are different statements and the second is already spellable; rewriting
613
+ // one into the other guesses at intent and silently changes which rows a
614
+ // stored view returns — the lenient second contract objectui#8514 was
615
+ // resolved against on this same data shape one layer down. A lowering to
616
+ // `in` would be a deliberate, separately-argued change, not a fallback.
617
+ //
618
+ // `$in` / `$nin` / `$between` MEMBERS are legitimately arrays and are read
619
+ // by the operator loop below, never here; `$and` / `$or` carry arrays of
620
+ // conditions and were consumed by the combinator arm above.
621
+ if (Array.isArray(value)) {
622
+ throw new FilterOperatorError(`[ObjectUI] The filter on field '${field}' carries a bare ARRAY as its ` +
623
+ `equality comparand: ${JSON.stringify(value)}. It cannot be lowered: the ` +
624
+ `ObjectQL filter AST has no array-equality node, so the lowered node ` +
625
+ `[${field}, '=', [...]] is refused by @objectstack/driver-sql (400 INVALID_FILTER) ` +
626
+ `and matches no row in the in-memory matchers — it can never select anything. ` +
627
+ `It is deliberately NOT read as membership here: { ${field}: [...] } and ` +
628
+ `{ ${field}: { $in: [...] } } are different statements, and guessing the ` +
629
+ `second from the first would silently change which rows a stored view ` +
630
+ `returns (objectui#8530; the same ruling objectui#8514 applied one layer ` +
631
+ `down). Spell membership as { ${field}: { $in: [...] } }, its negation as ` +
632
+ `{ ${field}: { $nin: [...] } }, or a range as { ${field}: { $between: [min, max] } }.`, { field });
633
+ }
101
634
  // Check if value is a complex operator object
102
635
  if (typeof value === 'object' && !Array.isArray(value)) {
636
+ // A `Date` is a COMPARAND, not an operator map — objectui#8555.
637
+ //
638
+ // `typeof new Date()` is `'object'` and a Date is not an array, so it used
639
+ // to enter the loop below; `Object.entries(someDate)` is `[]`, the body
640
+ // never ran, and NO condition was pushed for the field. Not refused, not
641
+ // lowered wrongly — ABSENT, so `{ status: 'a', created: someDate }`
642
+ // lowered to `['status', '=', 'a']` and the result set got WIDER than the
643
+ // author asked for, silently. That is the one failure direction this file
644
+ // exists to avoid. It also made the field's behaviour depend on its
645
+ // SIBLINGS: a Date alone left `conditions` empty, so the original object
646
+ // came back untouched and the defect was invisible until a second field
647
+ // appeared.
648
+ //
649
+ // Lowered rather than refused, and the spec is what decides it — the
650
+ // opposite answer to objectui#8514, which was a refusal precisely because
651
+ // the spec DECLINED to rule on that shape. Here it rules, twice over
652
+ // (measured against @objectstack/spec 17.3.0):
653
+ // `ACCEPTED_FILTER_COMPARAND_TYPES` is
654
+ // `['string','number','bigint','boolean','null','Date']`, and
655
+ // `$gt`/`$gte`/`$lt`/`$lte`/`$between` declare `z.ZodDate` in comparand
656
+ // position. So a Date is a first-class filter comparand, not a shape this
657
+ // layer has to invent an answer for.
658
+ //
659
+ // ⛔ NOT converted to an ISO string or an epoch here. The wire form is not
660
+ // this adapter's question to answer: `parseFilterAST(['created', '=', d])`
661
+ // hands back `{ created: d }` with the Date INSTANCE intact (measured), and
662
+ // `normalizeFilterComparandTypes` accepts it as-is. The operator arm below
663
+ // already passes a Date through untouched (`{ created: { $gte: d } }` →
664
+ // `['created', '>=', d]`), so stringifying here would make the shorthand
665
+ // and the operator form emit two different comparand types for the same
666
+ // author intent — a second dialect, in the file whose whole job is to have
667
+ // one.
668
+ //
669
+ // The gate is the spec's own predicate rather than a local `instanceof
670
+ // Date`, the same reason `normalizeFilterOperator` is used below instead of
671
+ // a second operator map. Today `Date` is its only object-typed member
672
+ // (pinned in filter-date-comparand-8555.test.ts), so this arm is a Date arm
673
+ // in practice; if the spec ever accepts another object-shaped literal, this
674
+ // reads it as a comparand instead of silently dropping it.
675
+ if (isAcceptedFilterComparand(value)) {
676
+ conditions.push([field, '=', value]);
677
+ continue;
678
+ }
679
+ // An EXOTIC object in comparand position — objectui#8567, the other half
680
+ // of the hole objectui#8555 closed.
681
+ //
682
+ // Same silent failure, one door further along: `Object.entries(/abc/)`,
683
+ // `Object.entries(new Set(['x']))` and `Object.entries(new Map())` are all
684
+ // `[]` (measured), so the loop below ran zero times and pushed NO condition
685
+ // for the field. `{ status: 'a', created: /abc/ }` lowered to
686
+ // `['status', '=', 'a']` — WIDER than the author asked for, with nothing
687
+ // thrown and nothing logged. With the exotic value alone `conditions` ended
688
+ // empty and the original object came back untouched, so the defect needed a
689
+ // sibling field to become visible at all.
690
+ //
691
+ // REFUSED rather than lowered, and — unlike the arm above — the spec rules
692
+ // it OUT rather than IN (measured against @objectstack/spec 17.3.0, floor
693
+ // ^17.2.0): `isAcceptedFilterComparand(/x/)` is `false`, and
694
+ // `normalizeFilterComparandTypes({ created: /x/ })` answers `INVALID_FILTER`
695
+ // / 400 — "Filter comparand at where.created is a RegExp instance ({}),
696
+ // which no driver can compare." Lowering it would only move that refusal
697
+ // downstream; dropping it is the defect. So the answer the wire would give
698
+ // two layers later is given HERE, where the field name and the offending
699
+ // value are both still in hand.
700
+ //
701
+ // ⚠️ This is NOT the class the `$regex` and `$not` refusals below belong
702
+ // to, and their message idiom is deliberately not copied. Those two answer
703
+ // "this layer has no TARGET for your operator" — `$regex` is absent from
704
+ // the spec's `FILTER_OPERATORS`, and `$not` is declared by the spec but has
705
+ // no keyword in the AST dialect this file emits. This one answers a
706
+ // question about a VALUE: the spec's own comparand-type predicate rejects
707
+ // it. The nearest neighbour is the bare-array arm above, which is also
708
+ // about a comparand — but that refusal rests on the spec DECLINING to rule
709
+ // (`assertListComparandShapes` rules only on `$in` / `$nin` / `$between`),
710
+ // which is the opposite authority to this one. What IS shared is the error
711
+ // TYPE: `FilterOperatorError` is named for operators but already carries
712
+ // four non-operator refusals in this file, because what it actually
713
+ // transports is the `INVALID_FILTER` / 400 envelope `classifyLoadError`
714
+ // reads — see its declaration.
715
+ //
716
+ // The accepted-type list is the spec's own sentence, not a second list
717
+ // spelled out here, for the same reason the gate above is the spec's
718
+ // predicate rather than a local `instanceof Date`.
719
+ if (!isOperatorMap(value)) {
720
+ throw new FilterOperatorError(`[ObjectUI] The filter on field '${field}' carries ${describeExoticComparand(value)} ` +
721
+ `in comparand position. '@objectstack/spec' does not accept it: a comparison value must ` +
722
+ `be ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (ACCEPTED_FILTER_COMPARAND_TYPES), so ` +
723
+ `isAcceptedFilterComparand is false for it and normalizeFilterComparandTypes answers 400 ` +
724
+ `INVALID_FILTER — "which no driver can compare". It is not an operator map either: only ` +
725
+ `a plain object can be one, so until objectui#8567 this value was read as ZERO operators ` +
726
+ `and the '${field}' condition was dropped from the filter entirely, silently WIDENING the ` +
727
+ `result set. Spell a text match as { ${field}: { $contains: '...' } } ($startsWith / ` +
728
+ `$endsWith; a pattern itself has no operator here, see the $regex refusal), a membership ` +
729
+ `test as { ${field}: { $in: [...] } }, and a date bound as ` +
730
+ `{ ${field}: { $gte: new Date(...) } }.`, { field });
731
+ }
732
+ // An operator map with NO operators — `{ created: {} }` — would push
733
+ // nothing below. It is refused HERE, where it is met, and not in the
734
+ // tail: beside a key that lowers, the tail is never reached, and the key
735
+ // used to vanish from the filter (objectui#10788). Alone, it is refused
736
+ // by this same line (objectui#9164). See refuseEmptyOperatorMap.
737
+ const operatorEntries = Object.entries(value);
738
+ if (operatorEntries.length === 0)
739
+ refuseEmptyOperatorMap(field);
103
740
  // Handle operator-based filters
104
- for (const [operator, operatorValue] of Object.entries(value)) {
741
+ for (const [operator, operatorValue] of operatorEntries) {
105
742
  // `$regex` is refused, not downgraded. It used to become `contains`
106
743
  // behind a `console.warn` — but substring matching is a DIFFERENT
107
744
  // QUESTION, not a weaker version of the same one: `$regex: 'a.c'`
@@ -117,7 +754,8 @@ export function convertFiltersToAST(filter) {
117
754
  `converted to 'contains', which matches a literal substring rather than a ` +
118
755
  `pattern — a different result, not a degraded one. ` +
119
756
  `Field: '${field}', Value: ${JSON.stringify(operatorValue)}. ` +
120
- `Use $contains, $startsWith or $endsWith.`);
757
+ `Use $contains for a case-sensitive substring, $icontains for a ` +
758
+ `case-insensitive one, or $startsWith / $endsWith.`, { operator: '$regex', field });
121
759
  }
122
760
  // $null / $exists translate based on their boolean value (per spec semantics).
123
761
  // $null: true → IS NULL | $null: false → IS NOT NULL
@@ -130,16 +768,66 @@ export function convertFiltersToAST(filter) {
130
768
  conditions.push([field, operatorValue ? 'is_not_null' : 'is_null', true]);
131
769
  continue;
132
770
  }
771
+ // objectui#11094 — `$empty`, admitted to the spec's `FILTER_OPERATORS`
772
+ // in `@objectstack/spec` 17.6.0 (objectstack#20446). The spec's own
773
+ // `parseFilterAST` reads `is_empty` as `$empty: true` and `is_not_empty`
774
+ // as `$empty: false`, so this is the inverse of that lowering, written
775
+ // the way `$null` is written above: direction from the flag, a `true`
776
+ // placeholder in the value slot, which the spec discards.
777
+ //
778
+ // The flag is a BOOLEAN by declaration (`SpecialOperatorSchema`), so a
779
+ // non-boolean is refused rather than read for its truthiness. That is
780
+ // the answer the spec's save door and every query face give, and
781
+ // `ValueDataSource`'s `$empty` arm refuses the same values, so one
782
+ // authored filter gets one answer whichever data source is behind it.
783
+ // Until this arm the operator reached the unknown-operator throw below.
784
+ if (operator === '$empty') {
785
+ if (operatorValue !== true && operatorValue !== false) {
786
+ throw new FilterOperatorError(`[ObjectUI] The '$empty' filter operator on field '${field}' takes a boolean flag ` +
787
+ `(true or false); received ${JSON.stringify(operatorValue) ?? String(operatorValue)}. ` +
788
+ `Write { ${field}: { $empty: true } } for an empty value, or ` +
789
+ `{ ${field}: { $empty: false } } for a non-empty one.`, { operator: '$empty', field });
790
+ }
791
+ conditions.push([field, operatorValue ? 'is_empty' : 'is_not_empty', true]);
792
+ continue;
793
+ }
133
794
  const astOperator = convertOperatorToAST(operator);
134
795
  if (astOperator) {
796
+ // objectui#9001 — the comparand door, at the ONE place this function
797
+ // reads a comparand. It runs before the push, so the refused node is
798
+ // never built; there is no `continue` and no key is skipped, which is
799
+ // what keeps the TRUE-identity tail's key-count comparison
800
+ // (objectui#8770, and the denominator objectui#9030 narrowed to the
801
+ // keys the loop processes) reading exactly what it read before.
802
+ //
803
+ // Keyed on the LOWERED operator rather than on the `$` spelling: the
804
+ // rule belongs to `icontains` itself, and `ValueDataSource`'s AST arm
805
+ // is keyed the same way. The `$` spelling the AUTHOR wrote is what
806
+ // travels into the message, which is what `FILTER_TEXT_CASES`'
807
+ // `mustMention: ['$icontains']` is about.
808
+ if (astOperator === 'icontains' && isRefusedTextComparand(operatorValue)) {
809
+ refuseTextComparand(field, operator, operatorValue);
810
+ }
135
811
  conditions.push([field, astOperator, operatorValue]);
136
812
  }
137
813
  else {
814
+ // A RETIRED lowercase alias is answered by name, before the generic
815
+ // arm below can swallow it into "unknown operator" (objectui#8568).
816
+ const canonical = RETIRED_OPERATOR_ALIASES[operator];
817
+ if (canonical) {
818
+ throw new FilterOperatorError(`[ObjectUI] The '${operator}' filter operator is retired. Write ` +
819
+ `'${canonical}' instead — the canonical spelling '@objectstack/spec' declares ` +
820
+ `in FILTER_OPERATORS (data/filter.zod.ts). The operator itself is unchanged; ` +
821
+ `only the key is renamed. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. ` +
822
+ `It used to be accepted here as a lowercase alias while the in-memory matcher ` +
823
+ `refused it, so the same filter selected rows through one data source and none ` +
824
+ `through the other (objectui#8568).`, { operator, field });
825
+ }
138
826
  // Unknown operator - throw error to avoid silent failure
139
827
  throw new FilterOperatorError(`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` +
140
828
  `Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, ` +
141
- `$contains, $notContains, $startsWith, $endsWith, $null, $exists. ` +
142
- `If you need exact object matching, use the value directly without an operator.`);
829
+ `$contains, $notContains, $startsWith, $endsWith, $icontains, $null, $exists, $empty. ` +
830
+ `If you need exact object matching, use the value directly without an operator.`, { operator, field });
143
831
  }
144
832
  }
145
833
  }
@@ -148,8 +836,136 @@ export function convertFiltersToAST(filter) {
148
836
  conditions.push([field, '=', value]);
149
837
  }
150
838
  }
151
- // If no conditions, return original filter
152
839
  if (conditions.length === 0) {
840
+ // The WHOLE filter was combinators that reduced to the TRUE identity —
841
+ // objectui#8770.
842
+ //
843
+ // `lowerLogicalGroup` answers `undefined` for a group that constrains
844
+ // nothing (`{ $and: [] }`, `{ $and: [{}] }`, `{ $or: [{}] }` — the
845
+ // identities objectstack#5322 rules TRUE), deliberately, so that no
846
+ // childless `['and']` is emitted. When such a group was the only thing in
847
+ // the filter that `undefined` reached the tail below, and the CALLER'S
848
+ // ORIGINAL OBJECT came back in its place — so the group did not disappear
849
+ // at this level after all. It reappeared one level up, in the `$` dialect,
850
+ // in a slot the AST is expected to occupy.
851
+ //
852
+ // What that cost, measured against @objectstack/spec 17.4.0 and
853
+ // @objectstack/client 17.4.0 rather than assumed: the object is NOT handed
854
+ // to the wire as a filter and refused there. `client.data.find()` tests the
855
+ // value with `isFilterAST`, and its ELSE branch spreads a plain object's
856
+ // entries as query parameters — so `{ $and: [] }` left as `?$and=` and
857
+ // `{ $or: [{}] }` as `?$or=[object Object]`, with no `filter` parameter at
858
+ // all, and the server refused the unknown `$`-prefixed parameter with `400
859
+ // UNSUPPORTED_QUERY_PARAM`. A filter whose ruled answer is EVERY ROW was a
860
+ // failed list. (The `$expand` / `$search` route sends the same object as
861
+ // `filter={"$and":[]}`, which the server accepts as a `FilterCondition` and
862
+ // answers with every row — so the two routes disagreed about one filter,
863
+ // and the fix makes both say what the ruling says.)
864
+ //
865
+ // `undefined` is not a new vocabulary invented here. It is already what
866
+ // `toFilterNode`, `mergeFilterNodes` and data-objectstack's
867
+ // `translateFilterToAST` mean by "no filter, skip the slot", and every call
868
+ // site of this function already acts on it: `lowerLogicalGroup` above tests
869
+ // `Array.isArray`, the other three test for `undefined` or falsiness.
870
+ //
871
+ // ⛔ Scoped to a filter whose every PROCESSED key is such a group, which is
872
+ // why the count above is compared with a count instead of being a flag.
873
+ // Inputs that are not combinators at all are NOT this case: `{}` still
874
+ // reaches the `return filter` below, and an empty operator map never gets
875
+ // this far — the operator-map arm refuses it where it is met
876
+ // (objectui#9164, objectui#10788).
877
+ //
878
+ // ⭐ The denominator is the number of keys the LOOP ACTUALLY PROCESSED, not
879
+ // `Object.keys(filter).length` — objectui#9030. Those differ by exactly the
880
+ // keys the loop's own first statement skipped, and counting a skipped key
881
+ // against this fold made the fold's answer depend on a key that, by this
882
+ // function's oldest ruling, contributes nothing: `{ $and: [] }` folded and
883
+ // `{ $and: [], b: undefined }` did not, though `JSON.stringify` drops the
884
+ // second key entirely and the two reach either wire route as the same
885
+ // bytes. Both `null` and `undefined` are subtracted, because the loop skips
886
+ // them with one statement and the guard below already gives that whole
887
+ // class the same `undefined` when it is alone — leaving the MIXTURE out
888
+ // would mean adding an always-TRUE `$and: []` to a filter that folds could
889
+ // stop it folding, which is the sibling-dependence hazard objectui#8555
890
+ // named on this file.
891
+ //
892
+ // ⚠ Subtraction, not a merged counter. `trueIdentityGroups > 0` still
893
+ // gates this arm and the skipped count still has its own arm below, so the
894
+ // two states stay told apart — what changed is only which keys this one
895
+ // is measured against.
896
+ const keysTheLoopProcessed = Object.keys(filter).length - skippedNullKeys;
897
+ if (trueIdentityGroups > 0 && trueIdentityGroups === keysTheLoopProcessed) {
898
+ return undefined;
899
+ }
900
+ // The WHOLE filter was keys the loop SKIPPED for a null/undefined value —
901
+ // objectui#9020.
902
+ //
903
+ // Skipping such a key is this file's oldest documented behaviour and is NOT
904
+ // what changed here: `{ a: null, s: 1 }` still lowers to `['s', '=', 1]`,
905
+ // pinned as "should skip null and undefined values" since long before any of
906
+ // the cards above. What changed is what the function says when the skip
907
+ // leaves NOTHING behind. It used to hand back the CALLER'S ORIGINAL OBJECT,
908
+ // and that object meant two different things on the two `find()` routes of
909
+ // `@object-ui/data-objectstack` — measured against @objectstack/spec 17.4.0
910
+ // and @objectstack/client 17.4.0:
911
+ //
912
+ // - the plain route (`convertQueryParams` → `client.data.find`) tests the
913
+ // value with `isFilterAST`, and its ELSE branch spreads a plain object's
914
+ // entries as query parameters SKIPPING null ones — so `{ a: null }`
915
+ // appended nothing at all, no `filter` parameter was sent, and the
916
+ // answer was EVERY ROW;
917
+ // - the `$expand` / `$search` route (`rawFindWithPopulate`, and the export
918
+ // route beside it) JSON-serialises the same object into `filter=`, and
919
+ // `{ a: null }` is a well-formed `FilterCondition` the spec accepts
920
+ // (`null` is in `ACCEPTED_FILTER_COMPARAND_TYPES`), so it arrived as a
921
+ // REAL predicate and the answer was SOME rows.
922
+ //
923
+ // One authored filter, two row sets, and the deciding input was whether the
924
+ // query happened to want a lookup expanded — the split objectui#6948
925
+ // recorded on this file and the shared `translateFilterToAST` helper exists
926
+ // to close. It was also self-inconsistent within ONE route: the key meant
927
+ // "no constraint" the moment ANY sibling produced a condition and meant a
928
+ // predicate when it was alone, which is the sibling-dependence objectui#8555
929
+ // named here.
930
+ //
931
+ // ⇒ the tie is broken by what this function ALREADY says about the key, not
932
+ // by inventing a meaning for it. The loop's `continue` is the ruling: the key
933
+ // contributes no condition. Carrying that to the wire is `undefined`, and an
934
+ // author who wants the predicate has always been able to spell it
935
+ // `{ a: { $null: true } }` → `['a', 'is_null', true]`. The opposite repair —
936
+ // making a null-valued key lower to a predicate — would have broken the skip
937
+ // pin and changed every caller.
938
+ //
939
+ // ⚠️ SAME ANSWER as the fold above, NOT the same state, which is why these
940
+ // are two counts and two guards rather than one. That fold's `undefined` is
941
+ // objectstack#5322's RULED identity, reached by `lowerLogicalGroup`; this
942
+ // one is this file's own tolerance made consistent with itself. They
943
+ // coincide because "no constraint" has exactly ONE expressible spelling in
944
+ // this dialect — the absence of the slot — not because the two inputs are
945
+ // the same kind of thing.
946
+ //
947
+ // A filter that MIXES the two kinds (`{ $and: [], a: null }`,
948
+ // `{ $and: [], b: undefined }`) is answered by the fold ABOVE, since
949
+ // subtracting the skipped keys leaves a filter whose every processed key is
950
+ // an identity group — objectui#9030. It used to satisfy neither guard and
951
+ // come back as the object, so a filter each of whose keys folds ALONE did
952
+ // not fold TOGETHER. That is why the counts staying apart is a statement
953
+ // about the two STATES and never about the two ARMS being unreachable from
954
+ // one input.
955
+ //
956
+ // ⛔ `{}` is not this case either — `skippedNullKeys > 0` excludes it. An
957
+ // empty filter has no key to skip, `toFilterNode` already folds it one level
958
+ // up, and objectui#8770 measured that boundary; moving it is a separate
959
+ // argument nobody has made.
960
+ if (skippedNullKeys > 0 && skippedNullKeys === Object.keys(filter).length) {
961
+ return undefined;
962
+ }
963
+ // Only `{}` — a filter with no key at all — still comes back as itself.
964
+ // Every other way to reach this tail is answered above, and an empty
965
+ // operator map (`{ a: {} }`, `{ a: {}, b: undefined }`,
966
+ // `{ $and: [], a: {} }`) never reaches it: the operator-map arm refuses it
967
+ // where it is met (objectui#9164, objectui#10788). `toFilterNode` folds
968
+ // `{}` one level up, and objectui#8770 measured that boundary.
153
969
  return filter;
154
970
  }
155
971
  // If only one condition, return it directly
@@ -183,9 +999,247 @@ function isViewFilterRule(value) {
183
999
  * turns a hole in an array into `null`, and `['x', 'equals', null]` is a real
184
1000
  * `{x: null}` predicate, i.e. a silently-wrong filter. Same rule the write side
185
1001
  * applies (`if (c.value !== undefined)`).
1002
+ *
1003
+ * The value's SHAPE is checked too, against the operator's arity — an array on a
1004
+ * single-value operator is refused rather than passed through. See the arm
1005
+ * itself for the reasoning and for why the refusal is a throw (objectui#8557).
1006
+ *
1007
+ * Its COMPARAND is checked on one operator, `icontains`, whose two refused
1008
+ * shapes `@objectstack/spec`'s `FILTER_TEXT_CASES` declares (objectui#9048).
1009
+ * That arm reads the same shared refusal `convertFiltersToAST` and
1010
+ * `ValueDataSource` read, and the arm itself carries the weighing for a SAVED
1011
+ * view — which is a different input from objectui#8557's and was redone rather
1012
+ * than inherited.
1013
+ *
1014
+ * @throws {FilterOperatorError} If the rule carries an ARRAY on an operator the
1015
+ * spec declares single-valued, or an empty / non-string comparand on
1016
+ * `icontains`.
1017
+ */
1018
+ /**
1019
+ * The view-filter operators whose `value` is legitimately an ARRAY.
1020
+ *
1021
+ * Not a local list — both halves are the spec's own, and it exports them for
1022
+ * exactly this question: `VIEW_FILTER_LIST_VALUE_OPERATORS` (`['in', 'not_in']`)
1023
+ * says which operators take a membership list, `VIEW_FILTER_PAIR_VALUE_OPERATORS`
1024
+ * (`['between']`) which take a `[min, max]` pair, and the docblock on the first
1025
+ * of them names a hard-coded `["in", "notIn"]` elsewhere in this repo as the
1026
+ * mistake it exists to prevent (`notIn` is an alias, not the canonical member).
1027
+ * Same reason the operator itself goes through `normalizeFilterOperator` rather
1028
+ * than a second map.
1029
+ */
1030
+ const ARRAY_VALUED_VIEW_OPERATORS = new Set([
1031
+ ...VIEW_FILTER_LIST_VALUE_OPERATORS,
1032
+ ...VIEW_FILTER_PAIR_VALUE_OPERATORS,
1033
+ ]);
1034
+ /**
1035
+ * The view-filter operators that carry NO comparand — their direction comes from
1036
+ * the operator NAME.
1037
+ *
1038
+ * The spec exports the two array-valued sets but no set for these, so this is
1039
+ * the one classification written out here. It is pinned against
1040
+ * `VIEW_FILTER_OPERATORS` (filter-view-rule-arity-8557.test.ts) so the four sets
1041
+ * partition the vocabulary exactly: an operator added to the spec lands in no
1042
+ * class, the pin reddens, and someone classifies it rather than it silently
1043
+ * inheriting a verdict.
1044
+ *
1045
+ * They are excluded from the refusal below deliberately, and on a measurement:
1046
+ * the spec DISCARDS a value on these operators — `parseFilterAST(['tags',
1047
+ * 'is_null', ['a']])` is `{ tags: { $null: true } }` — so a stray array here
1048
+ * cannot produce a node that selects the wrong rows. Refusing it would turn a
1049
+ * harmless input into a render-time throw, and the message would prescribe `in`
1050
+ * for an operator that takes no value at all.
186
1051
  */
1052
+ const VALUELESS_VIEW_OPERATORS = new Set([
1053
+ 'is_empty',
1054
+ 'is_not_empty',
1055
+ 'is_null',
1056
+ 'is_not_null',
1057
+ ]);
1058
+ /** Every operator spelling the spec knows, canonical forms only. */
1059
+ const KNOWN_VIEW_OPERATORS = new Set(VIEW_FILTER_OPERATORS);
187
1060
  function viewFilterRuleToNode(rule) {
188
1061
  const operator = normalizeFilterOperator(rule.operator);
1062
+ // An ARRAY on an operator that takes ONE value is refused here — objectui#8557.
1063
+ //
1064
+ // `rule.value` used to travel through unread, so a stored view rule
1065
+ // `{ field: 'tags', operator: 'equals', value: ['a'] }` became
1066
+ // `['tags', 'equals', ['a']]`. Measured against @objectstack/spec 17.3.0, the
1067
+ // spec's doors pass that node through unjudged — `isFilterAST` is `true` and
1068
+ // `parseFilterAST` hands back `{ tags: ['a'] }` — which is the SAME
1069
+ // array-in-a-scalar-slot shape the object arm above refuses (objectui#8530):
1070
+ // the ObjectQL AST has no array-equality node, `@objectstack/driver-sql`
1071
+ // answers `400 INVALID_FILTER`, and every in-memory matcher excludes every
1072
+ // row. So a hand-authored `{ tags: ['a'] }` already failed fast with a message
1073
+ // naming `$in`, while the same mistake SAVED INTO A VIEW still travelled
1074
+ // silently to a 400 the author could not attribute to their filter. This
1075
+ // closes that asymmetry.
1076
+ //
1077
+ // Keyed on the operator's ARITY, never on `Array.isArray(value)` alone:
1078
+ // `in` / `not_in` / `between` rules legitimately carry arrays through this
1079
+ // very function, and that path had no pin protecting it until this card.
1080
+ // The check runs AFTER normalization so an alias is judged by what it means —
1081
+ // `nin` is `not_in`, and is array-valued.
1082
+ //
1083
+ // An operator the spec does NOT know is left alone. `normalizeFilterOperator`
1084
+ // passes a misspelling through verbatim precisely so `isFilterAST` refuses it
1085
+ // and the server names it; refusing it HERE would report the wrong problem
1086
+ // ("use $in") for what is actually a typo.
1087
+ //
1088
+ // ## Why the refusal is a THROW, and why it belongs here
1089
+ //
1090
+ // It throws from the lowering, which for a saved view means the view fails at
1091
+ // render rather than returning a narrower answer. That is a real blast radius
1092
+ // and it was weighed:
1093
+ //
1094
+ // - It is not a NEW blast radius. Both sinks already catch a
1095
+ // `FilterOperatorError` from this same file — `plugin-list`'s
1096
+ // `buildEffectiveFilter` runs inside `ListView`'s load `try` and lands in
1097
+ // `setLoadError`, `plugin-view`'s `ObjectView` calls `mergeFilterNodes`
1098
+ // inside its own load `try` — because the object arm has thrown for
1099
+ // `$regex`, `$not` and bare arrays since objectui#8530. `classifyLoadError`
1100
+ // reads this error's `INVALID_FILTER` / `400`, so what a user sees is the
1101
+ // "filter is malformed" panel, not a crashed page and not a network error.
1102
+ // - The alternatives are both silent. DROPPING the rule widens the result
1103
+ // set, the one direction this file exists to avoid — and a stored view's
1104
+ // whole purpose can be to hide rows. Rewriting `equals` into `in` changes
1105
+ // the author's meaning, the lenient second contract objectui#8514 was
1106
+ // resolved against on this same data shape.
1107
+ // - The rule was authored long ago by someone who is not present, which
1108
+ // argues for the LOUDER answer, not the quieter one: a silent 400 two
1109
+ // layers away is attributable to nothing, whereas this names the view's
1110
+ // field and operator at the moment the filter is built.
1111
+ if (Array.isArray(rule.value)
1112
+ && typeof operator === 'string'
1113
+ && KNOWN_VIEW_OPERATORS.has(operator)
1114
+ && !ARRAY_VALUED_VIEW_OPERATORS.has(operator)
1115
+ && !VALUELESS_VIEW_OPERATORS.has(operator)) {
1116
+ throw new FilterOperatorError(`[ObjectUI] The stored view rule on field '${rule.field}' carries an ARRAY ` +
1117
+ `as the comparand of '${operator}', which takes a single value: ` +
1118
+ `${JSON.stringify(rule.value)}. It cannot be lowered: the ObjectQL filter ` +
1119
+ `AST has no array-equality node, so the lowered node ` +
1120
+ `[${rule.field}, '${operator}', [...]] is refused by @objectstack/driver-sql ` +
1121
+ `(400 INVALID_FILTER) and matches no row in the in-memory matchers — it can ` +
1122
+ `never select anything. It is deliberately NOT rewritten to 'in': that ` +
1123
+ `would change what the saved view means, and 'in' is already spellable in ` +
1124
+ `the rule itself. Spell membership as ` +
1125
+ `{ field: '${rule.field}', operator: 'in', value: [...] }, its negation as ` +
1126
+ `'not_in', or a range as { operator: 'between', value: [min, max] } — the ` +
1127
+ `three operators the spec declares array-valued, which are untouched ` +
1128
+ `(objectui#8557; the same ruling objectui#8530 applied to the object arm).`, { operator, field: rule.field });
1129
+ }
1130
+ // The `icontains` COMPARAND door, on the STORED VIEW rule vocabulary —
1131
+ // objectui#9048. Same two shapes `FILTER_TEXT_CASES` declares refused, same
1132
+ // discrimination and same words as `convertFiltersToAST` and
1133
+ // `ValueDataSource`, because all three now read ONE implementation
1134
+ // (`text-comparand.ts`) rather than three copies of it. Triage ruled exactly
1135
+ // that: "three implementations of one refusal, of which one is correct ⇒ the
1136
+ // useful dispatch is not 'add a third guard' but 'make the dialects share the
1137
+ // one that works'".
1138
+ //
1139
+ // ## Placed AFTER the arity arm, deliberately
1140
+ //
1141
+ // An ARRAY comparand on `icontains` is refused by objectui#8557 above, with
1142
+ // its message about `in` / `not_in` / `between`. Running this door first would
1143
+ // answer the same input with "not a string" — a true sentence that prescribes
1144
+ // the wrong repair, and a silent change to a shipped ruling this card is
1145
+ // fenced away from.
1146
+ //
1147
+ // ## An ABSENT comparand is left alone, and that is a boundary not an omission
1148
+ //
1149
+ // `isRefusedTextComparand(undefined)` is true, so the `!== undefined` test is
1150
+ // load-bearing. The `$` dialect has no "absent" — `{ $icontains: undefined }`
1151
+ // still HAS the key — but this vocabulary does, and the tail below already
1152
+ // encodes it: a rule with no `value` lowers to the 2-tuple `[field, operator]`
1153
+ // because the valueless operators (`is_null` and friends) take their direction
1154
+ // from the operator NAME. Measured on today's tree, that 2-tuple is already
1155
+ // refused downstream with THIS card's code: `parseFilterAST(['name',
1156
+ // 'icontains'])` throws `INVALID_FILTER` / 400 naming
1157
+ // `where.name.$icontains`. So the shape is loud already; moving WHERE it is
1158
+ // refused is a separate argument nobody has made, and `FILTER_TEXT_CASES`
1159
+ // declares two rows, not three.
1160
+ //
1161
+ // ## Why a THROW here, when it means a saved view fails at RENDER
1162
+ //
1163
+ // This is objectui#8557's weighing done again on a different input, not
1164
+ // inherited from it — the card says so, and the inputs really do differ.
1165
+ // Measured on `origin/main` `6df26f025` with `@objectstack/spec` 17.4.0, one
1166
+ // authored view rule `{ field: 'name', operator: 'icontains', value: '' }`:
1167
+ //
1168
+ // - it lowers to `['name','icontains','']`, which `isFilterAST` ACCEPTS, so
1169
+ // it is sent rather than stopped at any door;
1170
+ // - `parseFilterAST` reads it as `{ name: { $icontains: '' } }`;
1171
+ // - against the in-memory matchers that condition selects ZERO of the
1172
+ // spec's own nine `FILTER_TEXT_ROWS` (`@objectstack/formula`'s
1173
+ // `matchesFilterCondition`, and `ValueDataSource` since objectui#8748,
1174
+ // which also prints a refusal);
1175
+ // - and on the wire the published table states the other answer in its own
1176
+ // words — "Every row contains the empty substring, so evaluating it is a
1177
+ // predicate that constrains nothing".
1178
+ //
1179
+ // So the pre-fix answer is not one behaviour but TWO, chosen by which data
1180
+ // source the saved view happens to render against: silently-empty on the
1181
+ // in-memory faces, constrains-nothing on the wire. That is the acceptance-set
1182
+ // split this card family exists to close, and neither half is "a view that
1183
+ // renders correctly today". objectui#8557's conclusion (loud beats
1184
+ // silently-empty) covers the first half unchanged; the second half is the
1185
+ // WIDENING direction this file's own arity arm names as the one it exists to
1186
+ // avoid — "a stored view's whole purpose can be to hide rows". Both halves
1187
+ // point the same way, which is why the weighing survives the different input.
1188
+ //
1189
+ // The blast radius is also smaller than the shape suggests, measured rather
1190
+ // than assumed. The Console cannot author this rule: `foldFilterGroupToSpecRules`
1191
+ // (`@object-ui/app-shell`, objectui#4155) DROPS a condition whose value is
1192
+ // `null` / `''` / `[]` before it is persisted, precisely so an incomplete row
1193
+ // never reaches storage. And nothing in this tree authors the shape: over
1194
+ // every tracked file, an `operator: 'icontains'` rule appears only in this
1195
+ // card's own test, changeset and comment — lit control, 40 tracked files
1196
+ // mention the operator at all.
1197
+ //
1198
+ // ## ⚠️ Where the throw LANDS is a second question, and it is NOT settled here
1199
+ //
1200
+ // Two of the sinks catch it: `plugin-list`'s `buildEffectiveFilter` runs
1201
+ // inside `ListView`'s load `try` and `plugin-view`'s `ObjectView` inside its
1202
+ // own, and `classifyLoadError` reads this error's `INVALID_FILTER` / `400`, so
1203
+ // there the user sees the "filter is malformed" panel naming their field and
1204
+ // operator. FOUR more entries do not: `plugin-grid`'s `ObjectGrid` (twice),
1205
+ // `plugin-detail`'s `RelatedList` and `plugin-form`'s `LineItemsPanel` all
1206
+ // call `toFilterNode` inside a RENDER-time `useMemo`, where a throw
1207
+ // propagates as a render error with no `classifyLoadError` in the path.
1208
+ // `ObjectGrid` feeds it `schema.filter`, which the spec declares as
1209
+ // `z.array(ViewFilterRuleSchema)` — i.e. exactly the shape this arm judges.
1210
+ //
1211
+ // That is objectui#9050, an OPEN card on the DELIVERY axis, and this arm
1212
+ // deliberately does not pre-empt it. It is not a class this card creates or
1213
+ // even joins first: objectui#8557's array-arity refusal directly above throws
1214
+ // from this same function through those same four entries and has shipped
1215
+ // that way, and objectui#9050 counts eleven pre-existing throw sites reachable
1216
+ // the same way. ⛔ The alternatives are worse in the direction this whole card
1217
+ // family exists to close — dropping the rule widens the result set, lowering
1218
+ // it keeps the two-answers split. So the refusal lands and WHERE it surfaces
1219
+ // stays objectui#9050's decision to make for all thirteen at once.
1220
+ if (operator === 'icontains'
1221
+ && rule.value !== undefined
1222
+ && isRefusedTextComparand(rule.value)) {
1223
+ // The spelling that ARRIVED, never `$icontains` substituted for it. The
1224
+ // published rows spell the operator `$icontains` because their filters are
1225
+ // written in the `$` dialect; a view rule spells the same operator
1226
+ // `icontains`, and `ValueDataSource` already answers this question the same
1227
+ // way on today's tree — its `$` arm names `$icontains`, its AST arm names
1228
+ // `icontains`, each from the node in hand. Prescribing a spelling this
1229
+ // vocabulary does not have would send a view author looking for a `$` key
1230
+ // their metadata cannot contain, so the `$` twin is NAMED in the tail below
1231
+ // instead of substituted for what they wrote.
1232
+ const arrived = typeof rule.operator === 'string' && rule.operator !== ''
1233
+ ? rule.operator
1234
+ : operator;
1235
+ const tail = `This is a STORED VIEW rule, so it is refused as the filter is BUILT rather `
1236
+ + `than lowered onto the wire: the spelling @objectstack/spec's `
1237
+ + `FILTER_TEXT_CASES uses for this operator is the $-dialect '$icontains', `
1238
+ + `which this vocabulary spells '${arrived}'. Remove the condition from the `
1239
+ + `view, or give it a non-empty string comparand (objectui#9048; the same `
1240
+ + `refusal ValueDataSource and convertFiltersToAST already share).`;
1241
+ throw new FilterOperatorError(`[ObjectUI] The ${textComparandRefusalReason(rule.field, arrived, rule.value)}. ${tail}`, { operator: arrived, field: rule.field });
1242
+ }
189
1243
  return (rule.value === undefined
190
1244
  ? [rule.field, operator]
191
1245
  : [rule.field, operator, rule.value]);
@@ -202,8 +1256,12 @@ function viewFilterRuleToNode(rule) {
202
1256
  *
203
1257
  * The third is the one that kept getting lost. Renderers tested `source.length
204
1258
  * > 0` before using it, which is `undefined > 0` for an object — so a
205
- * `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
206
- * view returned every record. Silently: no error, just a wider answer.
1259
+ * `table.defaultFilters` (declared `Record<string, any>` until objectui#6152
1260
+ * round 10; the protocol's `ViewFilterRule` array since, and the record no
1261
+ * longer validates there) was DROPPED and the view returned every record.
1262
+ * Silently: no error, just a wider answer. That narrowing moved what an author
1263
+ * may write, not this sink: the object arm below still lowers a record whatever
1264
+ * caller hands it one.
207
1265
  *
208
1266
  * The FIRST never worked at all (objectui#3431). The array branch returned
209
1267
  * every array VERBATIM, so a saved view's `ViewFilterRule[]` travelled to
@@ -236,7 +1294,13 @@ function viewFilterRuleToNode(rule) {
236
1294
  * untouched.
237
1295
  *
238
1296
  * Returns `undefined` for an absent or empty source, so callers can skip
239
- * `$filter` rather than sending an empty array.
1297
+ * `$filter` rather than sending an empty array — and, since objectui#8770, for
1298
+ * an object source that is nothing but TRUE-identity combinators
1299
+ * (`{ $and: [] }`), and since objectui#9020 for one whose every key is skipped
1300
+ * for a null/undefined value (`{ a: null }`). Both constrain nothing and so
1301
+ * contribute nothing to the `and` {@link mergeFilterNodes} builds. Those answers
1302
+ * are inherited from {@link convertFiltersToAST}, not decided a second time
1303
+ * here — including the fact that they are two separate decisions there.
240
1304
  */
241
1305
  export function toFilterNode(source) {
242
1306
  if (source === null || source === undefined)
@@ -259,6 +1323,16 @@ export function toFilterNode(source) {
259
1323
  // MongoDB-style → AST, so it can sit beside the other shapes under one `and`.
260
1324
  return convertFiltersToAST(obj);
261
1325
  }
1326
+ export function toFilterNodeSafely(source) {
1327
+ try {
1328
+ return { ok: true, node: toFilterNode(source) };
1329
+ }
1330
+ catch (error) {
1331
+ if (error instanceof FilterOperatorError)
1332
+ return { ok: false, refusal: error };
1333
+ throw error;
1334
+ }
1335
+ }
262
1336
  /**
263
1337
  * Combine filter sources under a single `and`, each as its OWN child.
264
1338
  *