Commit 2b53993
Fixes #20445
Clause-②: yes (widening)
The `domain:services` lane's arms for the `$empty` operator, under
ruling A on #20399 (`5865693155`). Both service-analytics filter faces
now answer `{ f: { $empty: true | false } }` by the field's DECLARED row
of the ruled per-type table. They reach it through the spec's one
expansion, `expandEmptyOperator(fieldDef)` from `@objectstack/spec/data`
(PR #20442), and keep no copy of the table:
| declared row | `$empty: true` matches | `$empty: false` |
|---|---|---|
| text-like | null or `''` | the complement |
| multi-value (incl. `multiple: true` on a multi-capable type) | null or
`[]` | the complement |
| every other type | null only | the complement |
The staging does not move (「照 $like 先例分阶段」): `$empty` is **not** added
to `FILTER_OPERATORS`, and the `is_empty` / `is_not_empty` lowering
still emits `$null`. There is no `$eq: []` comparand anywhere (ruling 乙
on #19757 stands).
## What changed
- **`empty-operator-sql.ts` (new).** The SQL for one `$empty` predicate
per declared row. Null-only: `col IS NULL`. Text: `(col IS NULL OR col =
'')`, with the empty string bound. Multi-value: `(col IS NULL OR L)`,
where `L` is the dialect's empty-JSON-list test: SQLite `json_valid`
guard inside a `CASE`, then `json_type(col) = 'array' AND
json_array_length(col) = 0`; Postgres a `jsonb` equality with `'[]'`;
MySQL `JSON_TYPE` = `'ARRAY'` and `JSON_LENGTH` = 0. `$empty: false` is
the exact complement of each. Every predicate is TOTAL (never UNKNOWN),
so a `$not` over `$empty` needs no NULL guard.
- **Read-scope face (`compileScopedFilterToSql`).** A new `$empty` arm
in `compileOperator`. It asks the caller for the field's declaration
(new optional `declaredValueShape` option; both callers pass it from the
context), calls `expandEmptyOperator`, and compiles the row. The flag
joins the existing boolean-domain gate with `$null` / `$exists`. Both
NULL-polarity tables gain the row (null satisfies `$empty: true`; the
arm is total).
- **`where` face (`lowerAnalyticsWhere` /
`normalizeAnalyticsFilterTree`).** `fieldLeaves` stops refusing `$empty`
and lowers it to a valueless `empty` / `notEmpty` leaf. The normalizer
sees no field declaration, and the multi-value row cannot be spelled in
the lowered vocabulary, so the row is resolved by each consumer of the
tree:
- `NativeSQLStrategy.buildFilterClause` (the executed statement) and the
`ObjectQLStrategy` echo both call `whereEmptyLeafSql`: the host's
declared shape, then the spec's expansion, then the row's SQL.
- `ObjectQLStrategy.convertFilter` (the engine path) hands `{ $empty }`
to the engine as written. Its arm is the engine lane's (#20444).
- The flag joins `assertBooleanNullFlags` with `$null` / `$exists`. Both
polarity tables gain the row.
- **Where the declaration comes from.** A new context hook,
`DatasetScopedStrategyContext.declaredValueShape(object, field)`.
`AnalyticsService` answers it from the existing `sourceFieldMeta` hook
(`type`, and now `multiple`). `AnalyticsServicePlugin` relays `multiple`
from the field definition.
**The field's declaration is never guessed.** A face that cannot name it
refuses, before anything binds. On the `where` face that is
`INVALID_FILTER` / 400; on the read-scope face it is
`READ_SCOPE_COMPILE_FAILED` / 500. The same holds for a multi-value
field on the `'unknown'` dialect, where no JSON test parses everywhere;
the text and null-only rows need no dialect. Why a guess is not
possible: the "no declaration" reading the spec gives the JS faces
(null, `''` and `[]` all empty) has no SQL form without the type.
`amount = ''` is a type error on Postgres, and an empty list is only
recognisable as JSON. Both refusals are pinned with `code` + `status`.
## The question the card asked: should the read-scope face answer an
unknown operator with 400?
**No. It keeps `READ_SCOPE_COMPILE_FAILED` / 500 with the message
withheld, and this PR says so in code at `compileOperator`'s `default:`
arm.** The triage reading ("an authoring mistake answered as a server
error is the wrong class") assumes the caller authored the input.
Measured, the caller does not:
- **Who writes what reaches `compileScopedFilterToSql`.** Its only two
in-package callers are `NativeSQLStrategy.applyReadScope` and the
`ObjectQLStrategy.generateSql` echo. Both pass
`ctx.getReadScope(object)`. `AnalyticsServicePlugin` answers that hook
either from the `security` service's `getReadFilter`, which compiles
admin-authored sharing rules and permission sets, or from the host's own
`getReadScope` plugin option. The analytics caller's own filter takes
the other road, `filter-normalizer.ts`, and that road answers
`INVALID_FILTER` / 400 for an unknown operator.
- **The ruling already on file.** The read-scope module header records
the #5367 maintainer ruling (2026-08-06, re-affirmed as #7598 Q2 = A). A
read-scope refusal is a server fault: a 400 "told them to fix a request
that was never the problem, and hid the fault from the 5xx alerting". A
4xx body also relayed "THE FIELD NAMES AND COMPARANDS OF THE RLS
POLICY". The header states that the envelope "is not to be rewritten".
#19995 (`60fdaa9e`, PR #20072) extended the same withheld 500 to the
ObjectQL engine door for exactly that disclosure reason.
- Pinned: `$bogus` in a read scope still answers
`READ_SCOPE_COMPILE_FAILED` / 500 (`read-scope-empty-operator.test.ts`,
last case). The existing envelope suites stay green unchanged.
## Filter-semantics compile-surface declaration
Roster re-grepped on `fc0db22b` (`grep -rn
'matchesFilterCondition\|buildWhereSQL\|compileScopedFilterToSql'
packages --include=*.ts`).
| # | face | conclusion |
|---|---|---|
| 1 | `driver-sql` `applyFilterCondition` (and its `extends SqlDriver`
heirs) | **out of scope**: sibling #20444 (`domain:engine`). Measured
today: it refuses `$empty` with `INVALID_FILTER` / 400, operator and
field withheld. Untouched here. |
| 2 | turso `RemoteTransport` `buildWhereSQL` | **out of scope**:
sibling #20444. Untouched. |
| 3 | service-analytics `read-scope-sql` `compileScopedFilterToSql` |
**changed**: the `$empty` arm above, and the boolean gate. |
| 4 | service-analytics `filter-normalizer` `lowerAnalyticsWhere` /
`normalizeAnalyticsFilterTree` | **changed**: the `empty` / `notEmpty`
leaf, answered by NativeSQL and the ObjectQL echo, and handed to the
engine by ObjectQL execute. |
| 5 | `formula` `matchesFilterCondition` | **out of scope**: sibling
#20444. Untouched. |
| half-face | objectql `having-filter` (`applyHaving` / `matchesHaving`)
| **out of scope**: sibling #20444. Untouched. |
| unfrozen | `driver-memory` `checkCondition`, `driver-mongodb`
`translateFieldOperators` | **out of scope**: sibling #20444. Untouched.
|
Two package-local consumers of face 4's tree, named so they don't read
as missed:
- **ObjectQL execute.** It hands `{ $empty }` to the engine. Until
#20444's `driver-sql` arm lands, the engine refuses it, so this query is
refused on this strategy, while the echo prints the declared arm and the
native statement answers it. No face drops it.
- **Draft preview (`preview-evaluator.ts`).** Unchanged. It already
refuses `$empty` (`INVALID_FILTER` / 400) with its other unevaluated
operators, `$null` among them.
## Evidence (HEAD `6a07f8cc`; the suite ran at `20994c10`, and HEAD adds
only the changeset on top of it)
- **Premise, re-measured on `fc0db22b` before editing.**
`lowerAnalyticsWhere` passed `{ f: { $empty: true } }` through, and
`normalizeAnalyticsFilterTree` refused it `INVALID_FILTER` / 400.
`compileScopedFilterToSql` refused it `READ_SCOPE_COMPILE_FAILED` / 500,
and `$bogus` got the same answer. `git grep -c '$empty'` over
`service-analytics/src` read 0 hits; the control word `$null` read 10+
files.
- **New pins.**
- `read-scope-empty-operator.test.ts`: 31 tests, executed on `sql.js`.
- `where-empty-operator.test.ts`: 27 tests. NativeSQL executes on
`sql.js`, the ObjectQL echo runs on the same database and must return
the same rows, and the condition handed to the engine is pinned.
- Fixture: a text, a `tags`, a `lookup` with `multiple: true`, a
`select` and a `number` field. Rows: null, `''`, `[]`, a non-list JSON
value, and a value.
- Covered: `$empty: false` as the complement; nesting under `$and` /
`$or` / `$not`; beside another operator on the same field. Refusals
assert `code` + `status`.
- Postgres / MySQL SQL strings are pinned as compiled, **NOT MEASURED**
as executed: there is no live server here.
- **Package suite.** `pnpm --filter @objectstack/service-analytics exec
vitest run --maxWorkers=2`: `Test Files 134 passed (134)` · `Tests 3151
passed (3151)`.
- **Typecheck.** `pnpm --filter @objectstack/service-analytics exec tsc
--noEmit --listFiles` exits 0, and its file list contains all three new
files.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 62 commands; all 62 ran.
`--ran` reconciliation: "62 derived famil(ies) accounted for — 60 run, 2
NOT-MEASURED".
- NOT MEASURED: `check:dual-build-cjs-loads` and
`check:type-check-debt`. Both exit 3 with `PREREQUISITE NOT MET`,
because they need the whole workspace built. Narrowed probe instead:
this package's built `dist/index.cjs` and `dist/index.js` both load and
export `compileScopedFilterToSql`.
- **Lint, narrowed.**
- Population: `eslint.config.mjs` lints `packages/**/*.{ts,tsx,mts,cts}`
with no type-aware parsing (no `parserOptions.project`). A verdict on an
untouched file therefore cannot move with this diff.
- `pnpm exec eslint --no-inline-config --format json` over the 10
changed `.ts` files: the JSON has 10 file entries, 0 errors, 0 warnings.
### Ablation: the negative pins can fail
Committed first; each leg ran through `scripts/ablation-replace.mjs`,
which applies the mutation, runs, and restores. Restore was proven blob
== HEAD (`05d539c76470`) with `git diff HEAD` empty.
- **A1: the null-only row counts `''`.** The `null_only` arm falls
through to the text arm. **9 red across both files**, including "the
null-only row does NOT count the empty string": `AssertionError:
expected [ 'n', 's' ] to not include 's'`.
- **A2: `$empty: false` stops being the complement.** The text arm's
false branch becomes an OR. **7 red**, including "name: $empty: false is
the exact complement": `expected [ 'l', 'o', 's', 'v' ] to deeply equal
[ 'l', 'o', 'v' ]`.
## Acceptance notes (observations, not filed)
- **The spec's staging prose goes stale here.** The `FILTER_OPERATORS`
TSDoc table in `packages/spec/src/data/filter.zod.ts` still lists both
service-analytics rows as REFUSES. Carrier: the flip card, which
rewrites that table. No `packages/spec` edit here.
- **Shared conformance cases belong in the spec.** A shared `$empty`
conformance table (per-type rows × stored states, the way
`FILTER_LOGIC_CASES` works) would let every face run one standard. That
is the spec lane's to add, with the flip card, and is not added here.
- **The flip card will need `$empty` rows** in
`objectql-echo-operator-coverage.test.ts` (`OPERATOR_CASES`) and
`objectql-icontains-arm.test.ts` (`SAMPLES`). Both assert their tables
equal `FILTER_OPERATORS`, so they go red on the flip until the rows
exist.
- **A read scope carrying `$empty` on the ObjectQL execute face** is
refused by the engine's driver as `INVALID_FILTER` / 400 with the
operator and field withheld, not the read-scope 500. `judgeFilter`
admits the operator because it stops before the driver. This predates
the PR and closes when #20444 lands the driver arm. No in-repo producer
emits `$empty` in a read scope (the CEL lowering's `is_empty` emits
`$null`).
## Seat append (`domain:services` seat #6021,
`session_017B6YKCGu8CTY2KBWgwaHAs`)
- The `Clause-②` line changed from `no` to `yes (widening)`, per
contract review FAIL `5876996555`. The PR adds two published members,
`multiple?` on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape
and `declaredValueShape?` on `compileScopedFilterToSql`'s options. The
changeset moves to `minor` in the patch round on this PR.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48efe91 commit 2b53993
11 files changed
Lines changed: 1089 additions & 18 deletions
File tree
- .changeset
- packages/services/service-analytics/src
- __tests__
- strategies
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
Lines changed: 249 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
0 commit comments