feat(spec): declare the staged $empty filter operator and its per-type expansion (#20311) - #20442
Conversation
…pe expansion Declares `$empty: boolean` in FieldOperatorsSchema and SpecialOperatorSchema, its description carrying the ruled per-type table, plus the exported expandEmptyOperator / isEmptyFilterValue / EMPTY_OPERATOR_ARMS. Staged like $like: absent from FILTER_OPERATORS; the is_empty lowering still emits $null. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
… changeset Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
filter.zod.ts takes no import from field-value.zod: the two meet in the field.zod import cycle, and filter.zod.ts' import closure is what the published query and api skill reference indexes walk. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…ference for $empty Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check20 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 28f379d6550d53906ab8730cd2cc3bc47e9f4672 && git checkout 28f379d6550d53906ab8730cd2cc3bc47e9f4672
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fbeb56e4b5e4f7b33336926353bcd0c77147ad7f d581aed71f8b00f1f8df25a5d8f178e5029d1a82 && git checkout -B drift-repro fbeb56e4b5e4f7b33336926353bcd0c77147ad7f && git merge --no-ff d581aed71f8b00f1f8df25a5d8f178e5029d1a82
node scripts/docs-audit/affected-docs.mjs --json fbeb56e4b5e4f7b33336926353bcd0c77147ad7f |
Contract reviewServed-tier: Inputs read: card #20311 (body; ruling B Check-runs on this head, read at 2026-09-28T13:31Z: every gate-carrying context is ① Derived judgments
② Semver level
The one incidental narrowing is confined to a never-declared spelling: a NON-boolean Clause-②: yes (widening) — the changeset carries exactly that line. The PR body carries ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Pulled from the merge queue by the Signature. In the merge group Why this is not a flake, and whose it is. It is a semantic conflict between two PRs that are each green alone, and it is deterministic. This PR is the later lander, so the reconciliation is this PR's. Fix (patch round, after PR #20414 lands).
Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36431602315 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36434107738 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…-empty-per-type-lowering
…ference over the merged tree Both sides' exports: #20336's number-comparand door and this branch's $empty expansion; filter.mdx carries main's frontmatter and the $empty rows. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…and door's partition The #20336 pin partitions FieldOperatorsSchema's keys into the judged positions, the text operators and the boolean flags; $empty is a flag (a boolean, not a value of the field), so it joins $null / $exists there, in the module's "Not judged" docblock and as an unjudged case row. The door's verdict logic is unchanged. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: This is the patch-round record. It re-judges the whole net diff on this head, not only the three patch-round commits. Inputs read: card #20311 (body; ruling B Check-runs on this head, read at 2026-09-28T16:40Z: 42 runs, all completed, 38 ① Derived judgments
② Semver level
The one incidental narrowing stays confined to a never-declared spelling: a NON-boolean Clause-② — the changeset carries ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20311
Clause-②: yes
Declares the emptiness operator
$empty: booleanin@objectstack/spec. Its description is ruling B's per-type table. The one expansion every compile surface will call is exported beside it. The operator is staged the way$likewas: it is declared, but deliberately absent fromFILTER_OPERATORS, and theis_empty/is_not_emptylowering still emits$null.Ruling-ref: 5861435168 (ruling B on #20311), 5865693155 (ruling A on #20399, the spelling), 5868169573 (the maintainer's amendment, 「照 $like 先例分阶段」).
What changed
FieldOperatorsSchemaandSpecialOperatorSchema(the enforced copy and the documentation copy, one shared constant) gain$empty: z.boolean().optional(). The description is the ruled table: text-like (STRING_VALUE_TYPES) = null or''; multi-value (isMultiValueField: multiselect, checkboxes, tags, and select / radio / lookup / user / file / image withmultiple: true) = null or[]; every other type = null only;falseis the exact complement; a face with no field declaration judges by value. It also says in plain words that the operator is staged and that no face answers it yet.packages/spec/src/data/filter-empty-operator.ts, published on the data entry:expandEmptyOperator(field)keys on the field DEFINITION (type plusmultiple) and returns one of the frozenEMPTY_OPERATOR_ARMSrows{ arm, emptyString, emptyList }.isEmptyFilterValue(value, expansion?)is the value-level half: with an expansion it applies the declared row; without one it applies the by-value reading for the formula matcher andhaving. The result is surface-neutral, deliberately not aFilterCondition, because[]stays refused as an equality comparand (ruling 乙 on [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757, untouched).FILTER_OPERATORSis unchanged. Its docblock gains a$emptystaging paragraph with the measured per-face table below. The flip card is named as the one that adds the operator.filter-operator-vocabulary.test.ts'STAGED_AHEAD_OF_BACKENDSbecomes['$empty', '$ilike', '$like'].FieldOperatorsSchema's keys, kept green the way$like's staging kept them:filter-comparand-type.ts) gains$empty;filter-save-door-refusals.ts) gains$empty. A non-boolean$emptyis then refused at save, as$null/$existsalready are, with the flags' first sentence and an$empty-specific prescription.@objectstack/specminor,Clause-②: yes (widening). It says plainly that authoring$emptytoday is refused at query time.Why the expansion is its own module, not in
filter.zod.tsThe first version put the functions in
filter.zod.ts, importing the sets fromfield-value.zod.ts.gen:skill-refsthen rewroteskills/objectstack-query/references/_index.mdandskills/objectstack-api/references/_index.md: that import pulledfield-value.zod.ts,field.zod.tsand four shared modules into those skills' transitive reference lists. Anyskills/**path would make this PR Tier H. The two files also meet in thefield.zodimport cycle. So the expansion sits in a sibling module, the precedent beingfilter-text-operator-declared-type.ts, which reads the same sets from the same position.filter.zod.tstakes no new import. At the final headcheck:skill-refsis green with zeroskills/**changes. The description names its type lists literally, andfilter-empty-operator.test.tspins each list to the sets the function reads, so the two cannot drift.A1: what every compile surface does with a hand-authored
$empty(measured)Probe: a scratch script run once and not committed. It drove each surface with
{ f: { $empty: true } },{ f: { $empty: false } }and{ $and: [{ g: 'x' }, { f: { $empty: true } }] }, after this change was built. Two controls ran beside it: the declared{ f: { $null: true } }, and an undeclared{ f: { $bogus: true } }.$empty(all three shapes)$null$bogusapplyFilterCondition, viaSqlDriver.findon better-sqlite3 (driver-sqlite-wasm and driver-turso local inherit this compiler; not driven separately)INVALID_FILTER/ 400['2']INVALID_FILTER/ 400RemoteTransport.buildWhereSQL, viafindINVALID_FILTER/ 400IS NULLINVALID_FILTER/ 400compileScopedFilterToSqlREAD_SCOPE_COMPILE_FAILED/ 500 (fail-closed)IS NULLlowerAnalyticsWherenormalizeAnalyticsFilterTree, REFUSESINVALID_FILTER/ 400notSetmatchesFilterConditionfalsefor every record, with flagtrueand with flagfalsefalseapplyHaving/matchesHavingINVALID_FILTER/ 400findandmatch(checkCondition)INVALID_FILTER/ 400['2']translateFilter(translateFieldOperators)INVALID_FILTER/ 400{ f: { $eq: null } }Conclusion per surface, for this card: explicitly out of scope; each gets its
$emptyarm from its lane card. No surface drops the predicate, so nothing widens. Two readings differ from premise A1, "the staging leaves every surface loud":falsefor any operator it has no arm for: its decided fail-closed posture (JS 求值面全体拒收$icontains(driver-memory 两面 / driver-mongodb / objectqlhaving/ formula)—— SQL 族已实现,同一 filter 在内存 double 上抛错 #6520), identical for the undeclared$bogus, and unchanged by this PR. It denies a write-sidecheckrather than widening anything. But its own docblock says a DECLARED operator must not get that silentfalse("the same defect under a new name"), and$emptyis now declared. So that claim is stale until formula's lane card lands.$likegot its formula arm in the PR that declared it; this card is barred from formula by the order and by the amendment's placement of arms in lane cards. Flagged in the report as an open question for the seat.READ_SCOPE_COMPILE_FAILED/ 500, notINVALID_FILTER/ 400. It is loud and fail-closed, and it does the same for every unknown operator. The description and the changeset say "refuse", not "400".A2 to A4
FieldOperatorsSchema's own keys and saw$empty:filter-comparand-type.test.ts(judged set) andfilter-save-door-face-parity.test.ts(BOOLEAN_SLOTS). Both are updated, with the source sets they reconcile.filter-operator-vocabulary.test.tsrecords the staging. Suites that enumerateFILTER_OPERATORS(filter-view-operator-parity,page-component-filter-record-to-rule-array, service-analytics' echo coverage) are untouched, because the array is. No test outsidepackages/specenumeratesFieldOperatorsSchema's keys (repo grep: only prose mentions). No gate needed an edit outsidepackages/spec.50e273fd7:STRING_VALUE_TYPEShas the 14 text types;isMultiValueField=MULTI_OPTION_TYPESor aMULTI_CAPABLE_TYPESmember withmultiple: true. The sets are disjoint, a pin asserts it, andlookupvslookupwithmultiple: trueland on different rows.isEmptyFilterValue(value)with no expansion is the declaration-free reading (null,undefined,'',[]). It differs from the declared table only on a non-text column holding'', and a pin shows exactly that divergence.Pins (
filter-empty-operator.test.ts){ tags: { $empty: true } }parses atFieldOperatorsSchema(kept, not stripped), atFilterConditionSchema(any depth) and through the normalized AST.{ tags: [] }and{ tags: { $eq: [] } }are still refused (issue at the slot; the query face answersINVALID_FILTER/ 400). A non-boolean$emptyis refused at the slot and at the save door.expandEmptyOperatorreturns the text, multi-value and null arms for the three kinds, and everyFieldTypelands on its value class's arm.isEmptyFilterValueanswers each arm, plus the by-value reading.$emptyis ABSENT fromFILTER_OPERATORS; a comment names the flip card as the one that adds it.is_empty/isempty/is_not_empty/isnotemptystill lower to$null.Verification (final head
32e926db1)packages/specfull local suite:vitest run --project local, 563 files, 16536 passed, 1 todo.pnpm --filter @objectstack/spec typecheck: exit 0 (tsc, scripts typecheck, test typecheck).scripts/ablation-replace.mjs. It added'$empty'toFILTER_OPERATORS: anchor count 1 to 0, blob0912c2776e5eto74b0e4d23fe0. The run went red on exactly the two staging pins (filter-empty-operator§4 andfilter-operator-vocabulary), with 2 failed and 23 passed. The file was restored to blob == HEAD withgit diff HEADempty. The second ablation (dropping$emptyfrom the save-door flag set) was not run: the verify-lock queue timed out.@objectstack/spec, i.e. downstream.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 108 commands at this head (a superset of the dispatch list's 72). All were run, and--ranreconciled: 106 exited 0; 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET) because they need every package built. Those two arecheck:dual-build-cjs-loadsandcheck:type-check-debt, and CI runs them.check:skill-examplesfirst exited 3 (client-react unbuilt) and exited 0 after building it.pnpm --filter @objectstack/spec check:generated: every artifact current.eslint --no-inline-config --format jsonover the 9 changed TypeScript files reported 9 files, 0 errors, 0 warnings. The population iseslint.config.mjs'**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}block. That config never enables type-aware linting (its own note says so), so this diff cannot move a verdict on any untouched file. The fullpnpm lintis CI's.Patch round: reconciled with #20414 (head
d581aed71)where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336) landed asb28550818. Its partition pin holdsFieldOperatorsSchema’s keys equal to its judged positions, the text operators and$null/$exists. With$emptyadded, that pin went red in every merge group carrying both PRs (audit5871530372; the diagnosis is on Queue-flake anchor: src/data/filter-number-comparand-declared-type.test.ts #20455). This PR is the later lander, so it carries the reconciliation.where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336’s files.$emptyjoins the flag operators in the partition; the test title now says "three flag operators".$empty.NUMBER_COMPARAND_DOOR_CASESexists.origin/mainb28550818was merged throughos-regen-merge.sh(43801c9ed) and regenerated in859d9bd82. Both PRs’ exports are present, once each, inapi-surface/data.jsonandexport-origins/data.json, andfilter.mdxcarriesmain’s frontmatter and the$emptyrows.d581aed71.test(local): 566 files, 16687 passed;typecheck: exit 0;check:generated: exit 0;check:dual-build-cjs-loads,check:type-check-debt): both need the whole-repo build.test:reporan in 9 shards: 8 passed, and one was cut by the local time cap, so it is NOT MEASURED locally. CI’sTest Core, which runstest:repo, is green on this head.Stored sharing rules (ruling B, parameter 3)
50e273fd7: the criteria sharing rules inexamples/that use emptiness = 0.b45d463a9: none either; the 22 emptiness hits in sharing-rule-related files are builder code and tests, not stored rules.Acceptance notes
PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414’s number-comparand partition pin now names
$emptyamong the boolean flag operators (see the patch round above). The flip card filter: flipis_empty/is_not_emptyto$emptyand add it toFILTER_OPERATORS, once every compile surface answers it (the last step of ruling A on #20399) #20446 must keep it there when$emptyentersFILTER_OPERATORS.formula's docblock claim ("the silent answer is reachable only for a name the protocol does not declare") goes stale with this declaration. Carrier: formula's lane card, which gives it the
$emptyarm (by value,isEmptyFilterValue(value)).read-scope-sql answers every unknown operator with
READ_SCOPE_COMPILE_FAILED/ 500 rather than the ADR-0112INVALID_FILTER/ 400 the other faces use. It is pre-existing, fail-closed and loud. Carrier: service-analytics' lane card.packages/spec/src/ui/view-grouping-query.tssays the empty-group predicate and the view filter'sis_emptyagree on what "empty" means. That stays true while the lowering is$null. Carrier: the flip card.is_emptystill means null-only on every face until the flip card: the gap ruling B closes is still open for users, by design of the staging.The patch-round section and the first acceptance note were added by the
domain:specseat 1 (session_01B3TqpoQbTAfG7G74GMDWNW) from the dev’s round report.