@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
@@ -8,7 +8,10 @@
8
8
  * A DataSource adapter for the `provider: 'value'` ViewData mode.
9
9
  * Operates entirely on an in-memory array — no network requests.
10
10
  */
11
+ import { asciiCaseInsensitiveContains, canonicalAstOperator, isAcceptedFilterComparand, isEmptyFilterValue, RETIRED_FILTER_OPERATORS, } from '@objectstack/spec/data';
11
12
  import { emulateBatchTransaction } from './batchTransaction.js';
13
+ import { describeComparand, isRefusedTextComparand, textComparandRefusalReason, } from '../utils/text-comparand.js';
14
+ import { toFilterNodeSafely } from '../utils/filter-converter.js';
12
15
  // ---------------------------------------------------------------------------
13
16
  // Helpers
14
17
  // ---------------------------------------------------------------------------
@@ -18,118 +21,1020 @@ function getRecordId(record, idField) {
18
21
  return record[idField];
19
22
  return record.id ?? record._id;
20
23
  }
24
+ /**
25
+ * A filter node this matcher cannot execute.
26
+ *
27
+ * Recorded and EXCLUDING, never silently ignored. Answering `true` for a node
28
+ * the matcher does not understand is how a filtered list came back UNFILTERED —
29
+ * every row, no error, not one console line (objectui#7349). The measured
30
+ * sibling behaviour is a refusal, not a pass: `evaluateCondition`
31
+ * (`@object-ui/permissions`) returns `false` from its `default` arm, and
32
+ * `ReportViewer`'s formatting switch leaves `match` false. The wire-side
33
+ * sibling `@object-ui/data-objectstack` goes further and THROWS
34
+ * (`MalformedFilterError`), on the stated ground that dropping one entry of an
35
+ * `and` WIDENS the result set — but it is deciding whether to send a query at
36
+ * all, while this matcher is deciding about a single row. So the row is
37
+ * excluded and the reason is logged once per distinct refusal per `find()`,
38
+ * which keeps a 10k-row scan to one line.
39
+ */
40
+ function refuseFilterNode(refusals, reason) {
41
+ refusals.add(`[ObjectUI] ValueDataSource: ${reason}. Rows are excluded rather than passed through.`);
42
+ return false;
43
+ }
44
+ /**
45
+ * A refusal the LOWERING raised, re-seated in this adapter's own sentence
46
+ * (objectui#10767).
47
+ *
48
+ * `toFilterNode` throws a {@link FilterOperatorError} for a stored view rule it
49
+ * will not lower — an ARRAY on a single-valued operator (objectui#8557), an
50
+ * empty or non-string `icontains` comparand (objectui#9048). For the producers
51
+ * that call it before a WIRE query, a throw is right: they are deciding whether
52
+ * to send a query at all. `find()` is deciding about rows, so here the same
53
+ * refusal takes the shape every refusal takes in this file — the rows are
54
+ * excluded and the reason is logged once — rather than surfacing as a throw
55
+ * from a `find` that never threw on a bad filter before. The converter's own
56
+ * `[ObjectUI]` prefix and closing period are taken off so the sentence reads
57
+ * once, not twice; the reasoning and the prescription are its own, verbatim.
58
+ *
59
+ * The object arm re-seats the converter's refusal through this same function
60
+ * since objectui#10817, for a field whose condition is an object with no own
61
+ * keys — see {@link zeroKeyConditionRefusal}. One envelope for both arms.
62
+ */
63
+ function loweringRefusalReason(refusal) {
64
+ const body = refusal.message.replace(/^\[ObjectUI\]\s*/, '').replace(/\.\s*$/, '');
65
+ return `filter rule refused as it is lowered: ${body}`;
66
+ }
67
+ /**
68
+ * The operators whose comparand IS an array by declaration, in both dialects.
69
+ *
70
+ * `$in` / `$nin` take a member list and `$between` a two-element range; every
71
+ * other operator reads its comparand as a single value. That split is what
72
+ * {@link refuseArrayComparand} is checked against — an array anywhere else is
73
+ * not a comparand this protocol has a meaning for.
74
+ */
75
+ const DOLLAR_LIST_COMPARAND_OPERATORS = new Set(['$in', '$nin', '$between']);
76
+ const AST_LIST_COMPARAND_OPERATORS = new Set(['in', 'nin', 'between']);
77
+ /**
78
+ * The AST operators that never read the value slot at all, so nothing sitting
79
+ * there is a comparand. `matchesComparisonNode`'s null and emptiness arms take
80
+ * their direction from the operator NAME and the ObjectUI client sends a truthy
81
+ * placeholder in the third position, so the array guard must not judge it.
82
+ * The spec discards that slot on all four: `parseFilterAST(['x', 'is_empty',
83
+ * ['a']])` is `{ x: { $empty: true } }`.
84
+ */
85
+ const AST_NO_COMPARAND_OPERATORS = new Set(['is_null', 'is_not_null', 'is_empty', 'is_not_empty']);
86
+ /**
87
+ * Is this stored value EMPTY, in the sense the spec's `$empty` operator gives
88
+ * the word (objectui#11094)?
89
+ *
90
+ * The answer is the spec's own `isEmptyFilterValue` (`@objectstack/spec/data`),
91
+ * called WITHOUT an expansion. That is the by-value reading: null, `undefined`
92
+ * (an absent key reads as `undefined` here), `''` and `[]` are empty, and
93
+ * nothing else is.
94
+ *
95
+ * ## Why no field type is read
96
+ *
97
+ * The spec's ruled 「is empty」 table (ruling B on objectstack#20311) is keyed
98
+ * on a field's DECLARED type and `multiple`: a text-like field is empty when
99
+ * null or `''`, a multi-value field when null or `[]`, every other type when
100
+ * null. A face that holds the declaration turns it into a row with
101
+ * `expandEmptyOperator` and passes that row to `isEmptyFilterValue`. This face
102
+ * holds none. `ValueDataSourceConfig` carries rows and an id field, and the
103
+ * spec's `value` data source declares only `items`. So this face judges by
104
+ * value, as ruling A on objectstack#20399 gives the faces with no field
105
+ * declarations. `@objectstack/formula`'s matcher and `driver-memory`'s
106
+ * reference matcher make the same call to the same function.
107
+ *
108
+ * ⛔ No per-type table is kept here, and none is inferred from the rows. The
109
+ * by-value reading differs from a declared face only on a stored state the
110
+ * declaration does not predict: `''` in a non-text column, or `[]` in a scalar
111
+ * one. The spec calls the first a write-door defect rather than a stored state.
112
+ */
113
+ function isEmptyStoredValue(value) {
114
+ return isEmptyFilterValue(value);
115
+ }
116
+ /**
117
+ * An ARRAY where a single-value comparand belongs — `{ tags: ['a', 'b'] }`,
118
+ * `{ tags: { $eq: ['a', 'b'] } }`, `['tags', '=', ['a', 'b']]` (objectui#8514).
119
+ *
120
+ * ## Why this is refused rather than given a deep-equality reading
121
+ *
122
+ * The card was filed as "reference comparison excludes even the deep-equal
123
+ * row", which is true, and the obvious repair is to make `['a','b']` equal
124
+ * `['a','b']`. The census says otherwise, and the spec says who owns the
125
+ * question. `assertListComparandShapes` (`@objectstack/spec/data`) lists
126
+ * "arrays outside `$in`/`$nin`/`$between`" among the positions its comparand
127
+ * door deliberately does NOT judge, on the stated ground that the ruling does
128
+ * not name it and the matrix did not measure it — it is left "to the layers
129
+ * that already answer it". Those layers do not agree, and the two nearest to
130
+ * this one both REFUSE:
131
+ *
132
+ * - `@objectstack/formula`'s `matchesFilter` — the record-at-a-time evaluator
133
+ * this adapter is the closest sibling of — answers `false` structurally, on
134
+ * the comment "a bare array value is not a valid field spec (must be
135
+ * `{ $in: [...] }`)".
136
+ * - `@objectstack/driver-sql` refuses an array comparand with its own message.
137
+ * - Only the document stores give it array-equality, as a native behaviour of
138
+ * the store rather than a ruled contract.
139
+ *
140
+ * So there is no protocol answer to implement, and inventing one HERE is the
141
+ * lenient-consumer fallback the repo forbids (AGENTS.md #0.1): a second
142
+ * de-facto contract, agreed with by no backend, that a `provider: 'value'` list
143
+ * would honour and the same filter on the wire would not. Every producer in
144
+ * this repo already spells a multi-value comparand `{ $in: [...] }` — measured
145
+ * across every `$filter` literal under the packages' and apps' source trees,
146
+ * where the only array-valued comparands are `$in` / `$nin` members — so
147
+ * refusing costs no caller a working filter, and the message names the
148
+ * spelling that works. (The glob is spelled in words because a literal one
149
+ * would close this comment.)
150
+ *
151
+ * ## The rows do not move; the silence does — except on the negations
152
+ *
153
+ * For `{ tags: [...] }` and `{ tags: { $eq: [...] } }` this changes no result
154
+ * set: `!==` / `===` against a fresh array already excluded every row, so the
155
+ * repair is the diagnostic. `$ne` / `!=` are the exception and the reason this
156
+ * guard covers the operator positions too — `value !== target` against an
157
+ * array is always TRUE, so an array comparand there selected EVERY row, in
158
+ * silence. That is objectui#8447's fail-open direction surviving inside the
159
+ * arm objectui#8514 was filed about, and the same repair closes both.
160
+ */
161
+ function refuseArrayComparand(refusals, field, operator) {
162
+ return refuseFilterNode(refusals, `filter comparand for field '${field}' on operator '${operator}' is an ARRAY, `
163
+ + `which is a declared comparand only for $in / $nin / $between. The spec leaves `
164
+ + `an array in any other position unruled and the sibling in-memory matcher `
165
+ + `(@objectstack/formula) refuses it; express membership as `
166
+ + `{ ${field}: { $in: [...] } } or its negation { ${field}: { $nin: [...] } }`);
167
+ }
168
+ /**
169
+ * An `icontains` / `$icontains` comparand that is not a NON-EMPTY STRING —
170
+ * `{ name: { $icontains: '' } }`, `['name', 'icontains', 42]` (objectui#8748).
171
+ *
172
+ * ## Not this file's ruling — the published table already refuses both
173
+ *
174
+ * `FILTER_TEXT_CASES` (`@objectstack/spec/data`) carries the two shapes as
175
+ * REJECTION rows rather than as row-set expectations, each with
176
+ * `code: 'INVALID_FILTER'` and `mustMention: ['$icontains']`:
177
+ *
178
+ * > *an empty `$icontains` comparand is REFUSED* — "Every row contains the
179
+ * > empty substring, so evaluating it is a predicate that constrains nothing —
180
+ * > the widening [objectstack-ai/objectstack#5240] refused `{ field: {} }` over, one level in."
181
+ *
182
+ * > *a non-string `$icontains` comparand is REFUSED* — "Coercing 42 to `"42"`
183
+ * > would answer a query nobody wrote; the declared comparand type is string."
184
+ *
185
+ * Measured on `origin/main` before this guard, over `FILTER_TEXT_ROWS`: the
186
+ * empty comparand answered ALL NINE ROWS in both dialects with not one console
187
+ * line — objectui#7349's fail-open signature, and the same widening class
188
+ * objectui#8447 fixed one level up. The non-string comparand was quieter and
189
+ * worse to debug: `String(42)` was evaluated and answered `[]`, which is
190
+ * indistinguishable on screen from "evaluated, matched nothing" — which is why
191
+ * pinning it as "42 and '42' agree" would pin nothing (they agree before this
192
+ * guard too). What moves is the REFUSAL: the row is excluded and the operator
193
+ * is named.
194
+ *
195
+ * ## Why only `icontains`, and why the row is excluded rather than thrown
196
+ *
197
+ * Only `$icontains` because only `$icontains` is what the table declares; the
198
+ * sibling positive operators (`$contains` / `$startsWith` / `$endsWith`) have
199
+ * no such row and are deliberately left alone rather than widened by analogy.
200
+ * Excluded-and-logged because that is this face's declared refusal shape
201
+ * (objectui#7349): the wire-side sibling `@object-ui/data-objectstack` throws
202
+ * `MalformedFilterError` because it is deciding whether to send a query at all,
203
+ * while this matcher is deciding about one row. Whether this face should carry
204
+ * the throwing envelope instead is objectui#8600 Q1, and it stays closed.
205
+ *
206
+ * ⚠️ The PRODUCER half ships in the same change and is what makes this safe.
207
+ * `FilterConditionField` emitted `{ [field]: { $icontains: value } }` verbatim,
208
+ * so a builder row with the operator chosen and the value box still empty
209
+ * produced exactly this shape — refusing it here alone would flip that list
210
+ * from "every row" to "no rows", which this file's own `$exists` arm names as
211
+ * the one outcome worse than the bug.
212
+ */
213
+ /**
214
+ * ⚠️ The DISCRIMINATION and the MESSAGE moved to `utils/text-comparand.ts`
215
+ * (objectui#9048). Nothing about this face's answer changed — the reason string
216
+ * is the same bytes it has been since objectui#8748, and the pins that drive
217
+ * both faces and compare their wording verify that without transcribing it.
218
+ *
219
+ * What stayed here is the ENVELOPE, which is the half that does NOT transfer:
220
+ * this matcher is deciding about one ROW and HAS a row to exclude, so it
221
+ * excludes-and-logs (objectui#7349). Its two siblings each seat the same reason
222
+ * in their own envelope — `filter-converter` throws `FilterOperatorError`
223
+ * because it is a PRODUCER with no row to exclude.
224
+ */
225
+ function refuseTextComparand(refusals, field, operator, target) {
226
+ return refuseFilterNode(refusals, textComparandRefusalReason(field, operator, target));
227
+ }
228
+ /**
229
+ * The operators a `{ $field }` reference is a legal comparand ON, in both
230
+ * dialects — the SIX scalar comparisons and nothing else.
231
+ *
232
+ * Not this file's choice: `FieldReferenceSchema` (`@objectstack/spec/data`)
233
+ * declares the reference on `$eq`/`$ne`/`$gt`/`$gte`/`$lt`/`$lte`, and
234
+ * objectstack#7596 REMOVED it from `$in` / `$nin` members and `$between`
235
+ * endpoints at the schema door, on the finding that no backend ever resolved
236
+ * one there.
237
+ */
238
+ const DOLLAR_FIELD_REFERENCE_OPERATORS = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']);
239
+ const AST_FIELD_REFERENCE_OPERATORS = new Set(['=', '!=', '>', '>=', '<', '<=']);
240
+ /** A comparand this matcher refused; distinguishable from a resolved `undefined`. */
241
+ const REFUSED_COMPARAND = Symbol('ValueDataSource:refused-comparand');
242
+ /**
243
+ * Is this comparand shaped like a Filter Protocol FIELD REFERENCE?
244
+ *
245
+ * The test is `'$field' in value`, which is what `@objectstack/formula`'s
246
+ * `resolveValue` uses, so the three faces agree on what a reference IS before
247
+ * they differ on what to do with one. Validity of the `$field` VALUE is judged
248
+ * by {@link resolveFieldReference}, which refuses loudly — reporting a
249
+ * malformed reference beats letting it fall through to a comparison against
250
+ * the object, which is the silence this card is about.
251
+ */
252
+ function isFieldReferenceComparand(value) {
253
+ return !!value && typeof value === 'object' && !Array.isArray(value) && '$field' in value;
254
+ }
255
+ /**
256
+ * Resolve `{ $field: 'other_column' }` against the record being matched
257
+ * (objectui#8515), or refuse it with the reason.
258
+ *
259
+ * ## Why this is implemented rather than refused
260
+ *
261
+ * `@objectstack/spec/data` declares the shape, `@objectstack/formula`'s
262
+ * `compileCelToFilter` emits it for a field-to-field comparison in a CEL
263
+ * permission rule, and BOTH platform evaluation paths execute it — the
264
+ * in-memory `matchesFilter` resolves it in `resolveValue`, and `driver-sql`
265
+ * compiles it to a same-table column-to-column comparison (objectstack#5222),
266
+ * held to the same rows by a shared conformance corpus. Refusing it here would
267
+ * make this adapter the one face that answers a declared, executed shape with
268
+ * "no rows" — the outcome the sibling card ruled worse than the bug.
269
+ *
270
+ * Both of this file's matchers compared the reference OBJECT against the stored
271
+ * value: `value === target` for `$eq` (never equal — no rows) and `value > target`
272
+ * for the orderings (an object bound — no rows). Neither said anything.
273
+ *
274
+ * ## The three positions this refuses instead, each for a recorded reason
275
+ *
276
+ * - **A `$field` that is not a STRING.** `FieldReferenceSchema` declares
277
+ * `z.string()`, and the spec's own lowering predicate (matching `driver-sql`'s
278
+ * `fieldReferenceOf`) treats a non-string `$field` as "not a field reference
279
+ * on any path". Downstream that means "refused as the uncompilable object it
280
+ * is", which is what this does — loudly, rather than by object comparison.
281
+ * - **A DOTTED path.** This matcher addresses the left-hand side as
282
+ * `record[field]`, flat, so resolving a dotted path only on the right would
283
+ * make `['a.b', '=', x]` and `['x', '=', { $field: 'a.b' }]` disagree about
284
+ * what a dot means. SQL push-down refuses a dotted path too, under the
285
+ * maintainer's 2026-08-06 same-table ruling (no JOIN planning, no alias
286
+ * contract). `@objectstack/formula` DOES walk dots — that divergence is named
287
+ * here rather than papered over, because this adapter has no path accessor to
288
+ * be consistent with.
289
+ * - **`addDays`.** The whole-day offset (objectstack#14104) is defined against
290
+ * the referenced column's TEMPORAL CLASS — a `date` stays a calendar day, a
291
+ * `datetime` keeps its time of day — and SQL push-down compiles it only
292
+ * between two temporal columns of the same class, refusing everything else
293
+ * loudly. This matcher has no field types to read a class from, so it refuses
294
+ * for the same reason rather than guessing. Silently IGNORING the offset is
295
+ * the one answer that must not happen: it would compare against an unshifted
296
+ * column and return WRONG rows, where today's defect returns none.
297
+ */
298
+ function resolveFieldReference(record, reference, operator, rawOperator, field, legalOperators, refusals) {
299
+ const path = reference.$field;
300
+ if (typeof path !== 'string') {
301
+ refuseFilterNode(refusals, `filter comparand for field '${field}' carries a non-string $field `
302
+ + `(${describeComparand(path)}); a field reference declares `
303
+ + `$field as a string, so this is not one on any path`);
304
+ return REFUSED_COMPARAND;
305
+ }
306
+ if (!legalOperators.has(operator)) {
307
+ refuseFilterNode(refusals, `a { $field } reference is not a comparand for operator '${rawOperator}' on field `
308
+ + `'${field}'. It is declared only on the six scalar comparisons `
309
+ + `($eq/$ne/$gt/$gte/$lt/$lte and their AST spellings); the spec removed it from `
310
+ + `$in / $nin members and $between endpoints because no backend resolves one there`);
311
+ return REFUSED_COMPARAND;
312
+ }
313
+ if ('addDays' in reference && reference.addDays !== undefined) {
314
+ refuseFilterNode(refusals, `the { $field } reference on field '${field}' carries an addDays offset, which is `
315
+ + `defined against the referenced column's temporal class; this matcher has no field `
316
+ + `types to read one from, and answering without the offset would compare against an `
317
+ + `unshifted column and return the wrong rows`);
318
+ return REFUSED_COMPARAND;
319
+ }
320
+ if (path.includes('.')) {
321
+ refuseFilterNode(refusals, `the { $field } reference on field '${field}' names the dotted path '${path}'; this `
322
+ + `matcher addresses a flat record, so a dotted reference would not mean the same `
323
+ + `thing as the dotted field name on the other side of the comparison`);
324
+ return REFUSED_COMPARAND;
325
+ }
326
+ return record[path];
327
+ }
328
+ /**
329
+ * A `{ $field }` reference sitting INSIDE a list comparand — an `$in` / `$nin`
330
+ * member or a `$between` endpoint (objectstack#7596, ruled 2026-08-11).
331
+ *
332
+ * The positions were declared once and honoured by nobody; the ruling was
333
+ * REMOVE rather than implement. The `$nin` direction is why this is loud rather
334
+ * than left alone: an unresolved member drops an EXCLUSION the author wrote,
335
+ * so the row passes — a filter that widens its result set in silence.
336
+ */
337
+ function refuseListMemberFieldReference(refusals, field, operator) {
338
+ return refuseFilterNode(refusals, `a { $field } reference appears inside the list comparand of '${operator}' on field `
339
+ + `'${field}'. A reference may be the WHOLE comparand of a scalar comparison, never a `
340
+ + `member of $in / $nin nor an endpoint of $between — the spec removed those positions `
341
+ + `because no evaluation path resolved them`);
342
+ }
343
+ /**
344
+ * EQUALITY between a stored value and one comparand, as every equality and
345
+ * membership position on this face reads it (objectui#10829): the AST arm's
346
+ * `=` / `!=` / `in` / `nin`, and the object arm's implicit equality, `$eq` /
347
+ * `$ne` / `$in` / `$nin`. One helper, so the two arms cannot drift apart on it.
348
+ *
349
+ * ## Two `Date`s compare their INSTANT
350
+ *
351
+ * Every position used `===` (membership used `includes`), which compares a
352
+ * `Date` by IDENTITY. The constructor `structuredClone`s the rows it is given,
353
+ * so none of them holds the comparand's instance (only `create` / `update`,
354
+ * which copy shallowly, can store the caller's own), and a `Date` equality
355
+ * matched none of them, not even the one holding that exact instant. The
356
+ * ordering arms already compared a `Date` by its number, so `>=` and `<=` both
357
+ * held for a row that `=` rejected. So when BOTH sides are `Date` instances,
358
+ * this compares `getTime()`, and `=`, `>=` and `<=` give one answer for one
359
+ * instant.
360
+ *
361
+ * An invalid `Date` (its time is `NaN`) equals nothing, itself included, so
362
+ * `!=` / `$ne` / `nin` / `$nin` hold for it.
363
+ *
364
+ * ## Everything else is `===`
365
+ *
366
+ * A `Date` comparand against an ISO string or an epoch-milliseconds number is
367
+ * NOT coerced. `@objectstack/spec`'s `FILTER_COMPARAND_TYPE_CASES` declines to
368
+ * assert a `Date` row set because what it matches "legitimately differs per
369
+ * storage form (ADR-0053)", and this matcher has no field types to read a
370
+ * storage form from. The ordering operators and `$between` keep their own
371
+ * comparisons.
372
+ *
373
+ * Membership now reads `===` too, where `includes` read SameValueZero. The one
374
+ * value those two disagree on is the number `NaN`: `{ x: { $in: [NaN] } }` no
375
+ * longer matches a stored `NaN`, which agrees with `{ x: NaN }` and `$eq`,
376
+ * where `NaN` never equalled `NaN`.
377
+ */
378
+ function comparandEquals(value, target) {
379
+ if (value instanceof Date && target instanceof Date) {
380
+ const instant = value.getTime();
381
+ return !Number.isNaN(instant) && instant === target.getTime();
382
+ }
383
+ return value === target;
384
+ }
385
+ /**
386
+ * Evaluate ONE comparison node — `[field, operator]` or `[field, operator, value]`.
387
+ *
388
+ * The operator is canonicalized through the spec's own
389
+ * {@link canonicalAstOperator}, so this switch has ONE arm per operator rather
390
+ * than one per spelling, and the accepted vocabulary is the published one
391
+ * rather than a second hand-written list that drifts from it. That matters here
392
+ * more than it reads: `viewFilterRuleToNode` (`../utils/filter-converter.ts`)
393
+ * lowers a stored view's rules through the spec's `normalizeFilterOperator`,
394
+ * so what actually arrives is the CANONICAL VIEW spelling — `equals`,
395
+ * `greater_than`, `starts_with` — and 16 of the 20 `VIEW_FILTER_OPERATORS`
396
+ * had no arm in the old spelling-keyed switch. Every one of them reached its
397
+ * `default: return true` and selected every row.
398
+ */
399
+ function matchesComparisonNode(record, node, refusals) {
400
+ const field = node[0];
401
+ const rawOperator = node[1];
402
+ // `'not in'` (with a space) is NOT a member of the spec's
403
+ // `VALID_AST_OPERATORS` — the wire would refuse it — but this matcher has
404
+ // always implemented it and a test pins it. Canonicalizing it here keeps the
405
+ // refusal arm below from deleting support that exists today; whether to
406
+ // retire the spelling is a separate question from this card's.
407
+ const operator = rawOperator === 'not in' ? 'nin' : canonicalAstOperator(rawOperator);
408
+ const value = record[field];
409
+ const rawTarget = node[2];
410
+ // objectui#8514 — an array where a single-value comparand belongs. Checked
411
+ // before the switch so `=` / `!=` cannot answer it by reference (never equal
412
+ // / always unequal), and skipped for the operators that DECLARE an array
413
+ // comparand and for the two that never read the slot.
414
+ if (Array.isArray(rawTarget)
415
+ && !AST_LIST_COMPARAND_OPERATORS.has(operator)
416
+ && !AST_NO_COMPARAND_OPERATORS.has(operator)) {
417
+ return refuseArrayComparand(refusals, field, String(rawOperator));
418
+ }
419
+ // objectui#8515 — a `{ $field }` reference inside a list comparand. The
420
+ // members are walked only for the three operators that HAVE a list comparand;
421
+ // everywhere else an array was already refused above.
422
+ if (Array.isArray(rawTarget) && rawTarget.some(isFieldReferenceComparand)) {
423
+ return refuseListMemberFieldReference(refusals, field, String(rawOperator));
424
+ }
425
+ // objectui#8515 — a `{ $field }` reference as the WHOLE comparand. Resolved
426
+ // against this record so the switch below compares two of its own values,
427
+ // exactly as `matchesFilter` (`@objectstack/formula`) and `driver-sql` do.
428
+ const target = isFieldReferenceComparand(rawTarget)
429
+ ? resolveFieldReference(record, rawTarget, operator, String(rawOperator), field, AST_FIELD_REFERENCE_OPERATORS, refusals)
430
+ : rawTarget;
431
+ if (target === REFUSED_COMPARAND)
432
+ return false;
433
+ switch (operator) {
434
+ // -- Null-ness. Direction comes from the operator NAME; the value slot is
435
+ // never read, so the 2-tuple `['x', 'is_not_null']` and the 3-tuple
436
+ // `['x', 'isnotnull', null]` are the same predicate. `canonicalAstOperator`
437
+ // folds `is_null` / `isnull` onto the first arm and their two negatives
438
+ // onto the second.
439
+ case 'is_null':
440
+ return value === null || value === undefined;
441
+ case 'is_not_null':
442
+ return value !== null && value !== undefined;
443
+ // -- Emptiness (objectui#11094). The spec stopped folding `is_empty` /
444
+ // `isempty` onto `is_null` (objectstack#20570, in `@objectstack/spec`
445
+ // 17.6.0): `canonicalAstOperator` now answers `is_empty` for those two
446
+ // spellings and `is_not_empty` for their two negatives, and the spec
447
+ // lowers them to `$empty: true` / `$empty: false`. So they get their own
448
+ // arms, the twins of the `$empty` arm in `matchesDollarOperator`. Without
449
+ // these arms, on that release a stored `is_empty` rule reached the refusal
450
+ // below and selected no row. Direction comes from the operator NAME, as
451
+ // for null-ness, and the meaning is the spec's emptiness test, not a null
452
+ // test: see {@link isEmptyStoredValue}.
453
+ case 'is_empty':
454
+ return isEmptyStoredValue(value);
455
+ case 'is_not_empty':
456
+ return !isEmptyStoredValue(value);
457
+ // objectui#10829 — equality and membership through `comparandEquals`, so
458
+ // two `Date`s compare their instant; the ordering arms below are untouched.
459
+ case '=':
460
+ return comparandEquals(value, target);
461
+ case '!=':
462
+ return !comparandEquals(value, target);
463
+ case '>':
464
+ return value > target;
465
+ case '>=':
466
+ return value >= target;
467
+ case '<':
468
+ return value < target;
469
+ case '<=':
470
+ return value <= target;
471
+ case 'in':
472
+ return Array.isArray(target) && target.some((member) => comparandEquals(value, member));
473
+ case 'nin':
474
+ return Array.isArray(target) && !target.some((member) => comparandEquals(value, member));
475
+ // -- Text operators. THE DIRECTION, so the next reader does not take it for
476
+ // a typo and fold it back (objectui#7379): the `$contains` FAMILY is
477
+ // case-SENSITIVE and `icontains` is its one case-insensitive member. That
478
+ // is the platform's ruling (objectstack#4706 Q2 = A), not this file's
479
+ // preference, and it is what every backend executes — `driver-sql`,
480
+ // `driver-sqlite-wasm`, `driver-turso`, `driver-mongodb` and
481
+ // `driver-memory` all import `FILTER_TEXT_CASES` (`@objectstack/spec/data`)
482
+ // and answer its `$contains is case-SENSITIVE` rows. These four arms used
483
+ // to lower-case BOTH sides, so `contains` executed `icontains`, the two
484
+ // were one predicate, and a `provider: 'value'` list answered a filter with
485
+ // strictly more rows than the same filter run against the wire.
486
+ //
487
+ // There is no `i` twin for the other three: `VALID_AST_OPERATORS` has
488
+ // `icontains` and NOTHING else with an `i` prefix — no `istartswith`, no
489
+ // `iendswith`, no `not_icontains` (the `$` dialect has no `$notIcontains`,
490
+ // and the AST table mirrors the executed set rather than widening it). So
491
+ // case-sensitive is the ONLY reading available to them, and it is the one
492
+ // `$notContains` needs for complementarity: a folding `not_contains` beside
493
+ // a case-exact `contains` lets one row fail an operator AND its negation.
494
+ case 'contains':
495
+ return typeof value === 'string' && value.includes(String(target));
496
+ // The fold is ASCII-ONLY (objectstack#4706 Q1 = A) and it runs on BOTH
497
+ // sides, which is why this borrows the spec's own predicate instead of
498
+ // spelling one here. `String.prototype.toLowerCase()` — what this arm used
499
+ // to reach for — is the FULL Unicode fold, so it matched `CAFÉ` against
500
+ // `café`; three of the five backends are SQLite underneath, whose `lower()`
501
+ // folds ASCII only, so a Unicode promise here is one the wire cannot keep.
502
+ // objectui#8748 — the comparand door, checked BEFORE the fold. An empty
503
+ // comparand made this arm a predicate that constrained nothing (all nine
504
+ // `FILTER_TEXT_ROWS` came back, in silence); a non-string one was evaluated
505
+ // after a `String()` coercion nobody wrote. Both are refusals in
506
+ // `FILTER_TEXT_CASES`; see {@link refuseTextComparand}.
507
+ case 'icontains':
508
+ if (isRefusedTextComparand(target)) {
509
+ return refuseTextComparand(refusals, field, String(rawOperator), target);
510
+ }
511
+ return typeof value === 'string' && asciiCaseInsensitiveContains(value, target);
512
+ // objectui#8452 — this arm answers the PREDICATE, not a TYPE TEST. It used
513
+ // to read `typeof value === 'string' && !value.includes(...)`, so a row
514
+ // whose value is the number 5 failed `contains '5'` (right: a number cannot
515
+ // contain a substring) AND failed `not_contains '5'` (wrong: for the very
516
+ // same reason it cannot contain it, it does not contain it). Measured on
517
+ // this fixture before the fix, `not_contains '5'` answered `['sx']` where
518
+ // it now answers seven of eight rows: six rows sat outside BOTH halves of
519
+ // the partition, so no filter answer included them and the opposite filter
520
+ // — the one thing a user has to debug with — was silent too.
521
+ //
522
+ // The direction is objectstack#14079 (maintainer ruling 2026-09-05,
523
+ // option A), quoted from `filter-text-conformance.ts`, the contract that
524
+ // carries it: "a stored value that is not a string never satisfies a
525
+ // positive text operator (`$contains` / `$startsWith` / `$endsWith` /
526
+ // `$icontains` / `$like` / `$ilike`) and satisfies `$notContains` —
527
+ // complementarity holds, on every face." `FILTER_TEXT_CASES`' `score` rows
528
+ // pin it, and `driver-memory`'s reference matcher — the face the defect was
529
+ // measured on — carries the same expression negated the same way.
530
+ //
531
+ // So this is the exact complement of the `contains` arm above, and it has
532
+ // to STAY spelled that way: the type gate on the positive operators is the
533
+ // other half of the same ruling, not a leftover to clean up. A `null` or an
534
+ // absent key is admitted by the same predicate (they are not strings),
535
+ // which is also what objectstack#13166 ruled for this operator.
536
+ case 'not_contains':
537
+ return !(typeof value === 'string' && value.includes(String(target)));
538
+ case 'starts_with':
539
+ return typeof value === 'string' && value.startsWith(String(target));
540
+ case 'ends_with':
541
+ return typeof value === 'string' && value.endsWith(String(target));
542
+ case 'between':
543
+ return Array.isArray(target) && target.length === 2 && value >= target[0] && value <= target[1];
544
+ default:
545
+ // Includes the spec-valid `like` / `ilike`: they carry pattern semantics
546
+ // this in-memory matcher does not implement, and no producer in this repo
547
+ // emits them into a filter. Refusing is the loud answer; matching every
548
+ // row was the silent one.
549
+ return refuseFilterNode(refusals, `filter operator '${String(rawOperator)}' is not implemented by the in-memory matcher`);
550
+ }
551
+ }
21
552
  /**
22
553
  * Evaluate an AST-format filter node against a record.
23
- * Supports conditions like ['field', 'op', value] and logical
24
- * combinations like ['and', ...conditions] or ['or', ...conditions].
554
+ *
555
+ * Reads the four shapes the spec's `FilterArraySchema` declares and
556
+ * `isFilterAST` accepts, so this consumer applies the same filter the wire
557
+ * would (objectui#7349):
558
+ *
559
+ * - `['and' | 'or', ...children]` a logical group
560
+ * - `[field, operator, value]` a comparison
561
+ * - `[field, operator]` a comparison whose operator needs no value
562
+ * - `[[…], […]]` a legacy flat list of conditions, implicit AND
563
+ *
564
+ * The last one is the shape this matcher used to ignore ENTIRELY, at top level
565
+ * and as a child of `and` / `or` alike — and it is what `mergeFilterNodes`
566
+ * returns for a lone surviving filter source, i.e. the common case. A flat
567
+ * array is unambiguous: the spec's `FilterArrayFieldSchema` forbids `and` / `or`
568
+ * as field names, so a node whose head is itself an array can only be a list.
569
+ *
570
+ * Anything else is refused rather than passed. An empty array stays "no
571
+ * filter" — the spec says the same, and `find()` never calls with one.
572
+ *
573
+ * A spec `ViewFilterRule[]` never reaches this function as rule objects:
574
+ * `find()` lowers the array through `toFilterNode` first (objectui#10767), so a
575
+ * rule arrives here as the comparison tuple its operator spells. A rule object
576
+ * that DOES arrive is one the lowering left alone — no string `field` — and is
577
+ * refused below like any other unreadable node.
25
578
  */
26
- function matchesASTFilter(record, filterNode) {
27
- if (!filterNode || filterNode.length === 0)
579
+ function matchesASTFilter(record, filterNode, refusals) {
580
+ if (!Array.isArray(filterNode)) {
581
+ return refuseFilterNode(refusals, `filter node ${JSON.stringify(filterNode) ?? String(filterNode)} is not an array`);
582
+ }
583
+ if (filterNode.length === 0)
28
584
  return true;
29
585
  const head = filterNode[0];
30
- // Logical operators: ['and', ...conditions] or ['or', ...conditions]
31
- if (head === 'and') {
32
- return filterNode.slice(1).every((sub) => matchesASTFilter(record, sub));
33
- }
34
- if (head === 'or') {
35
- return filterNode.slice(1).some((sub) => matchesASTFilter(record, sub));
36
- }
37
- // Condition node: [field, operator, value]
38
- if (filterNode.length === 3 && typeof head === 'string') {
39
- const [field, operator, target] = filterNode;
40
- const value = record[field];
41
- switch (operator) {
42
- case '=':
43
- return value === target;
44
- case '!=':
45
- return value !== target;
46
- case '>':
47
- return value > target;
48
- case '>=':
49
- return value >= target;
50
- case '<':
51
- return value < target;
52
- case '<=':
53
- return value <= target;
54
- case 'in':
55
- return Array.isArray(target) && target.includes(value);
56
- case 'not in':
57
- case 'not_in':
58
- case 'nin': // canonical (per spec)
59
- case 'notin': // legacy alias
60
- return Array.isArray(target) && !target.includes(value);
61
- case 'contains': {
62
- const lv = typeof value === 'string' ? value.toLowerCase() : '';
63
- return typeof value === 'string' && lv.includes(String(target).toLowerCase());
586
+ // Logical group. `length >= 2` mirrors `isFilterAST`: the keyword opens a
587
+ // group and a group needs at least one condition.
588
+ if (typeof head === 'string') {
589
+ const keyword = head.toLowerCase();
590
+ if (keyword === 'and' || keyword === 'or') {
591
+ if (filterNode.length < 2) {
592
+ return refuseFilterNode(refusals, `'${keyword}' group carries no conditions`);
64
593
  }
65
- case 'notcontains': {
66
- const lv = typeof value === 'string' ? value.toLowerCase() : '';
67
- return typeof value === 'string' && !lv.includes(String(target).toLowerCase());
594
+ const children = filterNode.slice(1);
595
+ return keyword === 'and'
596
+ ? children.every((sub) => matchesASTFilter(record, sub, refusals))
597
+ : children.some((sub) => matchesASTFilter(record, sub, refusals));
598
+ }
599
+ }
600
+ // Legacy flat list of conditions — implicit AND, the way the server reads it.
601
+ if (Array.isArray(head)) {
602
+ return filterNode.every((sub) => matchesASTFilter(record, sub, refusals));
603
+ }
604
+ // Comparison node, 2- or 3-element.
605
+ if (typeof head === 'string' && typeof filterNode[1] === 'string' && filterNode.length <= 3) {
606
+ return matchesComparisonNode(record, filterNode, refusals);
607
+ }
608
+ return refuseFilterNode(refusals, `filter node ${JSON.stringify(filterNode)} is not a shape the matcher reads`);
609
+ }
610
+ /**
611
+ * Evaluate ONE `$`-dialect operator against a record's value for a field.
612
+ *
613
+ * The vocabulary is the spec's own `FILTER_OPERATORS` (`@objectstack/spec/data`)
614
+ * and each arm answers the same question its AST twin answers in
615
+ * {@link matchesComparisonNode} — `$eq`/`=`, `$nin`/`nin`, `$startsWith`/
616
+ * `starts_with`, `$empty`/`is_empty`, and so on, one-to-one across EVERY
617
+ * member: every declared operator is executed here, none is refused by name.
618
+ * How many members there are is not written here: the case table in
619
+ * `ValueDataSource.dollarFilterVocabulary.test.ts` is held equal to
620
+ * `FILTER_OPERATORS`, and it is what re-derives that. That pairing IS the
621
+ * fix for objectui#8447: `find()` picks between the two matchers on nothing
622
+ * more than whether `$filter` arrived as an array or an object, so any operator
623
+ * one of them executes and the other waves through is a result that changes
624
+ * with the SHAPE of the filter rather than with its meaning.
625
+ *
626
+ * ## What the `default` arm used to be
627
+ *
628
+ * `default: break` — which adds NO constraint. An operator this switch did not
629
+ * name therefore matched EVERY row, silently, while the array arm of the very
630
+ * same `if` refused the unknown node, excluded the row and logged it
631
+ * (objectui#7349). Measured before the fix: `$nin`, `$startsWith`, `$null` and
632
+ * `$exists` each returned the full set, and so did `$eq` — the most ordinary
633
+ * operator an author can reach for, whose plain-equality sibling
634
+ * (`{ age: 26 }`) was correct all along.
635
+ *
636
+ * The direction of the change is worth stating plainly, because it MOVES
637
+ * RESULTS: a filter that silently selected everything now selects the rows it
638
+ * names, and one this matcher cannot execute selects nothing and says so. Both
639
+ * are louder than what they replace; neither is "match everything", which was
640
+ * nobody's intent.
641
+ *
642
+ * ## What is refused, and why each one
643
+ *
644
+ * Nothing the spec DECLARES is on this list — `REFUSED_OPERATORS` in the
645
+ * companion test is empty, so the `FILTER_OPERATORS` parity guard covers the
646
+ * whole published vocabulary rather than a subset of it. What follows is
647
+ * everything OUTSIDE that vocabulary.
648
+ *
649
+ * - **`$like` / `$ilike`** — declared by `StringOperatorSchema` but deliberately
650
+ * kept OUT of `FILTER_OPERATORS` while the faces that cannot execute them are
651
+ * staged in (objectstack#7536). This matcher has no pattern engine, and the
652
+ * spec's own note says naming them before an arm exists turns a loud refusal
653
+ * into a dropped predicate — i.e. every row, the defect this file just fixed.
654
+ * - **`$regex` / `$options`** — RETIRED from the protocol (objectstack#4706).
655
+ * The refusal prints `RETIRED_FILTER_OPERATORS`' prescription verbatim, the
656
+ * way the five driver-side refusal sites do, so six faces say one thing.
657
+ * - **Anything else**, including a nested relation constraint
658
+ * (`{ profile: { verified: true } }`), whose keys are field names rather than
659
+ * operators: this matcher does not descend into relations.
660
+ */
661
+ function matchesDollarOperator(record, value, operator, rawTarget, field, refusals) {
662
+ // objectui#8514 — the `$` twin of the AST guard above, and the position that
663
+ // carries the fail-OPEN case: `$ne` against an array is always true.
664
+ if (Array.isArray(rawTarget) && !DOLLAR_LIST_COMPARAND_OPERATORS.has(operator)) {
665
+ return refuseArrayComparand(refusals, field, operator);
666
+ }
667
+ // objectui#8515 — a `{ $field }` reference inside a list comparand.
668
+ if (Array.isArray(rawTarget) && rawTarget.some(isFieldReferenceComparand)) {
669
+ return refuseListMemberFieldReference(refusals, field, operator);
670
+ }
671
+ // objectui#8515 — a `{ $field }` reference as the WHOLE comparand.
672
+ const target = isFieldReferenceComparand(rawTarget)
673
+ ? resolveFieldReference(record, rawTarget, operator, operator, field, DOLLAR_FIELD_REFERENCE_OPERATORS, refusals)
674
+ : rawTarget;
675
+ if (target === REFUSED_COMPARAND)
676
+ return false;
677
+ switch (operator) {
678
+ // objectui#10829 — the same helper as the AST twins above.
679
+ case '$eq':
680
+ return comparandEquals(value, target);
681
+ case '$ne':
682
+ return !comparandEquals(value, target);
683
+ case '$gt':
684
+ return value > target;
685
+ case '$gte':
686
+ return value >= target;
687
+ case '$lt':
688
+ return value < target;
689
+ case '$lte':
690
+ return value <= target;
691
+ case '$in':
692
+ return Array.isArray(target) && target.some((member) => comparandEquals(value, member));
693
+ case '$nin':
694
+ return Array.isArray(target) && !target.some((member) => comparandEquals(value, member));
695
+ case '$between':
696
+ return Array.isArray(target) && target.length === 2
697
+ && value >= target[0] && value <= target[1];
698
+ // -- Text operators. Same ruling the AST arms carry (objectstack#4706 Q2):
699
+ // the `$contains` family is case-SENSITIVE and `$icontains` is its one
700
+ // case-insensitive member, folding ASCII only on both sides.
701
+ case '$contains':
702
+ return typeof value === 'string' && value.includes(String(target));
703
+ // objectui#8748 — the `$` twin of the comparand door in the AST arm. Both
704
+ // dialects have to refuse the same two shapes: `find()` picks between the
705
+ // matchers on nothing more than whether `$filter` arrived as an array or an
706
+ // object, so a door on one side only is a result that changes with the
707
+ // SHAPE of the filter rather than with its meaning (objectui#8447).
708
+ case '$icontains':
709
+ if (isRefusedTextComparand(target)) {
710
+ return refuseTextComparand(refusals, field, operator, target);
68
711
  }
69
- case 'startswith': {
70
- const lv = typeof value === 'string' ? value.toLowerCase() : '';
71
- return typeof value === 'string' && lv.startsWith(String(target).toLowerCase());
712
+ return typeof value === 'string' && asciiCaseInsensitiveContains(value, target);
713
+ // objectui#8452 — the `$` spelling of the same cell, and the same fix: the
714
+ // complement of the `$contains` arm above rather than a `typeof` test
715
+ // standing in for the predicate. objectstack#14079 option A: a stored value
716
+ // that is not a string never satisfies a positive text operator and
717
+ // satisfies `$notContains`, so complementarity holds on every face. The two
718
+ // dialects are pinned against each other so this one cannot drift back.
719
+ case '$notContains':
720
+ return !(typeof value === 'string' && value.includes(String(target)));
721
+ case '$startsWith':
722
+ return typeof value === 'string' && value.startsWith(String(target));
723
+ case '$endsWith':
724
+ return typeof value === 'string' && value.endsWith(String(target));
725
+ // -- Null-ness. Unlike the AST spelling, direction comes from the VALUE:
726
+ // `$null: true` is IS NULL and `$null: false` is IS NOT NULL, which is the
727
+ // lowering `convertFiltersToAST` already performs.
728
+ case '$null':
729
+ return target
730
+ ? value === null || value === undefined
731
+ : value !== null && value !== undefined;
732
+ // `$exists` is the exact INVERSE of `$null`, and that is the platform's own
733
+ // reading rather than one invented here: `convertFiltersToAST`
734
+ // (`../utils/filter-converter.ts`) lowers `$exists: true` to `is_not_null`
735
+ // and `$exists: false` to `is_null`, three lines below where it lowers
736
+ // `$null` the other way round. Both operators are absent from that file's
737
+ // `convertOperatorToAST` map and both are special-cased BEFORE it, so that
738
+ // map's silence is not a decision about either of them. Two producers in
739
+ // this repo emit `$exists` — `FilterConditionField`'s `exists` /
740
+ // `notExists` (kept reachable on purpose by objectui#4736) and
741
+ // `datasetFilterCondition`'s `isEmpty` / `isNotEmpty` — so refusing it
742
+ // would have turned "every row" into "no rows" on a filter that looks like
743
+ // it works, which is the one outcome worse than the bug.
744
+ case '$exists':
745
+ return target
746
+ ? value !== null && value !== undefined
747
+ : value === null || value === undefined;
748
+ // objectui#11094 — `$empty`, which `@objectstack/spec` 17.6.0 admitted to
749
+ // `FILTER_OPERATORS` (objectstack#20446) and to which it lowers `is_empty` /
750
+ // `is_not_empty`. `true` selects the empty rows and `false` is the exact
751
+ // complement, by value (see {@link isEmptyStoredValue}); the AST twins are
752
+ // the `is_empty` / `is_not_empty` arms in `matchesComparisonNode`.
753
+ //
754
+ // The flag is a BOOLEAN by declaration (`SpecialOperatorSchema`), so
755
+ // anything else is refused here rather than read for its truthiness. That
756
+ // is the answer the spec gives at its save door and on every query face,
757
+ // and `@objectstack/formula`'s matcher denies a non-boolean flag too.
758
+ case '$empty':
759
+ if (target === true)
760
+ return isEmptyStoredValue(value);
761
+ if (target === false)
762
+ return !isEmptyStoredValue(value);
763
+ return refuseFilterNode(refusals, `filter operator '$empty' on field '${field}' takes a boolean flag (true or false); `
764
+ + `received ${describeComparand(target)}`);
765
+ // objectui#8515 — the hand-authored IMPLICIT form `{ amount: { $field: 'x' } }`.
766
+ // It is not a reference comparand: an object whose only key starts with `$`
767
+ // reads as an OPERATOR SPEC named `$field`, which is why this arrives here
768
+ // as an operator at all. objectstack#7597 ruled that form keeps its
769
+ // fail-closed fate rather than being promoted, and named the spelling that
770
+ // works — the same one `driver-sql`'s refusal names. The default arm below
771
+ // already excluded the row; this arm only replaces "not implemented" with
772
+ // the prescription, so an author reading the console can act on it.
773
+ case '$field':
774
+ return refuseFilterNode(refusals, `field '${field}' carries a bare { $field } object, which reads as an operator `
775
+ + `named '$field' rather than as a comparand. Write the explicit comparison `
776
+ + `{ ${field}: { $eq: { $field: ${JSON.stringify(String(rawTarget))} } } }`);
777
+ default: {
778
+ const retired = RETIRED_FILTER_OPERATORS[operator];
779
+ if (retired) {
780
+ return refuseFilterNode(refusals, `filter operator '${operator}' on field '${field}' is retired. ${retired.why}`);
72
781
  }
73
- case 'between':
74
- return Array.isArray(target) && target.length === 2 && value >= target[0] && value <= target[1];
75
- default:
76
- return true;
782
+ if (!operator.startsWith('$')) {
783
+ return refuseFilterNode(refusals, `filter condition on field '${field}' carries the non-operator key `
784
+ + `'${operator}'; the in-memory matcher does not descend into a nested `
785
+ + `relation constraint`);
786
+ }
787
+ return refuseFilterNode(refusals, `filter operator '${operator}' on field '${field}' is not implemented by the `
788
+ + `in-memory matcher`);
77
789
  }
78
790
  }
79
- return true;
80
791
  }
81
792
  /**
82
- * Simple in-memory filter evaluation.
83
- * Supports flat key-value equality and basic operators ($gt, $gte, $lt, $lte, $ne, $in).
793
+ * Evaluate ONE `$and` / `$or` group against a record (objectui#8513).
794
+ *
795
+ * ## The identities are the JS reducers, not a special case
796
+ *
797
+ * `$and` is `every` and `$or` is `some`, and the empty-array answers those two
798
+ * reducers already give — `[].every(…)` is `true`, `[].some(…)` is `false` —
799
+ * ARE the ruled identities. objectstack#5322 (closed `completed`, merged as
800
+ * objectstack#5365) fixed `{ $and: [] }` as TRUE / every row and `{ $or: [] }`
801
+ * as FALSE / no row, and `@objectstack/spec` pins all four identity answers in
802
+ * the cross-backend `FILTER_LOGIC_CASES` table that
803
+ * `ValueDataSource.filterLogicConformance.test.ts` drives this matcher through.
804
+ * So there is no identity branch to write here and none to get wrong: writing
805
+ * one would be a second statement of the rule that could drift from the table.
806
+ *
807
+ * A `{}` branch needs no arm either. {@link matchesFilter} answers `true` for a
808
+ * filter with no entries, which is what makes a `{}` disjunct a TRUE branch
809
+ * that ABSORBS its `$or` and a `{}` conjunct one that drops out of an `$and` —
810
+ * the third and fourth identities, for free, from the same recursion.
811
+ *
812
+ * ## Why the members are checked BEFORE any of them is evaluated
813
+ *
814
+ * `every` / `some` short-circuit, so a lazy shape check would reach a malformed
815
+ * member for some records and not for others — and `refusals` is drained once
816
+ * per `find()`, so whether the author sees the diagnostic at all would depend
817
+ * on which row happened to be tested first. Validating the whole member list up
818
+ * front makes the refusal a property of the FILTER rather than of the data.
819
+ *
820
+ * A refused group excludes the row, the same direction every other refusal in
821
+ * this file takes. Inside `$or` that is narrowing (one fewer way to match) and
822
+ * inside `$and` it is exclusion outright; neither can widen a result set.
84
823
  */
85
- function matchesFilter(record, filter) {
824
+ function matchesLogicalGroup(record, keyword, value, refusals) {
825
+ if (!Array.isArray(value)) {
826
+ return refuseFilterNode(refusals, `filter combinator '${keyword}' takes an ARRAY of conditions; received `
827
+ + `${typeof value === 'object' && value !== null ? 'an object' : typeof value}: `
828
+ + `${JSON.stringify(value) ?? String(value)}. The spec declares `
829
+ + `'${keyword}?: FilterCondition[]' (FilterConditionSchema, data/filter.zod.ts)`);
830
+ }
831
+ for (const branch of value) {
832
+ if (branch === null || typeof branch !== 'object' || Array.isArray(branch)) {
833
+ return refuseFilterNode(refusals, `every member of filter combinator '${keyword}' must be a filter condition `
834
+ + `OBJECT; received ${JSON.stringify(branch) ?? String(branch)}. The spec `
835
+ + `declares '${keyword}?: FilterCondition[]' (FilterConditionSchema, `
836
+ + `data/filter.zod.ts)`);
837
+ }
838
+ }
839
+ return keyword === '$and'
840
+ ? value.every((branch) => matchesFilter(record, branch, refusals))
841
+ : value.some((branch) => matchesFilter(record, branch, refusals));
842
+ }
843
+ /**
844
+ * In-memory evaluation of an OBJECT-shaped (`$`-dialect) filter.
845
+ *
846
+ * Reads a flat key/value equality (`{ age: 26 }`) or a `FieldOperatorsSchema`
847
+ * condition object (`{ age: { $gte: 25 } }`), one entry per field, ANDed. What
848
+ * it cannot execute it refuses through {@link refuseFilterNode} — excluded and
849
+ * logged once per distinct refusal per `find()` — rather than adding no
850
+ * constraint, which is what its `default: break` used to do (objectui#8447).
851
+ *
852
+ * ## `$and` / `$or` are EXECUTED here; `$not` is still refused (objectui#8513)
853
+ *
854
+ * The three `LOGICAL_OPERATORS` are not one case and this file must not let a
855
+ * fix flatten them into one. Before objectui#8447 made the refusal loud, they
856
+ * failed in two OPPOSITE directions: `{ $and: [...] }` / `{ $or: [...] }` carry
857
+ * an ARRAY, so they fell to the simple-equality branch below
858
+ * (`record['$and'] !== [...]` is always true) and excluded EVERY row, while
859
+ * `{ $not: {...} }` carries an OBJECT (`FilterConditionSchema`, not an array),
860
+ * entered the operator branch with its own nested FIELD names read as operator
861
+ * names, and matched every row. Two bugs, opposite signs, one heading.
862
+ *
863
+ * **`$and` / `$or` are executed** because the semantics they were waiting on
864
+ * have been ruled and shipped: objectstack#5322 (merged objectstack#5365)
865
+ * settled the empty-group identities, and five platform backends already answer
866
+ * to them through the shared `FILTER_LOGIC_CASES` conformance table. Refusing a
867
+ * shape the spec DECLARES, this repo's own `convertFiltersToAST` LOWERS, and
868
+ * every wire face EXECUTES made this adapter the one face that answers "no
869
+ * rows" to a filter the UI itself offers — the outcome objectui#8515 already
870
+ * ruled worse than the bug. Executing them is therefore restoring a declared
871
+ * invariant, not widening an accept set: no new operator, no new key, nothing
872
+ * admitted that `FilterConditionSchema` does not already declare.
873
+ *
874
+ * **`$not` keeps its refusal**, deliberately and with its own message rather
875
+ * than by falling through to the unknown-`$`-key arm below. objectstack#5146
876
+ * (merged objectstack#5296) did rule its NULL-safe semantics — the operand
877
+ * compiles to a TOTAL predicate before being negated — but this repo's own
878
+ * `convertFiltersToAST` (`../utils/filter-converter.ts`) still THROWS for
879
+ * `$not`, on a narrowing that is about the AST rather than about the ruling:
880
+ * `FILTER_ARRAY_LOGIC_KEYWORDS` is `['and', 'or']`, so the array dialect has no
881
+ * negation keyword, and rewriting the negation inward is silently partial
882
+ * because `startswith` / `endswith` / `between` / `icontains` have no negated
883
+ * counterpart in `VALID_AST_OPERATORS`. Whether that objectui-side narrowing
884
+ * should stand now that upstream has ruled is a question objectui#8513 does not
885
+ * decide, so `$not` is left exactly where it was — refused, loudly, and
886
+ * distinguishable in the log from a group that executes.
887
+ */
888
+ function matchesFilter(record, filter, refusals) {
86
889
  for (const [key, condition] of Object.entries(filter)) {
890
+ // The two combinators this matcher executes. A group is one ENTRY of the
891
+ // condition object, so it ANDs with its siblings exactly as a field entry
892
+ // does — `{ status: 'open', $or: [...] }` is "status AND the group", which
893
+ // is what `FILTER_LOGIC_CASES` measures in both key orders.
894
+ if (key === '$and' || key === '$or') {
895
+ if (!matchesLogicalGroup(record, key, condition, refusals))
896
+ return false;
897
+ continue;
898
+ }
899
+ // Refused on its own terms, ahead of the generic `$` arm, so the log tells
900
+ // an author which of the three combinators they hit and why this one is
901
+ // different. See this function's doc for the reasoning; the short version
902
+ // is that the AST this repo lowers to has no negation keyword.
903
+ if (key === '$not') {
904
+ return refuseFilterNode(refusals, `filter combinator '$not' is not executed by the object-dialect matcher. `
905
+ + `Its NULL-safe semantics are ruled (objectstack#5146) but this repo's own `
906
+ + `lowering refuses it too: FILTER_ARRAY_LOGIC_KEYWORDS is ['and', 'or'], so `
907
+ + `the filter AST has no negation keyword, and rewriting the negation inward `
908
+ + `would be silently partial ('startswith', 'endswith', 'between' and `
909
+ + `'icontains' have no negated counterpart). Express the negation with a `
910
+ + `negated operator instead ($ne, $nin, $notContains); note those follow each `
911
+ + `operator's own answer for a missing value rather than $not's NULL-safe rule`);
912
+ }
913
+ if (key.startsWith('$')) {
914
+ return refuseFilterNode(refusals, `filter key '${key}' is not a field name and not one of the combinators this `
915
+ + `matcher executes ($and / $or). The spec's LOGICAL_OPERATORS are `
916
+ + `$and / $or / $not and nothing else`);
917
+ }
87
918
  const value = record[key];
88
- if (condition && typeof condition === 'object' && !Array.isArray(condition)) {
89
- // Operator-based filter
919
+ // objectui#10829 — a `Date` is a COMPARAND, not an operator map. It is an
920
+ // object and not an array, so it used to enter the operator branch below,
921
+ // where `Object.entries` of a Date is `[]`: the loop ran zero times and the
922
+ // field added NO constraint. `{ status: 'a', created: someDate }` answered
923
+ // the same rows as `{ status: 'a' }`, in silence, while
924
+ // `convertFiltersToAST` lowers the same filter to
925
+ // `['created', '=', someDate]` (objectui#8555).
926
+ //
927
+ // So it takes the simple-equality branch, the position every other
928
+ // accepted comparand takes. That branch compares through
929
+ // `comparandEquals`, the helper the AST arm's `=` uses, so the object filter
930
+ // and its lowered array answer one row set: the rows holding the same
931
+ // instant as a `Date`. The gate is the
932
+ // spec's `isAcceptedFilterComparand`, the predicate the converter lowers a
933
+ // Date through, rather than a local `instanceof Date`: an object-typed
934
+ // value the converter lowers as a comparand is then one this matcher reads
935
+ // as a comparand, and no such value can reach the zero-operator loop.
936
+ if (condition && typeof condition === 'object' && !Array.isArray(condition)
937
+ && !isAcceptedFilterComparand(condition)) {
938
+ // Operator-based filter — every operator on the field is ANDed.
90
939
  for (const [op, target] of Object.entries(condition)) {
91
- switch (op) {
92
- case '$gt':
93
- if (!(value > target))
94
- return false;
95
- break;
96
- case '$gte':
97
- if (!(value >= target))
98
- return false;
99
- break;
100
- case '$lt':
101
- if (!(value < target))
102
- return false;
103
- break;
104
- case '$lte':
105
- if (!(value <= target))
106
- return false;
107
- break;
108
- case '$ne':
109
- if (value === target)
110
- return false;
111
- break;
112
- case '$in':
113
- if (!Array.isArray(target) || !target.includes(value))
114
- return false;
115
- break;
116
- case '$contains':
117
- if (typeof value !== 'string' || !value.toLowerCase().includes(String(target).toLowerCase()))
118
- return false;
119
- break;
120
- default:
121
- break;
122
- }
940
+ if (!matchesDollarOperator(record, value, op, target, key, refusals))
941
+ return false;
123
942
  }
124
943
  }
944
+ else if (Array.isArray(condition)) {
945
+ // objectui#8514 — the implicit-equality position this card was filed
946
+ // about. It reaches here because the operator branch above is guarded by
947
+ // `!Array.isArray(condition)`, and that guard STAYS: routing an array
948
+ // into the operator loop would read its INDICES as operator names, the
949
+ // exact shape `$not` had before objectui#8447 fixed it.
950
+ return refuseArrayComparand(refusals, key, 'implicit equality');
951
+ }
125
952
  else {
126
- // Simple equality
127
- if (value !== condition)
953
+ // Simple equality — a scalar, `null`, or a `Date` (objectui#10829).
954
+ if (!comparandEquals(value, condition))
128
955
  return false;
129
956
  }
130
957
  }
131
958
  return true;
132
959
  }
960
+ /**
961
+ * The object `$filter`'s first condition that is an object with NO OWN KEYS and
962
+ * that the converter refuses: `{ created: {} }`, alone or anywhere under `$and`
963
+ * / `$or` (objectui#10817).
964
+ *
965
+ * ## What was wrong
966
+ *
967
+ * {@link matchesFilter} read `{ created: {} }` as an operator map whose
968
+ * operator loop runs zero times, so the field added NO constraint.
969
+ * `{ status: 'a', created: {} }` answered the same rows as `{ status: 'a' }`,
970
+ * and `{ created: {} }` answered every row, with no console line. The same
971
+ * authored filter is refused on the ObjectStack path: `@objectstack/spec` ruled
972
+ * `{ field: {} }` REJECTED wherever it appears (objectstack#5240, recorded on
973
+ * `FilterConditionSchema`), and `convertFiltersToAST` refuses it alone
974
+ * (objectui#9164) and beside a key that lowers (objectui#10788). One filter,
975
+ * two fates, and the value face took the widening one.
976
+ *
977
+ * ## Why the converter decides, and not a check written here
978
+ *
979
+ * The field is handed to {@link toFilterNodeSafely} on its own, as
980
+ * `{ [field]: condition }`. The converter alone decides what an empty operator
981
+ * map is, with its own throw site and its own wording, so there is no second
982
+ * opinion to drift and no new export (`@object-ui/core` re-exports the whole
983
+ * converter module). Only a zero-key object is routed: an object that HAS keys
984
+ * is an operator map the matcher below already reads, and refuses per node
985
+ * when it cannot.
986
+ *
987
+ * What the routing carries, on purpose, is whatever the converter decides for
988
+ * a zero-key object. A `RegExp`, `Map` or `Set` also has no own keys, and the
989
+ * converter refuses it as an exotic comparand (objectui#8567), so this face now
990
+ * refuses it too instead of dropping the constraint. A `Date` has no own keys
991
+ * either, but the converter LOWERS it, so it reaches the matcher, which reads
992
+ * it as implicit equality and matches the rows holding the same instant as a
993
+ * `Date` (objectui#10829). A `null` / `undefined` condition is not an object
994
+ * and is not routed.
995
+ *
996
+ * ## Why up front, and why the whole filter
997
+ *
998
+ * {@link matchesFilter} returns on a row's first false entry and `$or` uses
999
+ * `some`, so a refusal raised while matching would fire for some rows and not
1000
+ * for others: `{ status: 'a', created: {} }` would log only if some row had
1001
+ * status `'a'`, and `{ $or: [{ status: 'b' }, { status: 'a', created: {} }] }`
1002
+ * would still answer the `'b'` rows. `@objectstack/formula` walks the tree
1003
+ * before evaluating for the same reason. A refused filter answers NO rows and
1004
+ * logs the converter's reason once, which is exactly how the array arm answers
1005
+ * a filter its lowering refuses (objectui#10767). `find` never throws on a bad
1006
+ * filter (objectui#7349).
1007
+ *
1008
+ * The walk follows the two combinators {@link matchesFilter} executes. A
1009
+ * malformed group, `$not` and every other `$` key keep the refusals the
1010
+ * matcher already gives them.
1011
+ */
1012
+ function zeroKeyConditionRefusal(filter) {
1013
+ for (const [key, condition] of Object.entries(filter)) {
1014
+ if (key === '$and' || key === '$or') {
1015
+ if (!Array.isArray(condition))
1016
+ continue;
1017
+ for (const member of condition) {
1018
+ if (member === null || typeof member !== 'object' || Array.isArray(member))
1019
+ continue;
1020
+ const refusal = zeroKeyConditionRefusal(member);
1021
+ if (refusal)
1022
+ return refusal;
1023
+ }
1024
+ continue;
1025
+ }
1026
+ if (key.startsWith('$'))
1027
+ continue;
1028
+ if (condition === null || typeof condition !== 'object' || Array.isArray(condition))
1029
+ continue;
1030
+ if (Object.keys(condition).length > 0)
1031
+ continue;
1032
+ const lowered = toFilterNodeSafely({ [key]: condition });
1033
+ if (!lowered.ok)
1034
+ return lowered.refusal;
1035
+ }
1036
+ return undefined;
1037
+ }
133
1038
  /** Apply sort ordering to an array (returns a new sorted array) */
134
1039
  function applySort(data, orderby) {
135
1040
  if (!orderby)
@@ -226,8 +1131,27 @@ export class ValueDataSource {
226
1131
  writable: true,
227
1132
  value: new Set()
228
1133
  });
229
- // Deep clone to prevent external mutation
230
- this.items = JSON.parse(JSON.stringify(config.items));
1134
+ // Deep clone to prevent external mutation.
1135
+ //
1136
+ // `structuredClone`, NOT a `JSON.parse(JSON.stringify(...))` round-trip
1137
+ // (objectui#9175, maintainer ruling A on objectui#9061). The clone exists
1138
+ // only to stop a caller mutating rows this read-only query source already
1139
+ // handed out; it was never a serialization boundary, and the round-trip
1140
+ // quietly made it one. Everything routed through `provider: 'value'` had to
1141
+ // survive `JSON.stringify` — so a `Date` came back as a string, keys whose
1142
+ // value was `undefined` disappeared, a cycle threw, and objectui#6018's
1143
+ // pinned guarantee ("an inline value never has to be serializable at all")
1144
+ // became false the moment a renderer routed its inline rows through this
1145
+ // adapter to honour `filter` / `sort` / the objectui#7210 ceiling.
1146
+ //
1147
+ // `structuredClone` handles cycles, `Date`, `Map`/`Set`, `BigInt` and typed
1148
+ // arrays, and is already an unguarded runtime requirement of published
1149
+ // ObjectUI packages (`@object-ui/app-shell`, `@object-ui/plugin-designer`).
1150
+ // It still throws `DataCloneError` on a function or a DOM node — that is
1151
+ // deliberate and stays LOUD: ⛔ no `try`/`catch` fallback here, because
1152
+ // falling back to the round-trip would restore exactly the silent
1153
+ // flattening this replaces.
1154
+ this.items = structuredClone(config.items);
231
1155
  this.idField = config.idField;
232
1156
  }
233
1157
  /** Notify all mutation subscribers */
@@ -248,12 +1172,73 @@ export class ValueDataSource {
248
1172
  let result = [...this.items];
249
1173
  // Filter — support both MongoDB-style objects and AST-format arrays
250
1174
  if (params?.$filter) {
251
- if (Array.isArray(params.$filter) && params.$filter.length > 0) {
252
- result = result.filter((r) => matchesASTFilter(r, params.$filter));
1175
+ // ONE collector for BOTH arms, drained after the pass: a node either
1176
+ // matcher refuses would otherwise log once PER ROW. Shared on purpose —
1177
+ // the two arms differ only in the SHAPE of `$filter`, and objectui#8447
1178
+ // was exactly the asymmetry of one arm refusing loudly while the other
1179
+ // waved everything through in silence.
1180
+ const refusals = new Set();
1181
+ if (Array.isArray(params.$filter)) {
1182
+ // objectui#10767 — THE ARRAY ARM LOWERS BEFORE IT MATCHES.
1183
+ //
1184
+ // An array `$filter` is one of three things: an AST node, a legacy flat
1185
+ // list of nodes, or a spec `ViewFilterRule[]` (`[{ field, operator,
1186
+ // value }, …]`) — the ONLY form the spec's converged `filter` doors
1187
+ // accept (objectui#6206 B), and therefore the form a spec-conformant
1188
+ // page carries. `matchesASTFilter` reads the first two; a rule OBJECT
1189
+ // is none of its shapes, so it used to refuse every row of a
1190
+ // spec-conformant filter with one warning, and the four blocks whose
1191
+ // rows can be inline (`object-map` / `object-tree` / `object-calendar`
1192
+ // / `object-gantt`) rendered NOTHING for the one filter an author is
1193
+ // allowed to write.
1194
+ //
1195
+ // The lowering is the repo's ONE sink, `toFilterNode` — the same
1196
+ // function the grid, the list and every other lowering caller already
1197
+ // use before a wire query — so this adapter grows no second lowering
1198
+ // and no second operator table: the rule's operator goes through the
1199
+ // spec's own `normalizeFilterOperator` there, and arrives here as the
1200
+ // AST spelling the arm below already executes (`equals`,
1201
+ // `greater_than`, …). `QueryParams.$filter` (`@object-ui/types`) has
1202
+ // declared the array form legal for every DataSource and named the wire
1203
+ // adapter's `translateFilterToAST` as the accept set; this was the one
1204
+ // face in the family that did not read the rule array, so
1205
+ // `resolveDataSource` answered `provider: 'value'` with an adapter that
1206
+ // read FEWER filter shapes than `provider: 'object'` got.
1207
+ //
1208
+ // The matcher itself is NOT relaxed. `toFilterNode` returns an AST
1209
+ // array untouched (no rule objects, nothing to lower), `undefined` for
1210
+ // an empty array (no filter — what the object-bound arm answers too),
1211
+ // and passes an operator the spec does not know through VERBATIM, so a
1212
+ // node that is genuinely unreadable still reaches the refusals below
1213
+ // unchanged and still excludes the row with the same sentence. A rule
1214
+ // the lowering itself refuses is excluded and logged once through
1215
+ // `loweringRefusalReason` — never thrown from `find`.
1216
+ const lowered = toFilterNodeSafely(params.$filter);
1217
+ if (!lowered.ok) {
1218
+ refuseFilterNode(refusals, loweringRefusalReason(lowered.refusal));
1219
+ result = [];
1220
+ }
1221
+ else if (Array.isArray(lowered.node) && lowered.node.length > 0) {
1222
+ const node = lowered.node;
1223
+ result = result.filter((r) => matchesASTFilter(r, node, refusals));
1224
+ }
253
1225
  }
254
- else if (!Array.isArray(params.$filter) && Object.keys(params.$filter).length > 0) {
255
- result = result.filter((r) => matchesFilter(r, params.$filter));
1226
+ else if (Object.keys(params.$filter).length > 0) {
1227
+ // objectui#10817 — a condition that is an object with no own keys is
1228
+ // judged by the converter BEFORE any row is matched, and a refusal
1229
+ // answers the whole filter the way the array arm answers one above.
1230
+ const filter = params.$filter;
1231
+ const refusal = zeroKeyConditionRefusal(filter);
1232
+ if (refusal) {
1233
+ refuseFilterNode(refusals, loweringRefusalReason(refusal));
1234
+ result = [];
1235
+ }
1236
+ else {
1237
+ result = result.filter((r) => matchesFilter(r, filter, refusals));
1238
+ }
256
1239
  }
1240
+ for (const message of refusals)
1241
+ console.warn(message);
257
1242
  }
258
1243
  // Search (simple text search across all string fields)
259
1244
  if (params?.$search) {
@@ -306,7 +1291,9 @@ export class ValueDataSource {
306
1291
  throw new Error(`ValueDataSource: Record with id "${id}" not found`);
307
1292
  }
308
1293
  this.items[index] = { ...this.items[index], ...data };
309
- this.emitMutation({ type: 'update', resource: _resource, id, record: { ...this.items[index] } });
1294
+ // The event carries the protocol's string id (objectui#10078): an inline
1295
+ // item may hold a numeric key, and this adapter is where it converts.
1296
+ this.emitMutation({ type: 'update', resource: _resource, id: String(id), record: { ...this.items[index] } });
310
1297
  return { ...this.items[index] };
311
1298
  }
312
1299
  async delete(_resource, id) {
@@ -314,7 +1301,7 @@ export class ValueDataSource {
314
1301
  if (index === -1)
315
1302
  return false;
316
1303
  this.items.splice(index, 1);
317
- this.emitMutation({ type: 'delete', resource: _resource, id });
1304
+ this.emitMutation({ type: 'delete', resource: _resource, id: String(id) });
318
1305
  return true;
319
1306
  }
320
1307
  async bulk(_resource, operation, data) {
@@ -414,8 +1401,13 @@ export class ValueDataSource {
414
1401
  get count() {
415
1402
  return this.items.length;
416
1403
  }
417
- /** Get a snapshot of all items (cloned) */
1404
+ /**
1405
+ * Get a snapshot of all items (cloned).
1406
+ *
1407
+ * Same clone as the constructor and for the same reason — see the note
1408
+ * there: `structuredClone`, never a JSON round-trip (objectui#9175).
1409
+ */
418
1410
  getAll() {
419
- return JSON.parse(JSON.stringify(this.items));
1411
+ return structuredClone(this.items);
420
1412
  }
421
1413
  }