Skip to content

Field.time repeats the #3912 pattern: writes unnormalised, repaired only on read — window filters and ORDER BY are silently wrong on SQLite #3994

Description

@os-zhuang

结论

Field.timeField.datetime#3912 之前的完整翻版:写入不规范化、只在读时修补。而且因为读时修补做得很好,find() / findOne() 看起来永远是对的 —— 这恰恰是它一直没被发现的原因。一旦离开读路径(过滤、排序、distinct/aggregate),存储形态的漂移就直接暴露成错误结果。

toTimeOnly 的 docblock 自己写着「read-only, so no write/read asymmetry is introduced」。这句话在 formatOutput 的范围内成立,但它默认了没有别的路径会看到原始存储形态——而过滤、排序、distinct 三条路径都会。

以下全部是在真实数据库上实测的,不是推理:SQLite(better-sqlite3)、PostgreSQL 16.13 @ TimeZone=Asia/Shanghai、MariaDB 10.11 @ time_zone=+08:00,Node 进程 TZ=America/New_York(与 #3979 引入的 temporal-conformance job 同样的非 UTC 条件)。

代码层面的根因

三处,与 #3912 的三处一一对应:

  1. formatInput 没有 timeFields 分支。datetimeFieldsdateFields 各有一段规范化,timeFields 没有 —— 绑什么就存什么。
  2. temporalFieldKind() 只返回 'datetime' | 'date' 因此 coerceFilterValue() 对 time 字段原样返回比较值;temporalFilterValue() 直接委托给它,所以 analytics 的 native-SQL 路径同样没有 time 这一档。
  3. readPresentationKind() 没有 'time'distinct() / aggregate() 因此拿到的是裸存储值,与 find() 的呈现不一致 —— 正是 aggregate()/distinct() 不过 formatOutput —— SQLite 上 Field.datetime 的裸 epoch 从聚合出口漏出去(#3773 同根不同出口) #3797 / 非空分组桶的键两条路也不一致:下推 SQL 保留原类型,内存兜底 String() 强转(布尔是语义分歧,不只是类型) #3849 修过的那类不一致,当时没有推广到 time。

另外 ADR-0053 通篇没有出现过 Field.time这个字段类型至今没有任何存储约定,这正是 #3912 的元问题。

实测结果

F1 — 窗口过滤静默丢行(P1,SQLite)

一列里实测出现 5 种存储形态:

写入SQLite 存储存储类find() 读回
'14:30'14:30text14:30
'14:30:00'14:30:00text14:30:00
'14:30:00.500'14:30:00.500text14:30:00.500
'2026-01-15T14:30:00.500Z'2026-01-15T14:30:00.500Ztext14:30:00.500
'2026-01-15 14:30:00'2026-01-15 14:30:00text14:30:00
new Date(...)1768487400500integer14:30:00
17684874005001768487400500integer14:30:00

七行在界面上全部显示为 14:30,全部落在营业时间内。然后:

awaitdriver.find('shift',{where: {starts_at: {$gte: '09:00:00',$lte: '18:00:00'}}})// 实测命中: ["s_hm","s_hms","s_ms"]// 应该命中: 上表全部 7 行

7 行里丢了 4 行。 机制和 #3912 逐字相同:Date/epoch 存成 INTEGER,而 SQLite 的类型序是 INTEGER < TEXT>= '09:00:00' 恒假;full-ISO / naive-timestamp 存成以 '2026-' 开头的 TEXT,字典序上 > '18:00:00'<= '18:00:00' 恒假。

对用户而言就是:列表里明明有这条 14:30 的排班,一加时间段筛选就没了。

F2 — ORDER BY 排序错误(P1,SQLite)

order by starts_at asc 实测:
s_date 14:30:00 ← INTEGER 行全部排在最前
s_epoch 14:30:00
s_early 08:00:00
s_hm 14:30
...

14:30 排在 08:00 前面。所有 INTEGER 行先于所有 TEXT 行,TEXT 行内部按字典序 —— 与 #3928 修掉的 datetime 排序问题同构。

F3 — 同一个 payload 在不同方言上有的成功有的抛异常(P2)

写入形态SQLitePostgreSQLMySQL
full-ISO 字符串✅ 存原文invalid input syntax for type timeIncorrect time value
epoch number✅ 存 INTEGERinvalid input syntax for type timeIncorrect time value
JS Date✅ 存 INTEGER❌ 抛异常✅ 存 14:30:00

full-ISO 字符串正是 REST/JSON 客户端最自然的写法。同一份请求体,SQLite 开发环境通过,PG 生产环境 500。

Date 那一行还有个更隐蔽的问题:PG 报错信息里的实际绑定值是 2026-01-15T09:30:00.500-05:00 —— pg 驱动按进程本地时区America/New_York)序列化了这个 Date,14:30 UTC 已经先变成了 09:30。就算 PG 接受了这个语法,存进去的 time-of-day 也取决于 Node 主机的 TZ。MySQL 之所以对,是因为 #3942 把 mysql2 的 session 钉在了 UTC —— 是顺带救下来的,不是设计。

F4 — 精度是方言定义的,MySQL 上直接丢失(P2)

写入 '14:30:00.500'

物理类型datetime_precision读回
SQLiteTEXT14:30:00.500
PostgreSQLtime without time zone614:30:00.5
MySQLtime014:30:00

createColumn 走的是 table.time(name),MySQL 上就是零位小数的 TIME。这与 #3942TIMESTAMP 没有毫秒、最终改用 DATETIME(3) 的问题完全同构,对应的解法是 table.time(name, { precision: 3 })。顺带一提 PG 返回 .5 而 SQLite 返回 .500,跨方言呈现也不一致。

F5 — defaultValue: 'NOW()' 在三个方言上是三个不同的钟(P2)

同一时刻(UTC 01:58)实测:

DDL 默认值读回语义
SQLitestrftime('%H:%M:%f','now')01:55:08.467UTC
PostgreSQLCURRENT_TIMESTAMP09:57:24.044175服务器 TimeZone(Asia/Shanghai)
MySQLcurrent_timestamp()01:57:53执行 INSERT 那个 session 的时区

MySQL 那一格尤其值得注意:driver 自己的 session 被 #3942 钉成了 UTC,所以 driver 写入时是 UTC;但任何别的客户端(psql/mysql CLI、另一个应用)向同一列插入时拿到的是服务器本地时间。同一列的默认值取决于谁插的。

nowColumnDefault() 的 docblock 只推理了 SQLite 分支,!this.isSqlite 直接落到 knex.fn.now(),没有考虑它落在 TIME 列上意味着什么。

F6 — distinct() / aggregate() 泄漏裸存储值(P3)

同一张表,find() 统一呈现为 14:30:00,而:

awaitdriver.distinct('shift','starts_at')// ["14:30","14:30:00","14:30:00.500","2026-01-15T14:30:00.500Z","2026-01-15 14:30:00",1768487400500,"08:00:00"]

裸 epoch number 和两个完整时间戳直接进了返回值。任何拿 distinct 做筛选器候选项的 UI 会同时列出 14:3014:30:0014:30:00.5002026-01-15T14:30:00.500Z1768487400500 五个「不同的值」,而它们是同一个时刻。#3797 / #3849 修的就是这个,readPresentationKind 当时没加 'time'

F7 — toTimeOnly 对毫秒的处理不自洽(P3)

Date / epoch 走 .slice(11, 19) 丢掉毫秒,字符串分支的正则却保留毫秒。所以同一个时刻,存成 Date 读回 14:30:00,存成 ISO 字符串读回 14:30:00.500

建议的修法

#3912#3928#3942 那条线保持同构,不要再发明第二套机制:

  1. 定一个存储约定并写进 ADR-0053(目前 ADR 完全没提 Field.time)。自然的选择是 HH:MM:SS.sss 的 tz-naive 时刻,与 Field.dateYYYY-MM-DD 对称 —— 字典序即时间序,可走索引。
  2. formatInput 增加 timeFields 分支,用一个 canonicalTimeOfDay() 把所有已接受的输入形态(HH:MMHH:MM:SS、full-ISO、naive timestamp、Date、epoch)折叠成该形态。注意 Date/epoch → 取哪个时区的 wall clock 需要显式决策(建议 UTC,与 SQLite 现有默认一致),并写进 ADR。
  3. temporalFieldKind() 增加 'time'coerceFilterValue 对其应用同一个函数 —— 于是 temporalFilterValue() 自动覆盖 analytics 路径,这正是 feat(spec,ci): temporal hooks onto the IDataDriver contract; conformance job with live non-UTC servers #3979 把这对钩子提上 IDataDriver 契约的意义。
  4. readPresentationKind() 增加 'time'presentReadValue 复用 toTimeOnly,补上 F6/F7。
  5. MySQL 用 table.time(name, { precision: 3 })(对应 F4),并统一 nowColumnDefault 在非 SQLite 上的 time 语义(对应 F5)。
  6. SQLite 存量列就地回填,复用 SQLite datetime ORDER BY is wrong on mixed-storage columns: INTEGER-epoch rows always sort before ISO-TEXT rows #3928 已有的 backfillCanonicalDatetimes / previewDeferredSchemaWork 机制,在 os migrate plan 里作为一条 normalize_time_storage 的 in-place 工作列出(os migrate plan omits the datetime storage-convergence work, so the plan understates what apply will do #3954 已经把渲染框架搭好了)。

第 6 步同样修不了「旧写入路径本来就记错了时刻」的行 —— 与 #3928 一样,回填只能保证盘上已有的值不动、形态统一,不能还原一个从没被正确记录过的 wall clock。

复现

探针脚本(SQLite / PG / MySQL 三方言参数化)与上述全部数据可复现;核心是声明一个 { starts_at: { type: 'time' } } 对象,按上表 7 种形态写入,然后跑 where/orderBy/distinct 三条路径对比。

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions