fix(spec): $gt/$gte/$lt/$lte 接受平台自己产出的 ISO 字符串 (#5685) - #6570
Merged
Conversation
四个排序比较槽位声明为 `number | Date | FieldReference`,而平台自己的生产者 往这四个槽位里放的恰恰只有字符串。这不是"声明得不够细",是声明与现实相矛盾。 ## 前提复核(origin/main @ 02479cc,逐条核对 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<T>` 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 115 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
实现 #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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5685
四个排序比较槽位声明为
number | Date | FieldReference,而平台自己的生产者往这四个槽位里放的恰恰只有字符串。这不是「声明得不够细」,是声明与现实相矛盾。前提复核(origin/main @
02479cc91,逐条核对 issue 事实)filter.zod.ts:92起四槽位为z.union([z.number(), z.date(), FieldReferenceSchema]),无stringfilter-tokens.ts:303 case 'now': return now.toISOString()、:319 addUnits(...).toISOString()、:304-306/321 asYmd(...),无任何分支返回 number 或 Datedate-macros.zod.ts明写 driver 只见 ISO 串.toISOString()lifecycle-service.ts:1105 / 1134-1135 / 1195 / 1211、plugin-email/src/outbox-sweep.ts:155,160前提全部成立(
premise_still_valid: true),方向按分诊结论执行:声明侧对齐现实,不动生产者与调用方。复核中另找到 issue 未列的第三、第四个一方生产者:
plugin-auth/src/objectql-adapter.ts:206-215把 better-auth 未定型的condition.value直接下沉进这四个槽位;@objectstack/objectql的filter-comparand-shape.ts(数据 API:集合算子not_in/in的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869)已经为绕开本声明而构建,并白纸黑字写下它是错的:「FieldOperatorsSchemacannot be used as the gate directly because it is stricter than the runtime in ways the runtime deliberately allows ——$gtis declarednumber | Date | FieldReference, while['created_at', '>', '2026-01-01']lowers to a STRING bound that every backend accepts and that the showcase apps rely on」。第二条尤其要紧:它是「真实业务拉力」这一轴上可测量的读数,而非断言 —— 另一个包为此付出了 workaround 的代价,且点名 showcase apps 依赖 ISO 字符串。
改动
string并入四个槽位的联合,并入本文件声明该契约的全部三处:ComparisonOperatorSchema—— 文档面(issue 点名处)。补长 docblock + 每槽位.describe()。FieldOperatorsSchema—— 被强制执行的那一份:NormalizedFilterSchema拿它校验,导出类型FieldOperators由它推断。只改文档面会留下「文档说可以、可达面仍拒绝」的分裂,故一并对齐。这是本 PR 唯一超出 issue 字面点名的范围扩张,理由即此:同一文件、同四个算子、同一份契约的两种拼写。Filter< T >TS 泛型 —— 此处T已知,故保持类型精确而非一律放行:Date字段兼收解析器产出的 ISO 串;string字段(Field.time的'09:00'、autonumber 编码)可排序,而非塌成never;number字段仍只收数字。纯声明侧加宽,additive:未改任何生产者/调用方/driver。各求值面本来就在比较字符串(driver-sql 绑
>/>=/</<=;formula 的matchesFilter与 driver-memory 的 matcher 落到 JS 运算符),改前能过的过滤器改后一律仍能过。第二个 commit:同步被本 PR 改失效的两处注释(纯注释,零代码行)
上面那处 objectql workaround 的注释把旧声明原文写进了正文,加宽后那句话为假。不同步就会让仓库自相矛盾 —— 正是本 issue 要终结的那类腐坏,且是本 PR 自己造成的失效,不是顺手修别的 bug:
objectql/src/filter-comparand-shape.ts—— 保留该 gate 不整表 parse 的成本理由(它每次读写都跑),把已被修正的「schema 比运行时严」降级为 ComparisonOperatorSchema 的 $gt/$gte/$lt/$lte 不含 string,与平台自己只产出字符串的日期宏解析器相矛盾 #5685 的历史记录;记录而非删除,因为这处 workaround 本身就是「错的是 schema 不是运行时」的证据。objectql/src/engine-filter-array-lowering.test.ts—— 同步旧联合引用。git diff过滤后该 commit 无任何非注释行。$between(RangeOperatorSchema与FieldOperatorsSchema.$between)带同一处矛盾,但属另一个算子、不在 issue 的测量范围内,按 PD#10 另行记录为 #6571,未夹带修改。rider ① 裸
z.string()vs 受约束日期串 —— 实测后取裸z.string()先与 driver-sql 比较语义对读:四个算子在
sql-driver.ts:7287-7297下沉为朴素>/>=/</<=,比较值先过coerceFilterValue(:6408)。三条实测理由:coerceFilterValue按temporalFieldKind分派 —— datetime →storageDatetimeValue,date →toDateOnly,time →canonicalTimeOfDay,其余透传。field-value.zod.ts的CLOCK_TIME_TYPES定义Field.time值为HH:MM[:SS[.fff]],并明写 "notDate.parse-able";SqlDriver.temporalFilterValue正是在比较值位置规范化它('14:30'变成'14:30:00',feat(spec,ci): temporal hooks onto the IDataDriver contract; conformance job with live non-UTC servers #3979 契约对,pin 在sql-driver-time-canonical-storage.test.ts:221-222)。$gte: '09:00'是受支持的比较,ISO refine 会拒绝它。YYYY-MM-DD作下界锚定 UTC 午夜,作上界由calendarDayUpperBoundRewrite改写为半开的< 次日午夜(dashboard 的日期区间上界打在datetime列上丢失当天数据 —— 默认配置即命中 #3777 约定)。放行面据实写入
.describe()与 docblock。 加宽同时放行非时间列的文本排序({ code: { $gt: 'M' } })。这是真 SQL 且各后端都会作答,但次序是后端的、不是本契约的:driver-sql 交给方言排序规则(SQLite 按字节、Postgres 按库 locale、MySQL 按列 collation),formula / driver-memory 用 JS 的 UTF-16 码元序;二者仅在 ASCII 上重合 —— 与StringOperatorSchema当年不得不裁决的大小写分裂是同一类。故契约保证的比较值形态是 ISO/时钟那三种:
YYYY-MM-DD、UTC ISO-8601 瞬间、HH:MM[:SS[.fff]]。它们 ASCII 定宽,字典序即时间序,各后端一致。排序任意自然语言文本是放行而非承诺。否决窗口:若维护者认为「放行非日期文本排序」代价过高,替代方案是收窄为
ISO date | ISO date-time | HH:MM 时钟三选一的具名 schema —— 代价是plugin-auth的泛型下沉、objectql/showcase 依赖的字符串界、以及任何 autonumber/文本列排序全部落到声明面之外,且需按 ADR-0122 走 PARSED 命名或 isomorphic pin(计数 755 会变)。本 PR 未走这条,理由如上第 1、2 条。rider ② changeset
.changeset/comparison-operator-string-comparand.md,@objectstack/spec: minor—— 加宽接受面属 additive,按仓内惯例走 minor。(本 PR 有 changeset,故不需要skip-changeset标签;Check Changeset已绿。)验证读数(全部前台阻塞,
flock串行)pnpm --filter @objectstack/spec test→Test Files 343 passed (343)/Tests 8830 passed (8830),408.53s。engine-filter-array-lowering.test.ts→ 46 passed (46)(先pnpm --filter '@objectstack/objectql^...' build备齐依赖,避免把缺产物读成自己改坏了导入)。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:generated10/10 up to date;check:docs231 files in sync;check:api-surface、check:authorable-surface均 exit 0。check:spec-parsed-aliasOK(1465 bare / 755 pinned isomorphic / 710 paired)—— 本改动未新增具名 schema,ADR-0122 计数不动,type-alias-convention.pin.test.ts:1501的toHaveLength(755)无需改;check:nul-bytesOK(6133 文件,无裸控制字节);check:empty-changesetOK(1 declaring changeset);eslint 三个改动文件 exit 0。authorable-surface.base.json已撤回:gen:authorable-surface-base产出的 diff 全是他人的键(api/ValidateData*、cloud/ProvisionEnvironmentResponse:hostnameAssignment),是锚点追赶 main,与本改动无关。生成器与check:authorable-surface都写明「重锚是需要独立 review 的刻意动作,绝非本次构建的副作用」(check:authorable-surface在--check模式下也会重写authorable-surface.base.json—— 一次纯核验会改工作区,且任何无关 PR 都能因此静默推进删除门的锚点 #5358)。本改动不新增 authorable 键。逆向验证(方向先判后跑)
判定:本改动是加宽,新测试钉的是「新被接受」的值,故预期为常规方向 —— 撤掉 schema 改动则新测试转红。实测与预判一致。
filter.zod.ts、保留新测试:filter.test.ts→5 failed | 101 passed (106),报错正是"Invalid input: expected number, received string"/"expected date, received string"。101 个既有断言仍绿 —— 这同时证明加宽是 additive,没有改变任何原有判定。tsc而非 vitest 裁定(vitest 不做类型检查),故单独跑tsc -p tsconfig.test.json,得两条、均落在新增断言块内:undefined而非never:旧 guard 的never撞上槽位自身的?,optional 的never即undefined。测试注释已按实测原文更正(此前按预期写的是never)—— 报告模板不该压过真实读数。filter.test.ts106/106 绿,typecheck 全绿。