结论 Field.time 是 Field.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 的三处一一对应:
formatInput 没有 timeFields 分支。datetimeFields、dateFields 各有一段规范化,timeFields 没有 —— 绑什么就存什么。temporalFieldKind() 只返回 'datetime' | 'date'。 因此 coerceFilterValue() 对 time 字段原样返回比较值;temporalFilterValue() 直接委托给它,所以 analytics 的 native-SQL 路径同样没有 time 这一档。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:30text 14:30'14:30:00'14:30:00text 14:30:00'14:30:00.500'14:30:00.500text 14:30:00.500'2026-01-15T14:30:00.500Z'2026-01-15T14:30:00.500Ztext 14:30:00.500'2026-01-15 14:30:00'2026-01-15 14:30:00text 14:30:00new Date(...)1768487400500integer 14:30:0017684874005001768487400500integer 14:30:00
七行在界面上全部显示为 14:30,全部落在营业时间内。然后:
await driver . 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) 写入形态 SQLite PostgreSQL MySQL full-ISO 字符串 ✅ 存原文 ❌ invalid input syntax for type time ❌ Incorrect time value epoch number ✅ 存 INTEGER ❌ invalid input syntax for type time ❌ Incorrect 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读回 SQLite TEXT — 14:30:00.500PostgreSQL time without time zone6 14:30:00.5MySQL time0 14:30:00
createColumn 走的是 table.time(name),MySQL 上就是零位小数的 TIME。这与 #3942 里 TIMESTAMP 没有毫秒、最终改用 DATETIME(3) 的问题完全同构,对应的解法是 table.time(name, { precision: 3 })。顺带一提 PG 返回 .5 而 SQLite 返回 .500,跨方言呈现也不一致。
F5 — defaultValue: 'NOW()' 在三个方言上是三个不同的钟(P2) 同一时刻(UTC 01:58)实测:
DDL 默认值 读回 语义 SQLite strftime('%H:%M:%f','now')01:55:08.467UTC PostgreSQL CURRENT_TIMESTAMP09:57:24.044175服务器 TimeZone (Asia/Shanghai)MySQL current_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,而:
await driver . 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:30、14:30:00、14:30:00.500、2026-01-15T14:30:00.500Z、1768487400500 五个「不同的值」,而它们是同一个时刻。#3797 / #3849 修的就是这个,readPresentationKind 当时没加 'time'。
F7 — toTimeOnly 对毫秒的处理不自洽(P3) Date / epoch 走 .slice(11, 19) 丢掉毫秒,字符串分支的正则却保留毫秒。所以同一个时刻,存成 Date 读回 14:30:00,存成 ISO 字符串读回 14:30:00.500。
建议的修法 与 #3912 → #3928 → #3942 那条线保持同构,不要再发明第二套机制:
定一个存储约定并写进 ADR-0053 (目前 ADR 完全没提 Field.time)。自然的选择是 HH:MM:SS.sss 的 tz-naive 时刻,与 Field.date 的 YYYY-MM-DD 对称 —— 字典序即时间序,可走索引。formatInput 增加 timeFields 分支 ,用一个 canonicalTimeOfDay() 把所有已接受的输入形态(HH:MM、HH:MM:SS、full-ISO、naive timestamp、Date、epoch)折叠成该形态。注意 Date/epoch → 取哪个时区的 wall clock 需要显式决策(建议 UTC,与 SQLite 现有默认一致),并写进 ADR。temporalFieldKind() 增加 'time' ,coerceFilterValue 对其应用同一个函数 —— 于是 temporalFilterValue() 自动覆盖 analytics 路径,这正是 feat(spec,ci): temporal hooks onto the IDataDriver contract; conformance job with live non-UTC servers #3979 把这对钩子提上 IDataDriver 契约的意义。readPresentationKind() 增加 'time' ,presentReadValue 复用 toTimeOnly,补上 F6/F7。MySQL 用 table.time(name, { precision: 3 }) (对应 F4),并统一 nowColumnDefault 在非 SQLite 上的 time 语义(对应 F5)。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 三条路径对比。
结论
Field.time是Field.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-conformancejob 同样的非 UTC 条件)。代码层面的根因
三处,与 #3912 的三处一一对应:
formatInput没有timeFields分支。datetimeFields、dateFields各有一段规范化,timeFields没有 —— 绑什么就存什么。temporalFieldKind()只返回'datetime' | 'date'。 因此coerceFilterValue()对 time 字段原样返回比较值;temporalFilterValue()直接委托给它,所以 analytics 的 native-SQL 路径同样没有 time 这一档。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 种存储形态:
find()读回'14:30'14:3014:30'14:30:00'14:30:0014:30:00'14:30:00.500'14:30:00.50014:30:00.500'2026-01-15T14:30:00.500Z'2026-01-15T14:30:00.500Z14:30:00.500'2026-01-15 14:30:00'2026-01-15 14:30:0014:30:00new Date(...)176848740050014:30:001768487400500176848740050014:30:00七行在界面上全部显示为 14:30,全部落在营业时间内。然后:
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)
14:30 排在 08:00 前面。所有 INTEGER 行先于所有 TEXT 行,TEXT 行内部按字典序 —— 与 #3928 修掉的 datetime 排序问题同构。
F3 — 同一个 payload 在不同方言上有的成功有的抛异常(P2)
invalid input syntax for type timeIncorrect time valueinvalid input syntax for type timeIncorrect time valueDate14:30:00full-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_precision14:30:00.500time without time zone14:30:00.5time14:30:00createColumn走的是table.time(name),MySQL 上就是零位小数的TIME。这与 #3942 里TIMESTAMP没有毫秒、最终改用DATETIME(3)的问题完全同构,对应的解法是table.time(name, { precision: 3 })。顺带一提 PG 返回.5而 SQLite 返回.500,跨方言呈现也不一致。F5 —
defaultValue: 'NOW()'在三个方言上是三个不同的钟(P2)同一时刻(UTC 01:58)实测:
strftime('%H:%M:%f','now')01:55:08.467CURRENT_TIMESTAMP09:57:24.044175TimeZone(Asia/Shanghai)current_timestamp()01:57:53MySQL 那一格尤其值得注意: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,而:裸 epoch number 和两个完整时间戳直接进了返回值。任何拿
distinct做筛选器候选项的 UI 会同时列出14:30、14:30:00、14:30:00.500、2026-01-15T14:30:00.500Z、1768487400500五个「不同的值」,而它们是同一个时刻。#3797 / #3849 修的就是这个,readPresentationKind当时没加'time'。F7 —
toTimeOnly对毫秒的处理不自洽(P3)Date/ epoch 走.slice(11, 19)丢掉毫秒,字符串分支的正则却保留毫秒。所以同一个时刻,存成Date读回14:30:00,存成 ISO 字符串读回14:30:00.500。建议的修法
与 #3912 → #3928 → #3942 那条线保持同构,不要再发明第二套机制:
Field.time)。自然的选择是HH:MM:SS.sss的 tz-naive 时刻,与Field.date的YYYY-MM-DD对称 —— 字典序即时间序,可走索引。formatInput增加timeFields分支,用一个canonicalTimeOfDay()把所有已接受的输入形态(HH:MM、HH:MM:SS、full-ISO、naive timestamp、Date、epoch)折叠成该形态。注意Date/epoch → 取哪个时区的 wall clock 需要显式决策(建议 UTC,与 SQLite 现有默认一致),并写进 ADR。temporalFieldKind()增加'time',coerceFilterValue对其应用同一个函数 —— 于是temporalFilterValue()自动覆盖 analytics 路径,这正是 feat(spec,ci): temporal hooks onto the IDataDriver contract; conformance job with live non-UTC servers #3979 把这对钩子提上IDataDriver契约的意义。readPresentationKind()增加'time',presentReadValue复用toTimeOnly,补上 F6/F7。table.time(name, { precision: 3 })(对应 F4),并统一nowColumnDefault在非 SQLite 上的 time 语义(对应 F5)。backfillCanonicalDatetimes/previewDeferredSchemaWork机制,在os migrate plan里作为一条normalize_time_storage的 in-place 工作列出(os migrate planomits 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三条路径对比。