From 20911473180cbbedc990f190aa226c49939c54ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 05:12:25 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(spec):=20$gt/$gte/$lt/$lte=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=97=E5=B9=B3=E5=8F=B0=E8=87=AA=E5=B7=B1=E4=BA=A7=E5=87=BA?= =?UTF-8?q?=E7=9A=84=20ISO=20=E5=AD=97=E7=AC=A6=E4=B8=B2=20(#5685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四个排序比较槽位声明为 `number | Date | FieldReference`,而平台自己的生产者 往这四个槽位里放的恰恰只有字符串。这不是"声明得不够细",是声明与现实相矛盾。 ## 前提复核(origin/main @ 02479cc91,逐条核对 issue 事实) 1. `packages/spec/src/data/filter.zod.ts:92` 起四槽位确为 `z.union([z.number(), z.date(), FieldReferenceSchema])`,无 `string`; 其上注释写 "Supported data types: Number, Date"。成立。 2. `packages/core/src/utils/filter-tokens.ts` 日期宏全分支返回 string — `:303 case 'now': return now.toISOString()`、`:319 addUnits(...).toISOString()`、 `:304-306/321 asYmd(...)`,无任何分支返回 number 或 Date。成立。 3. `date-macros.zod.ts` 明写 "the DRIVER only ever sees ISO date / timestamp strings, never {tokens}"。成立。 4. 一方调用方传 `.toISOString()`:`lifecycle-service.ts:1105/1134-1135/1195/1211`、 `plugin-email/src/outbox-sweep.ts:155,160`。成立。 复核中另发现**第三个**一方生产者(issue 未列): `plugin-auth/src/objectql-adapter.ts:206-215` 用 better-auth 未定型的 `condition.value` 直接下沉到这四个槽位。 前提全部成立,方向按分诊结论:声明侧对齐现实,不动生产者与调用方。 ## 改动 `string` 并入四个槽位的联合,并入本文件声明该契约的**全部三处**: - `ComparisonOperatorSchema` —— 文档面(issue 点名处),补长 docblock 与 每槽位 `.describe()`; - `FieldOperatorsSchema` —— **被强制执行的那一份**:`NormalizedFilterSchema` 拿它校验,导出类型 `FieldOperators` 由它推断。只改文档面会留下 "文档说可以、可达面仍拒绝"的分裂,故一并对齐; - `Filter` TS 泛型 —— 此处 `T` 已知,故保持类型精确而非一律放行: `Date` 字段兼收解析器产出的 ISO 串,`string` 字段(`Field.time` 的 `'09:00'`、 autonumber 编码)可排序而非塌成 `never`,`number` 字段仍只收数字。 纯声明侧加宽,additive:未改任何生产者/调用方/driver。各求值面本来就在比较 字符串(driver-sql 绑 `>`/`>=`/`<`/`<=`;formula 的 matchesFilter 与 driver-memory 的 matcher 落到 JS 运算符),改前能过的过滤器改后一律仍能过。 ## rider ① 裸 string vs 受约束日期串 —— 实测后取**裸 `z.string()`** 先与 driver-sql 比较语义对读:`$gt/$gte/$lt/$lte` 在 `sql-driver.ts:7287-7297` 下沉为朴素 `>`/`>=`/`<`/`<=`,比较值先过 `coerceFilterValue`(`:6408`)。三条实测理由: 1. **本 schema 是 field-agnostic 的** —— 它看不到算子作用在哪一列,任何 值形状 refine 都是对列类型的猜测。比较值与列的匹配已有归属: `coerceFilterValue` 按 `temporalFieldKind` 分派(datetime → `storageDatetimeValue`,date → `toDateOnly`,time → `canonicalTimeOfDay`, 其余透传)。 2. **ISO refine 会拒掉平台自己声明的形态。** `field-value.zod.ts` 的 `CLOCK_TIME_TYPES` 定义 `Field.time` 值为 `HH:MM[:SS[.fff]]`,并明写 "not Date.parse-able";`SqlDriver.temporalFilterValue` 正是在**比较值位置** 规范化它(`'14:30'` → `'14:30:00'`,#3979 契约对,pin 在 `sql-driver-time-canonical-storage.test.ts:221-222`)。`$gte: '09:00'` 是 受支持的比较,ISO refine 会拒绝它。 3. **date-only 与 full-timestamp driver 侧已经处理一致**(rider 要求确认的 那一条),故收窄换不到安全性:裸 `YYYY-MM-DD` 作下界锚定 UTC 午夜,作上界 由 `calendarDayUpperBoundRewrite` 改写为半开的 `< 次日午夜`(#3777 约定)。 **放行面据实写入 `.describe()` 与 docblock**:加宽同时放行非时间列的文本排序 (`{ code: { $gt: 'M' } }`)。这是真 SQL 且各后端都会作答,但**次序是后端的、 不是本契约的** —— driver-sql 交给方言排序规则(SQLite 按字节、Postgres 按库 locale、MySQL 按列 collation),formula/driver-memory 用 JS 的 UTF-16 码元序; 二者仅在 ASCII 上重合。故契约**保证**的比较值形态是 ISO/时钟那三种 (`YYYY-MM-DD`、UTC ISO-8601 瞬间、`HH:MM[:SS[.fff]]`):它们是 ASCII 定宽, 字典序即时间序,各后端一致。排序任意自然语言文本是"放行"而非"承诺"。 ## rider ② changeset `.changeset/comparison-operator-string-comparand.md`,`@objectstack/spec: minor` —— 加宽接受面属 additive,按仓内惯例走 minor。 ## 验证读数(全部前台阻塞,flock 串行) - `pnpm --filter @objectstack/spec test`:343 files / **8830 passed**,408.53s。 - `pnpm --filter @objectstack/spec typecheck`:`tsc --noEmit` + scripts + `check:test-typecheck` 全绿(测试层 58 files / 267 errors 仍为既有 debt 基线, 未增;`data/filter.test.ts` **不在** debt 名单内,故本 PR 的类型断言是真受检的)。 - 生成物:`gen:schema` → `gen:docs` → `gen:api-surface` 后 `check:generated` **10/10 up to date**;`check:docs` 231 files in sync; `check:api-surface`、`check:authorable-surface` 均 exit 0。 - 门禁:`check:spec-parsed-alias` OK(1465 bare / **755 pinned isomorphic** / 710 paired —— 本改动未新增具名 schema,ADR-0122 计数不动,pin 测试 `type-alias-convention.pin.test.ts:1501` 的 `toHaveLength(755)` 无需改); `check:nul-bytes` OK(6132 文件,无裸控制字节);eslint 两个改动文件 exit 0。 - `gen:authorable-surface-base` 产出的 `authorable-surface.base.json` 变更已 **撤回**:其 diff 全是他人的键(`api/ValidateData*`、 `cloud/ProvisionEnvironmentResponse:hostnameAssignment`)——是锚点追赶 main, 与本改动无关。生成器与 `check:authorable-surface` 都写明"重锚是需要独立 review 的刻意动作,绝非本次构建的副作用"(#5358)。本改动不新增 authorable 键。 ## 逆向验证(方向先判后跑) 判定:本改动是**加宽**,新测试钉的是"新被接受"的值,故预期为常规方向 —— 撤掉 schema 改动则新测试转红。实测与预判一致: - 撤回 `filter.zod.ts`、保留新测试:`filter.test.ts` **5 failed | 101 passed**, 报错正是 `"Invalid input: expected number, received string"` / `"expected date, received string"`。101 个既有断言仍绿 —— 这同时证明加宽是 additive,没有改变任何原有判定。 - TS 半边由 `tsc` 而非 vitest 裁定(vitest 不做类型检查),单独跑 `tsc -p tsconfig.test.json` 得两条,均落在新增断言块内: `filter.test.ts(547,57): TS2322 Type 'string' is not assignable to type 'Date'` `filter.test.ts(549,54): TS2322 Type 'string' is not assignable to type 'undefined'` 第二条读作 `undefined` 而非 `never`:旧 guard 的 `never` 撞上槽位自身的 `?`, optional 的 `never` 即 `undefined`。测试注释已按**实测原文**更正 (原先按预期写的是 `never`)。 - 恢复改动后:`filter.test.ts` 106/106 绿,typecheck 全绿。 Fixes #5685 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .../comparison-operator-string-comparand.md | 50 ++++++ packages/spec/src/data/filter.test.ts | 117 ++++++++++++++ packages/spec/src/data/filter.zod.ts | 148 +++++++++++++++--- 3 files changed, 297 insertions(+), 18 deletions(-) create mode 100644 .changeset/comparison-operator-string-comparand.md diff --git a/.changeset/comparison-operator-string-comparand.md b/.changeset/comparison-operator-string-comparand.md new file mode 100644 index 0000000000..8d7d960429 --- /dev/null +++ b/.changeset/comparison-operator-string-comparand.md @@ -0,0 +1,50 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec): `$gt`/`$gte`/`$lt`/`$lte` accept the ISO string the platform itself produces (#5685) + +The four ordering-comparison slots declared `number | Date | FieldReference` — +and the platform's own producers put a **string** in them and nothing else. The +declaration did not merely under-describe reality, it contradicted it: + +- `resolveFilterTokens` (`@objectstack/core`) is the evaluator for the `{token}` + grammar and **every** branch returns a string — `asYmd(…)` for a calendar day, + `.toISOString()` for the sub-day tokens. Its own module example is exactly + this shape: `{ close_date: { $gte: '{current_year_start}' } }` becomes + `{ close_date: { $gte: '2026-01-01' } }`. +- `date-macros.zod.ts` states the same rule from the other end: "the DRIVER only + ever sees ISO date / timestamp strings, never `{tokens}`". +- Three first-party callers send strings today — `lifecycle-service`'s retention + cutoffs, `plugin-email`'s outbox sweep, and `plugin-auth`'s better-auth + adapter. + +An author — an AI author in particular — reading `number | Date` concluded that +a date window must be a `Date` object or an epoch number, which is the one form +the date-macro path can never hand them. + +**This is additive and declaration-side only.** No producer, caller or driver +changed. Every evaluation surface already compared strings: `driver-sql` binds +`>`/`>=`/`<`/`<=`, and `formula`'s `matchesFilter` and `driver-memory`'s matcher +fall through to the JS operators. Filters that validated before still validate. + +Widened in all three places this contract is spelled: `ComparisonOperatorSchema` +(documentation), `FieldOperatorsSchema` (the copy `NormalizedFilterSchema` +validates against and `FieldOperators` is inferred from), and the `Filter` +TypeScript helper — where `T` is known, so it stays type-precise: a `Date` field +now also takes the resolver's ISO string, a `string` field (a `Field.time` +`'09:00'`, an autonumber code) is orderable instead of collapsing to `never`, +and a `number` field stays numbers-only. + +**The comparand form the contract guarantees** is the ISO/clock one — an ISO +calendar day (`YYYY-MM-DD`), a UTC ISO-8601 instant, or a wall-clock time of day +(`HH:MM[:SS[.fff]]`). Those are ASCII and fixed-width, so lexicographic order IS +chronological order and every backend agrees. The union is a bare `string` +rather than an ISO refinement because this schema is field-agnostic (it never +sees which column the operator applies to) and because an ISO refinement would +reject `Field.time`'s declared `HH:MM` form, which `SqlDriver.temporalFilterValue` +canonicalises in the comparand position. Ordering **non-temporal** text is +therefore permitted but not promised: the order is the backend collation's +(byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the +JS matchers), and those coincide only for ASCII. The `.describe()` on each slot +says so. diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index 6a2b8cf4cd..df72150189 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -59,6 +59,95 @@ describe('ComparisonOperatorSchema', () => { expect(() => ComparisonOperatorSchema.parse({ $lt: date })).not.toThrow(); expect(() => ComparisonOperatorSchema.parse({ $lte: date })).not.toThrow(); }); + + // ========================================================================== + // #5685 — the four slots accept the STRING the platform itself produces. + // + // Before this was pinned the union was `number | Date | FieldReference`, so + // every shape below threw — including the date-macro resolver's own + // documented output. These are the exact spellings the producers emit; see + // `ComparisonOperatorSchema`'s docblock for why the union is a bare `string` + // rather than an ISO refinement. + // ========================================================================== + + describe('string comparands (#5685)', () => { + const OPS = ['$gt', '$gte', '$lt', '$lte'] as const; + + /** `resolveFilterTokens` returns `asYmd(...)` for every calendar-day token. */ + it('accepts the calendar-day string a date macro resolves to', () => { + for (const op of OPS) { + expect(() => ComparisonOperatorSchema.parse({ [op]: '2026-01-01' })).not.toThrow(); + } + }); + + /** + * The sub-day tokens (`{now}`, `{N_hours_ago}`) and all three first-party + * callers — `lifecycle-service`, `plugin-email`'s outbox sweep — emit a + * full `.toISOString()`. + */ + it('accepts the full ISO instant the sub-day tokens and the sweeps emit', () => { + const cutoff = new Date('2026-08-08T04:32:56.000Z').toISOString(); + for (const op of OPS) { + expect(() => ComparisonOperatorSchema.parse({ [op]: cutoff })).not.toThrow(); + } + }); + + /** + * A `Field.time` comparand is `HH:MM[:SS[.fff]]` and is declared + * NOT `Date.parse`-able (`field-value.zod.ts`, CLOCK_TIME_TYPES); the SQL + * driver canonicalises exactly this in the comparand position (#3979). + * This case is why the union is not narrowed to an ISO date/date-time shape. + */ + it('accepts a wall-clock time-of-day, which an ISO refinement would reject', () => { + for (const op of OPS) { + expect(() => ComparisonOperatorSchema.parse({ [op]: '09:00' })).not.toThrow(); + expect(() => ComparisonOperatorSchema.parse({ [op]: '14:30:00.500' })).not.toThrow(); + } + }); + + /** + * Non-temporal text ordering rides along with the widening. Pinned as an + * ADMITTED shape rather than a promised ordering — the docblock says the + * ORDER is the backend collation's, and this schema is field-agnostic so it + * cannot tell a code column from a date one. + */ + it('admits non-temporal text (order is the backend collation\'s, not this contract\'s)', () => { + for (const op of OPS) { + expect(() => ComparisonOperatorSchema.parse({ [op]: 'M' })).not.toThrow(); + } + }); + + it('still accepts numbers, Dates and field references — widening is additive', () => { + for (const op of OPS) { + expect(() => ComparisonOperatorSchema.parse({ [op]: 42 })).not.toThrow(); + expect(() => ComparisonOperatorSchema.parse({ [op]: new Date('2026-01-01') })).not.toThrow(); + expect(() => ComparisonOperatorSchema.parse({ [op]: { $field: 'other.col' } })).not.toThrow(); + } + }); + + it('still rejects a comparand that is orderable at no backend', () => { + for (const op of OPS) { + expect(() => ComparisonOperatorSchema.parse({ [op]: true })).toThrow(); + expect(() => ComparisonOperatorSchema.parse({ [op]: { nope: 1 } })).toThrow(); + } + }); + + /** + * The documentation copy above and the ENFORCED copy must not drift: it is + * `FieldOperatorsSchema` that `NormalizedFilterSchema` validates against and + * that the exported `FieldOperators` type is inferred from, so widening only + * the documented one would have left the reachable surface still rejecting + * the platform's own output. + */ + it('is matched by the enforced copy — FieldOperatorsSchema and the normalized AST', () => { + for (const op of OPS) { + expect(() => FieldOperatorsSchema.parse({ [op]: '2026-01-01' })).not.toThrow(); + } + expect(() => NormalizedFilterSchema.parse({ + $and: [{ close_date: { $gte: '2026-01-01' } }, { created_at: { $lt: '2026-08-08T04:32:56.000Z' } }], + })).not.toThrow(); + }); + }); }); // ============================================================================ @@ -439,6 +528,34 @@ describe('TypeScript Type System', () => { expect(filter).toBeDefined(); }); + /** + * #5685 — the TYPED half of the same contract. `Filter` knows `T`, so it + * stays type-precise where `ComparisonOperatorSchema` cannot; what it must + * NOT do is reject the string the platform's own date-macro resolver hands + * the author. These assignments are checked by `pnpm typecheck`, not by the + * runtime expectation below — vitest never typechecks, so reverting + * `filter.zod.ts` leaves this test GREEN under vitest and RED under `tsc`. + * Measured on the reverted schema, both errors land in this block: + * `$gte: '2026-01-01'` -> TS2322 Type 'string' is not assignable to type 'Date' + * `$gte: '09:00'` -> TS2322 Type 'string' is not assignable to type 'undefined' + * (the second reads `undefined`, not `never`: the old guard's `never` meets + * the slot's own `?`, and an optional `never` IS `undefined`.) + */ + it('accepts an ISO string on a Date field and orders string fields (#5685)', () => { + interface Deal { + close_date: Date; // resolved date macro arrives as 'YYYY-MM-DD' + shift_start: string; // Field.time — 'HH:MM[:SS[.fff]]' + amount: number; + } + + const resolvedMacro: Filter = { close_date: { $gte: '2026-01-01' } }; + const stillTakesDate: Filter = { close_date: { $lt: new Date('2026-01-01') } }; + const clockTime: Filter = { shift_start: { $gte: '09:00' } }; + const numeric: Filter = { amount: { $gt: 1000 } }; + + expect([resolvedMacro, stillTakesDate, clockTime, numeric]).toHaveLength(4); + }); + it('should support logical operators', () => { interface Task { title: string; diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 26d9977d33..b922e2ce67 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -86,21 +86,118 @@ export const EqualityOperatorSchema = lazySchema(() => z.object({ })); /** - * Comparison operators for numeric and date comparisons. - * Supported data types: Number, Date + * The comparand contract shared by `$gt` / `$gte` / `$lt` / `$lte` (#5685). + * + * Module-private on purpose: it is documentation attached to four slots, not an + * authorable surface of its own, so it stays out of the exported API surface. + * The reasoning behind every sentence is in {@link ComparisonOperatorSchema}'s + * docblock. + */ +const ORDERING_COMPARAND_DESCRIPTION = + 'Comparand is a number, a Date, a string, or a { $field } reference. ' + + 'STRING is the form the platform itself produces: the date-macro resolver ' + + 'returns only strings ("{current_year_start}" -> "2026-01-01"), and the ' + + 'guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 ' + + 'instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time ' + + 'column. Those are ASCII and fixed-width, so lexicographic order IS ' + + 'chronological order and every backend agrees; the driver reconciles the ' + + 'comparand with the column (a bare calendar day used as an upper bound ' + + 'becomes the half-open next-day boundary). Ordering NON-temporal text is ' + + 'permitted but NOT promised: the order is the backend collation\'s ' + + '(byte-wise on SQLite, the database locale on Postgres, UTF-16 code units ' + + 'in the JS matchers), and those coincide only for ASCII.'; + +/** + * Ordering-comparison operators. + * + * Supported comparand types: **Number, Date, ISO/clock STRING, FieldReference**. + * + * ## Why `string` is in the union (#5685) + * + * Until this was written down the four slots read `number | Date | + * FieldReference` — and the platform's own producers put a STRING in them and + * nothing else. The declaration did not merely under-describe reality, it + * contradicted it: + * + * - `resolveFilterTokens` (`@objectstack/core`, `filter-tokens.ts`) is the + * evaluator for the `{token}` grammar, and **every** branch returns a string + * — `asYmd(…)` for a calendar day, `.toISOString()` for the sub-day tokens. + * Its own module example is exactly this shape: + * `{ close_date: { $gte: '{current_year_start}' } }` → + * `{ close_date: { $gte: '2026-01-01' } }`. + * - `date-macros.zod.ts` states the same rule from the other end: "the DRIVER + * only ever sees ISO date / timestamp strings, never `{tokens}`". + * - Three first-party callers send strings today — + * `lifecycle-service.ts` (`{ created_at: { $lt: keepCutoff } }`, a + * `.toISOString()`), `plugin-email`'s `outbox-sweep.ts` (same shape), and + * `plugin-auth`'s better-auth adapter, which lowers a `gt`/`gte`/`lt`/`lte` + * clause with the producer's own untyped `condition.value`. + * + * An author — an AI author in particular — reading `number | Date` concluded + * that a date window must be a `Date` object or an epoch number, which is the + * one form the platform's own date-macro path can never hand them. This is the + * declaration aligning to a contract the rest of the stack already keeps, not + * a new capability: every evaluation surface ALREADY compares strings + * (`driver-sql` binds `>`/`>=`/`<`/`<=`, `formula`'s `matchesFilter` and + * `driver-memory`'s matcher fall through to the JS operators). + * + * ## Why a BARE string, and not an ISO-shaped refinement (#5685 rider ①) + * + * A tempting narrowing is "accept only an ISO date / date-time string". It was + * measured and rejected, for three reasons: + * + * 1. **This schema is field-AGNOSTIC.** It never sees which column the operator + * is applied to, so any value-shape refinement here is a guess about the + * column. Comparand-vs-column correctness is a field-TYPED judgement and it + * already has an owner: `SqlDriver.coerceFilterValue` dispatches on + * `temporalFieldKind` — `storageDatetimeValue` for `datetime`, `toDateOnly` + * for `date`, `canonicalTimeOfDay` for `time`, passthrough otherwise. + * 2. **An ISO refinement would reject a form this platform DECLARES.** + * `field-value.zod.ts`'s `CLOCK_TIME_TYPES` defines a `Field.time` value as + * `HH:MM[:SS[.fff]]` and says in as many words that it is "not + * `Date.parse`-able". `SqlDriver.temporalFilterValue` canonicalises exactly + * that in the COMPARAND position (`'14:30'` → `'14:30:00'`, the #3979 + * contract pair). A `$gte: '09:00'` on a `time` column is a supported + * comparison an ISO refinement would refuse. + * 3. **date-only and full-timestamp are already reconciled by the driver**, so + * narrowing buys no safety there. A bare `YYYY-MM-DD` anchors to midnight + * UTC for a lower bound and is rewritten to the half-open + * `< next-day-midnight` for an upper bound (`calendarDayUpperBoundRewrite`, + * the #3777 convention). + * + * ## What widening ADMITS, stated plainly + * + * `string` also admits ordering comparisons on NON-temporal text columns + * (`{ code: { $gt: 'M' } }`). That is real SQL and every backend answers it — + * but **the ORDER is the backend's, not this contract's**: `driver-sql` binds a + * plain `>` decided by the dialect's collation (byte-wise on SQLite, the + * database locale on Postgres, the column collation on MySQL), while `formula` + * and `driver-memory` use the JS operators, i.e. UTF-16 code-unit order. Those + * answers coincide for ASCII and diverge outside it — the same split + * {@link StringOperatorSchema} had to rule on for case sensitivity. + * + * **The comparand form this contract guarantees is therefore the ISO/clock one** + * — `YYYY-MM-DD`, a UTC ISO-8601 instant, or `HH:MM[:SS[.fff]]`. All three are + * ASCII and fixed-width, so lexicographic order IS chronological order and every + * backend agrees. Ordering arbitrary natural-language text is permitted, not + * promised: it is the collation's answer, and it may differ per backend. */ export const ComparisonOperatorSchema = lazySchema(() => z.object({ /** Greater than - SQL: > | MongoDB: $gt */ - $gt: z.union([z.number(), z.date(), FieldReferenceSchema]).optional(), - + $gt: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional() + .describe(`Greater than. ${ORDERING_COMPARAND_DESCRIPTION}`), + /** Greater than or equal to - SQL: >= | MongoDB: $gte */ - $gte: z.union([z.number(), z.date(), FieldReferenceSchema]).optional(), - + $gte: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional() + .describe(`Greater than or equal to. ${ORDERING_COMPARAND_DESCRIPTION}`), + /** Less than - SQL: < | MongoDB: $lt */ - $lt: z.union([z.number(), z.date(), FieldReferenceSchema]).optional(), - + $lt: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional() + .describe(`Less than. ${ORDERING_COMPARAND_DESCRIPTION}`), + /** Less than or equal to - SQL: <= | MongoDB: $lte */ - $lte: z.union([z.number(), z.date(), FieldReferenceSchema]).optional(), + $lte: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional() + .describe(`Less than or equal to. ${ORDERING_COMPARAND_DESCRIPTION}`), })); // ============================================================================ @@ -263,11 +360,16 @@ export const FieldOperatorsSchema = lazySchema(() => z.object({ $eq: z.any().optional(), $ne: z.any().optional(), - // Comparison (numeric/date) - $gt: z.union([z.number(), z.date(), FieldReferenceSchema]).optional(), - $gte: z.union([z.number(), z.date(), FieldReferenceSchema]).optional(), - $lt: z.union([z.number(), z.date(), FieldReferenceSchema]).optional(), - $lte: z.union([z.number(), z.date(), FieldReferenceSchema]).optional(), + // Ordering. `string` is in the union for the reason {@link ComparisonOperatorSchema} + // gives at length (#5685): the date-macro resolver and all three first-party + // callers produce ISO/clock STRINGS in these slots and nothing else. This copy + // is the ENFORCED one — `NormalizedFilterSchema` validates against it and the + // exported `FieldOperators` is inferred from it — so it must not drift from the + // documentation copy above. + $gt: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional(), + $gte: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional(), + $lt: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional(), + $lte: z.union([z.number(), z.date(), z.string(), FieldReferenceSchema]).optional(), // Set & Range $in: z.array(z.any()).optional(), @@ -464,10 +566,20 @@ export type Filter = { | { $eq?: T[K]; $ne?: T[K]; - $gt?: T[K] extends number | Date ? T[K] : never; - $gte?: T[K] extends number | Date ? T[K] : never; - $lt?: T[K] extends number | Date ? T[K] : never; - $lte?: T[K] extends number | Date ? T[K] : never; + // Ordering (#5685). The TYPED half of what {@link ComparisonOperatorSchema} + // declares — and unlike that field-agnostic schema, `T` is known here, so + // this stays type-precise instead of admitting `string` everywhere: + // - a `Date` field also takes the ISO STRING the date-macro resolver + // produces (`{ close_date: { $gte: '2026-01-01' } }`), which the old + // `T[K] extends number | Date ? T[K]` guard rejected outright; + // - a `string` field (a `Field.time` `'09:00'`, an autonumber code) is + // orderable at every backend, where the old guard collapsed it to + // `never` and made the operator unwritable; + // - a `number` field stays numbers-only — nothing here wants `'5'`. + $gt?: T[K] extends number ? number : T[K] extends Date | string ? T[K] | string : never; + $gte?: T[K] extends number ? number : T[K] extends Date | string ? T[K] | string : never; + $lt?: T[K] extends number ? number : T[K] extends Date | string ? T[K] | string : never; + $lte?: T[K] extends number ? number : T[K] extends Date | string ? T[K] | string : never; $in?: T[K][]; $nin?: T[K][]; $between?: T[K] extends number | Date ? [T[K], T[K]] : never; From ab46537bd3864a1fe3879846e38f58bb1322cd0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 05:28:00 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(objectql,spec):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=20#5685=20=E5=8A=A0=E5=AE=BD=E5=90=8E=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E7=9A=84=E4=B8=A4=E5=A4=84=E6=B3=A8=E9=87=8A,=E5=B9=B6?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=20objectql=20=E7=9A=84=E4=BD=90=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 #5685 时在 `packages/objectql` 发现**两处注释直接引用了被本 PR 改掉的声明**, 不同步就会让仓库自相矛盾 —— 正是本 issue 要终结的那类 declared ≠ documented 腐坏。 纯注释,零代码行改动(`git diff` 过滤后无非注释行)。 ## 为什么这不是范围外夹带 不是"顺手修别的 bug",是**本 PR 自己造成的失效**:两处注释都把 `$gt` 的声明原文写进了正文,而本 PR 把那份声明改了。 - `packages/objectql/src/filter-comparand-shape.ts:214-218`(#5869)原文写: 「`FieldOperatorsSchema` cannot be used as the gate directly because it is stricter than the runtime in ways the runtime deliberately allows —— `$gt` is declared `number | Date | FieldReference`, while `['created_at', '>', '2026-01-01']` lowers to a STRING bound that every backend accepts and that the showcase apps rely on」。加宽后这句话为假。 改法:保留该 gate 不整表 parse 的**成本**理由(它每次读写都跑),把已被 修正的"schema 比运行时严"那条降级为 #5685 的历史记录 —— 记录而非删除, 因为这处 workaround 本身就是结论"错的是 schema 不是运行时"的证据。 - `packages/objectql/src/engine-filter-array-lowering.test.ts:520-523` 同样引用 旧联合,同步为 `number|Date|string|FieldReference`,并点明该 gate 仍只管三个 list 声明是出于成本、而非"schema 不同意"。 ## 反向补强 spec 侧 docblock objectql 这处 workaround 同时是 rider ① 需要的**实测**证据(而非断言): 另一个包为绕开本声明而构建,并白纸黑字写下它是错的,且点名 **showcase apps 依赖 ISO 字符串** —— 这是 issue 与 PR 都未列出的**第四个** 一方生产者,也是"真实业务拉力"这一轴上可测量的读数。已并入 `ComparisonOperatorSchema` docblock。 ## 验证读数 - `packages/objectql` 受影响用例:`engine-filter-array-lowering.test.ts` **46 passed (46)**(先 `pnpm --filter '@objectstack/objectql^...' build` 备齐依赖)。 - `packages/spec`:`filter.test.ts` **106/106 绿**;`check:generated` 仍 **10/10 up to date**(注释不入产物)。 - eslint 三个改动文件 exit 0;`check:nul-bytes` OK(6133 文件)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .../src/engine-filter-array-lowering.test.ts | 10 +++++---- .../objectql/src/filter-comparand-shape.ts | 22 ++++++++++++------- packages/spec/src/data/filter.zod.ts | 10 +++++++++ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/objectql/src/engine-filter-array-lowering.test.ts b/packages/objectql/src/engine-filter-array-lowering.test.ts index 7382910561..c573f5be20 100644 --- a/packages/objectql/src/engine-filter-array-lowering.test.ts +++ b/packages/objectql/src/engine-filter-array-lowering.test.ts @@ -517,10 +517,12 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158) it('a scalar on a NON-collection operator is untouched', async () => { await engine.find('deal', asFilterArrayQuery([['stage', '!=', 'won']])); expect(lastWhere()).toEqual({ stage: { $ne: 'won' } }); - // String bounds on a range comparison stay legal — `FieldOperatorsSchema` - // declares `$gt` as number|Date|FieldReference, but ISO strings are what the - // showcase apps send and every backend accepts. This gate enforces the - // three list declarations, not the whole schema. + // String bounds on a range comparison stay legal, and since #5685 the + // declaration agrees: `FieldOperatorsSchema` now declares `$gt` as + // number|Date|string|FieldReference, matching the ISO strings the showcase + // apps send and every backend accepts. This gate still enforces only the + // three list declarations, not the whole schema — for cost, not because the + // schema disagrees. await engine.find('deal', asFilterArrayQuery([['stage', '>', '2026-01-01']])); expect(lastWhere()).toEqual({ stage: { $gt: '2026-01-01' } }); }); diff --git a/packages/objectql/src/filter-comparand-shape.ts b/packages/objectql/src/filter-comparand-shape.ts index 38b8578aad..29a828f1f6 100644 --- a/packages/objectql/src/filter-comparand-shape.ts +++ b/packages/objectql/src/filter-comparand-shape.ts @@ -210,14 +210,20 @@ function malformedRangeComparandError( * * Read-only and allocation-free on the overwhelmingly common path (a filter * with no list operator walks its own keys and returns). Runs on every engine - * read and write, so it stays a walk rather than a schema parse: - * `FieldOperatorsSchema` cannot be used as the gate directly because it is - * stricter than the runtime in ways the runtime deliberately allows — `$gt` is - * declared `number | Date | FieldReference`, while `['created_at', '>', - * '2026-01-01']` lowers to a STRING bound that every backend accepts and that - * the showcase apps rely on. Enforcing the whole schema here would refuse - * working queries; this gate enforces the three declarations that the drivers - * genuinely cannot agree on. + * read and write, so it stays a walk rather than a schema parse — that cost is + * now the whole reason, and this gate deliberately enforces only the three + * list declarations the drivers genuinely cannot agree on. + * + * [#5685] This paragraph used to carry a second reason: that + * `FieldOperatorsSchema` was "stricter than the runtime in ways the runtime + * deliberately allows", because `$gt` was declared `number | Date | + * FieldReference` while `['created_at', '>', '2026-01-01']` lowers to a STRING + * bound that every backend accepts and the showcase apps rely on. That was a + * real mismatch and it is **fixed at the source** rather than tolerated here: + * the four ordering slots now declare `string` too, so the observation that + * motivated this note no longer describes the schema. It is recorded rather + * than deleted because this file's workaround is part of the evidence that + * closed #5685 — the schema, not the runtime, was the wrong side. */ export function assertListComparandShapes( object: string, diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index b922e2ce67..da7db126c9 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -133,6 +133,16 @@ const ORDERING_COMPARAND_DESCRIPTION = * `plugin-auth`'s better-auth adapter, which lowers a `gt`/`gte`/`lt`/`lte` * clause with the producer's own untyped `condition.value`. * + * The mismatch was already COSTING something, and the receipt is in the tree: + * `@objectstack/objectql`'s `filter-comparand-shape.ts` (#5869) had to state, + * as its reason for not using this schema as its gate, that the schema is + * "stricter than the runtime in ways the runtime deliberately allows — `$gt` is + * declared `number | Date | FieldReference`, while `['created_at', '>', + * '2026-01-01']` lowers to a STRING bound that every backend accepts and that + * **the showcase apps rely on**". A second package building around this + * declaration, and writing down that it is wrong, is the measurement that says + * the pull is real rather than hypothetical. + * * An author — an AI author in particular — reading `number | Date` concluded * that a date window must be a `Date` object or an epoch number, which is the * one form the platform's own date-macro path can never hand them. This is the