SqlDriver.aggregate() 和 distinct() 都直接 return await builder,不过 formatOutput 。所以在 SQLite 上,Field.datetime 列的原始存储形态(INTEGER epoch 毫秒)会从这两个出口原样漏给调用方 —— 而 find() 同一列返回的是规范化的 ISO-Z。
实测(better-sqlite3,一行 closed_at = 2026-01-10T09:00:00Z):
路径
返回
find()
"2026-01-10T09:00:00.000Z" ✅
distinct('closed_at')
[1768035600000] ❌ 裸 epoch
aggregate() max(closed_at) as latest
1768035600000 ❌
aggregate() groupBy: ['closed_at'](无 granularity)
{closed_at: 1768035600000} ❌
Field.date 四条全对 —— 它在每个方言上都是 ISO TEXT,存储形态本来就等于呈现形态。和 #3773 是同一个根因的不同出口 :凡是绕过 formatOutput 的读路径,在唯一「存储 ≠ 呈现」的方言上都会漏。
可达面
min/max 度量 :analytics 的 inferMeasure 认 _min/_max 后缀(analytics-service.ts:952),所以 closed_at_max 这样的「最近成交时间」KPI 磁贴在 SQLite 上会显示 1768035600000。
无 granularity 的 groupBy :桶键是裸 epoch。这同时是一条 parity 漂移 —— 内存兜底 applyInMemoryAggregation 吃的是 driver.find() 的行(已经 formatOutput 过),它给的是 ISO 字符串。同一个数据集换条路走,桶键类型都不一样,drill-down 跨不过去。这正是 SQLite 上 Field.datetime 的 dateGranularity 分桶恒为 NULL —— 趋势图塌成一根柱子 #3773 刚为 date-bucket 那一半钉下的契约。
distinct() :扫了全仓,零调用方 (除驱动自身测试)。漏是真漏,但没有用户可达路径 —— 顺手修保持一致即可,不构成紧急度。
修法上的一个陷阱
不能简单地「对着输出行跑一遍 formatOutput」,也不能按列名匹配声明字段:
所以规范化必须由**「这一列是什么产生的」**驱动 —— 在建查询时就记下每个输出列的语义,而不是事后按名字猜。
SqlDriver.aggregate()和distinct()都直接return await builder,不过formatOutput。所以在 SQLite 上,Field.datetime列的原始存储形态(INTEGER epoch 毫秒)会从这两个出口原样漏给调用方 —— 而find()同一列返回的是规范化的 ISO-Z。实测(better-sqlite3,一行
closed_at = 2026-01-10T09:00:00Z):find()"2026-01-10T09:00:00.000Z"✅distinct('closed_at')[1768035600000]❌ 裸 epochaggregate()max(closed_at) as latest1768035600000❌aggregate()groupBy: ['closed_at'](无 granularity){closed_at: 1768035600000}❌Field.date四条全对 —— 它在每个方言上都是 ISO TEXT,存储形态本来就等于呈现形态。和 #3773 是同一个根因的不同出口:凡是绕过formatOutput的读路径,在唯一「存储 ≠ 呈现」的方言上都会漏。可达面
inferMeasure认_min/_max后缀(analytics-service.ts:952),所以closed_at_max这样的「最近成交时间」KPI 磁贴在 SQLite 上会显示1768035600000。groupBy:桶键是裸 epoch。这同时是一条 parity 漂移 —— 内存兜底applyInMemoryAggregation吃的是driver.find()的行(已经formatOutput过),它给的是 ISO 字符串。同一个数据集换条路走,桶键类型都不一样,drill-down 跨不过去。这正是 SQLite 上Field.datetime的 dateGranularity 分桶恒为 NULL —— 趋势图塌成一根柱子 #3773 刚为 date-bucket 那一半钉下的契约。distinct():扫了全仓,零调用方(除驱动自身测试)。漏是真漏,但没有用户可达路径 —— 顺手修保持一致即可,不构成紧急度。修法上的一个陷阱
不能简单地「对着输出行跑一遍
formatOutput」,也不能按列名匹配声明字段:Field.datetime的 dateGranularity 分桶恒为 NULL —— 趋势图塌成一根柱子 #3773 之后,带dateGranularity的分桶表达式是 aliased AS 字段名的,它的值是标签('2026-01')而不是实例。按名字规范化会把标签喂进normalizeSqliteDatetimeOutput。min/max的输出列叫alias(如latest),不叫closed_at。按名字匹配根本够不着它。所以规范化必须由**「这一列是什么产生的」**驱动 —— 在建查询时就记下每个输出列的语义,而不是事后按名字猜。