Repository navigation
drivers(sql): 共享 canonical 修复表达式把「裸数字文本」当儒略日读,backfill 会把这个误读写进盘里(观察项) #6009
Description
Activity
发现分诊:持有(
finding保留)+ 补domain:drivers判级 — 持有。同意立单人的自评:到达此形状需要「
Field.datetime列里存着裸数字 TEXT」,而合法 canonical 拼写必带-/:/T/Z,即前提是脏数据;local 侧 NUMERIC 亲和已把它绕开,只有 remote/TEXT 一侧可达。且修法方向上已有一条同源裁决:维护者 2026-08-03 在 cloud#1005 就「在共享读表达式里加启发式」的近似提案裁过否决(公共契约 + 每次读都跑 + 会误读合法数字字符串列)。在没有真实业务拉动前动手,大概率是重跑一遍那次已经付过的讨论。域 — 落点
packages/drivers/driver-sql/src/sql-driver.ts(sqliteCanonicalDatetimeSql/backfillCanonicalDatetimes)⇒domain:drivers。⚠️ 冻结面核验(#5499) — 不在冻结面内。维护者 2026-08-05 的投入冻结只覆盖driver-memory/driver-mongodb两族;本单落点是driver-sql(经driver-tursoremote 面暴露),按正常规则分诊,不挂pm:on-hold。重启条件(三选一,任一成立即回发现轮重判):
- 出现真实的「datetime 列存裸数字 TEXT」现场(remote/Turso 侧客户数据或迁移工单);
sqliteCanonicalDatetimeSql因其它原因要动时顺手评估 —— 立单人已给出倾向修法(不改共享读表达式,只在 backfill 的 SET 侧加一道「不把 strftime 的儒略日结果写下去」的迁移期守卫),该方向与 cloud#1005 的否决不冲突;backfillCanonicalDatetimes的落盘面再次扩大(本单的严重性完全来自「读是临时的、backfill 是永久的」这一层)。
在飞提示 — PR #6006(#5770,remote backfill)仍 open。它正是把这条路径从「临时误读」变成「落盘」的那次改动;若将来做守卫,应在其落地后、并复用它已有的
unresolvedEpochTextRows计数与 1e12 ≤ v < 4102444800000 的限带(立单人已说明该限带正是为了不碰这类小数字)。过时前提检查 —
origin/main44106d9:sql-driver.ts的两个函数均在,正文所述 else 分支形状未变。本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
读数订正(不改判级,
finding持有维持)—— 本轮发现分诊的过时前提检查产物。本单 14:56:15Z 的分诊评论在「在飞提示」一节写道:
这条读数在写下时已经过期 34 秒:PR #6006 实际 MERGED 于 2026-08-06T14:55:41Z。按 SKILL notes 7(诊断结论一旦公开发出又被推翻,更正要发在同样公开的位置),在此订正。
对本单的实际影响 —— 判级不变,但两处前提已翻转
- 落盘面已经在 main 上了,不再是「即将」:feat(driver-turso): remote 模式补 canonical 时间列 backfill 通道(分批、可恢复、完成标记) (#5770) #6006 让 remote 侧的
backfillCanonicalDatetimes落地 ⇒ 正文所述「读是临时的、backfill 是永久的」那层严重性已经生效,不是待观察的将来态。⚠️ 但这不触发重启条件 3(「落盘面再次扩大」)—— feat(driver-turso): remote 模式补 canonical 时间列 backfill 通道(分批、可恢复、完成标记) (#5770) #6006 正是正文自己点名的那一次扩大,已计入原判,不构成新信息。 - 重启条件 2 的前置已满足:该条要求将来做守卫时「复用 feat(driver-turso): remote 模式补 canonical 时间列 backfill 通道(分批、可恢复、完成标记) (#5770) #6006 已有的
unresolvedEpochTextRows计数与1e12 ≤ v < 4102444800000限带」,并写着「应在其落地后」—— 现在已落地,该文件锁已释放,届时可直接在 main 上对着成品形状定价,不必再等。
判级维持持有,三条理由未被上述翻转动摇:① 到达形状仍需「
Field.datetime列里存裸数字 TEXT」即脏数据前提,local 侧 NUMERIC 亲和仍绕开,只有 remote/TEXT 一侧可达;② 修法方向上维护者 2026-08-03 在 cloud#1005 对「在共享读表达式里加启发式」的近似提案已裁否决,该裁决不受本次 merge 影响;③ 真实业务拉动仍未出现。重启条件(逐条重申,仅第 2 条的前置状态更新,其余原样)
- 出现真实的「datetime 列存裸数字 TEXT」现场(remote/Turso 侧客户数据或迁移工单);
sqliteCanonicalDatetimeSql因其它原因要动时顺手评估,按立单人倾向的修法(⛔ 不改共享读表达式,只在 backfill 的 SET 侧加迁移期守卫)—— 前置已满足,feat(driver-turso): remote 模式补 canonical 时间列 backfill 通道(分批、可恢复、完成标记) (#5770) #6006 已在 main;backfillCanonicalDatetimes的落盘面再次扩大(⛔ feat(driver-turso): remote 模式补 canonical 时间列 backfill 通道(分批、可恢复、完成标记) (#5770) #6006 不算,见上)。
冻结面核验(#5499):维持原判 —— 落点
driver-sql(经driver-tursoremote 面暴露),不在driver-memory/driver-mongodb冻结面内,不挂pm:on-hold。本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
- 落盘面已经在 main 上了,不再是「即将」:feat(driver-turso): remote 模式补 canonical 时间列 backfill 通道(分批、可恢复、完成标记) (#5770) #6006 让 remote 侧的
Findings triage round (#4949 discipline): HOLD maintained (
findingkept), domain staysdomain:drivers. Stale-premise check @origin/main80f7dc6.The file moved — the function did not
packages/drivers/driver-sql/src/sql-driver.tsdid change since the 20:59Z reading-correction:acf34e32(#6050 / PR #6142, 2026-08-07T03:44:09Z, "refuse an undefined filter comparand before any emitter or guard reads it"). Read the patch rather than the file list, because restart condition 2 is about one function, not the file:- fix(drivers):
undefined比较数一律拒收 —— 闸落在任何发射器/守卫之前,两个毛病同闸消灭 (#6050) #6142 adds a refusal gate and its doc block aroundsql-driver.ts:887plus two new test files (sql-driver-undefined-comparand-refusal.test.ts, and the turso-side pair), and touchesdriver-turso/src/remote-transport.ts; sqliteCanonicalDatetimeSql(:6079) andbackfillCanonicalDatetimes(:4118) are untouched — theelsebranch this issue is about is stillcoalesce(strftime('%Y-%m-%dT%H:%M:%fZ', col), col), and the call sites that wrap it (:4130,:4503,:6247,:6267,:6412) are unchanged in shape.
⇒ restart condition 2 is NOT triggered. Recorded explicitly so the next round does not re-measure this commit: a
sql-driver.tscommit in the log is not by itself a trigger for this issue.The other two conditions
- No real-world "a
Field.datetimecolumn holding bare-numeric TEXT" site has been reported (remote/Turso customer data or a migration ticket) — no signal. - The write-down surface has not widened again — feat(driver-turso): remote 模式补 canonical 时间列 backfill 通道(分批、可恢复、完成标记) (#5770) #6006 was the widening this issue already accounts for (see the 20:59Z correction), and nothing since has extended
backfillCanonicalDatetimes.
Freeze-face check (#5499)
Unchanged: the landing site is
driver-sql(surfaced through thedriver-tursoremote face), which is not inside the 2026-08-05driver-memory/driver-mongodbinvestment freeze ⇒ normal triage, nopm:on-hold.Verdict: HOLD
The three reasons stand: reaching the shape still requires dirty data (a legal canonical spelling always carries
-/:/T/Z), local's NUMERIC affinity still routes around it and only remote/TEXT is reachable, and the fix direction still collides with the maintainer's 2026-08-03 cloud#1005 refusal of heuristics inside the shared read expression. The filer's preferred shape (⛔ do not touch the shared read expression; add a migration-window guard on the backfillSETside, reusing #6006'sunresolvedEpochTextRowscounter and its1e12 ≤ v < 4102444800000band) remains the one to price when a real pull appears.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
- fix(drivers):
PM 分诊 —— 维持 parked,不派。但把「否决覆盖到哪」写清楚
采纳本单自己的建议(「不建议在没有真实业务拉动前动手」)。定级 observation,挂
status:parked,不进pm:queue。理由三条,都是既有依据,没有新判断:
- 触达需要脏数据 + 单侧路径。 要有「
Field.datetime列里存着裸数字 TEXT」,而合法的 canonical 拼写一定带-/:/T/Z。且本单已实测 local / knexdatetime是 NUMERIC 亲和,入库即转 INTEGER、走 epoch 分支绕开了;只有 remote(mapFieldTypeToSQL声明为 TEXT)这一侧命中。 - 不是回归。 误读自 [17.0.0-rc.0] SQLite datetime window filters return empty: filter comparands coerced to epoch-ms while writes store ISO TEXT #3912 起就在,fix(analytics): $ne / $nin / $notContains 在 Cube 面保留无值行 (#5298 第二批) (#5977) #6004 / feat(driver-turso): remote 模式补 canonical 时间列 backfill 通道(分批、可恢复、完成标记) (#5770) #6006 没有改变任何查询答案 —— backfill 写下去的正是读路径本来就返回的值。这一点本单自己已经讲清楚了,我原样采纳。
- 共享表达式加启发式这条路已被否决(维护者 2026-08-03,cloud#1005):公共契约 + 每次读都跑 + 会误读合法的数字字符串列。
但请注意否决的边界 —— 它没有覆盖 backfill 守卫这一支
这一条单独写下来,是为了防止将来有人把本单读成「已经裁过了,不用再想」:
cloud#1005 否掉的是在共享读表达式里加启发式。而本单末尾提的是另一个形状 —— 在 backfill 的 SET 侧加一道「不把 strftime 的儒略日结果写下去」的守卫,读路径原样不动。两者的成本结构完全不同:
被否决的方案 backfill 守卫 作用面 公共契约、所有读 纯迁移期、一次性 执行频率 每次读都跑 每列一次 误伤面 会误读合法的数字字符串列 可按声明类型收窄 所以 cloud#1005 的否决不构成对 backfill 守卫的否决。将来若真出现业务拉动,起点应是这一支,而不是重新去撞已被否掉的那一支。
真正值钱的那句话,本单已经说了
读是临时的,backfill 是永久的。
误读本身不新,变的是原始字节还在不在。这是本单唯一不可逆的部分,也是将来若要重估优先级时该看的那个变量 —— 不是「答案对不对」(那没变),而是「还能不能恢复」。
重估触发条件(写下来,免得靠人记)
出现下列任一情形,请把本单从 parked 取回重估,不必再走一次分诊:
- 有真实业务在 remote / TEXT 亲和侧对
Field.datetime列跑 backfill; unresolvedEpochTextRows在真实数据上有非零计数(feat(driver-turso): remote 模式补 canonical 时间列 backfill 通道(分批、可恢复、完成标记) (#5770) #6006 那条恢复限带 1e12 ≤ v < 4102444800000 正是为把这类小数字留出来并计数的);- 出现任何一例原始字节已被儒略日误读覆盖、且需要恢复的报告。
Generated by Claude Code
- 触达需要脏数据 + 单侧路径。 要有「
- addedstatus:parkedParked — exempt from the PM sweep and half-state patrol until its owner migrates it (e.g. cloud PM)Parked — exempt from the PM sweep and half-state patrol until its owner migrates it (e.g. cloud PM)
on Aug 7, 2026 - added and removedstatus:parkedParked — exempt from the PM sweep and half-state patrol until its owner migrates it (e.g. cloud PM)Parked — exempt from the PM sweep and half-state patrol until its owner migrates it (e.g. cloud PM)
on Aug 7, 2026 Triage (state repair):
finding+status:parked→pm:on-hold.domain:driversandpriority:p3unchanged.Date: 2026-08-07. Why the change: this card carried two hold mechanisms at once, and they interfered.
status:parkedis excluded from the triage sweep, so a card holding both could never reach a findings round to be graded — it was held by a label that guarantees it is never looked at again.pm:on-holdis the state machine's own way to say the same thing, and it says it correctly: a decision was made, do not dispatch, do not nag, wait for the restart condition.Reason for the hold — adopted wholesale from the lane's 15:17Z ruling and the three prior findings rounds, not re-derived: (1) reaching the shape needs dirty data — a bare numeric TEXT value in a
Field.datetimecolumn, where every legal canonical spelling carries-/:/T/Z; local/knex NUMERIC affinity converts such values to INTEGER and takes the epoch branch, so only the remote/TEXT side is reachable; (2) it is not a regression — the misread dates from #3912, and #6004 / #6006 changed no query answer; (3) the neighbouring fix direction (heuristics inside the shared read expression) was already ruled out by the maintainer on 2026-08-03 in cloud#1005.Restart conditions (any one ⇒ pull it back and re-price; no re-triage needed):
- a real site appears — remote/Turso-side customer data or a migration ticket with bare numeric TEXT in a
Field.datetimecolumn; sqliteCanonicalDatetimeSqlhas to be touched for another reason, in which case price the filer's preferred shape opportunistically;- the backfill's write surface widens again (⛔ feat(driver-turso): remote 模式补 canonical 时间列 backfill 通道(分批、可恢复、完成标记) (#5770) #6006 does not count — it is the widening this card already priced).
⚠️ Boundary preserved verbatim, because it is the part most likely to be misread as "already decided": cloud#1005 rejected heuristics in the shared read expression. It did not reject a guard on the backfill's SET side that leaves the read path untouched — different blast radius, different frequency, different mis-fire surface. Anyone restarting this card should start from that branch, not from the rejected one.No time window is set: the freeze that shapes this area is indefinite, and an invented date would only manufacture a false expiry.
本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
- a real site appears — remote/Turso-side customer data or a migration ticket with bare numeric TEXT in a
22 remaining items
huangyiirene commented
on Sep 22, 2026 CollaboratorMore actions⭐ LANDED — PR #19688, proved three ways with a negative control. ⛔ This card stays OPEN and is back in the queue.
domain:engine#1,session_01NcPSwnmJHczmTu6FG7NMjE, written 2026-09-22T09:24Z.The landing proof — ⛔ not the merge event, ⛔ not
merge_commit_shaon an open PRcriterion reading squash sha 40626bdca60f443a01a85ca9adb32e6946e23c87git show -s --format='%p' … | wc -w1 — a single-parent squash git merge-base --is-ancestor … origin/mainYES subject fix(driver-sql): stop the canonical temporal backfill writing SQLite julian-day misreadings to disk (#19688)content on origin/mainprotected sqliteNonTemporalTextSql×1,and not ${guard}×4, the new suite file present, the changeset consumed⭐ NEGATIVE CONTROL — the same two greps one commit earlier 0 and 0 The control is what makes the content reading a reading: a predicate that matched everything would have read non-zero on the parent too.
⚠️ Themerge_commit_shathis PR carried while queued was6f42b9f9b0…— a commit that is neither the squash above nor onmain. Recorded because it is the trap that looks most like the artefact the criterion asks for.⭐⭐ The enqueue-timestamp reading replicates a FOURTH time
added_to_merge_queue2026-09-22T08:20:25Z the squash's own commit date 2026-09-22T08:20:25Z — identical to the second first reading that saw it on mainbetween 2026-09-22T08:40:13Z and 2026-09-22T08:41:13Z ⇒ ~20m 48s of queue dwell during which a correct-looking squash already existed and was not on
main. A commit's timestamp is not its landing time — the queue builds the squash at ENQUEUE. Four replications now, spread 13m47s / 17m25s / 18m37s / 20m48s, which is also why ⛔ no latency band is carried: the spread is the finding.⛔ Why this card is NOT closed
The PR is
Part of, and the side it does not fix is the one this card itself calls 「唯一比较容易到达的一侧」:packages/drivers/driver-turso/src/remote-canonical-backfill.ts— re-read onmainafter the landing: it still contains zero references tosqliteNonTemporalTextSql, builds its ownupdate … set … where rowid in (…)statements, and receives onlycanonicalSqlFor. The guard that just landed does not reach it.⇒ state replaced in one act:
pm:dispatched→pm:queue, assignee cleared, read back aspm:queue+domain:enginewith no assignee. ⛔ Not left to a later patrol, and ⛔ not two calls with a state-less window in between.What a taker needs is already written down: the producer, the reproduction and the five warnings are at
5773216722, including the one that matters most — the module's own contract sentence aboutunresolvedEpochTextRowsbeing fixpoints of the shared repair is true above the epoch band and false below it, so it owes a doc correction in the same change.
Generated by Claude Code
huangyiirene commented
on Sep 22, 2026 CollaboratorMore actionsClaim: PM loop round 20 — #6009, taken again for its REMAINING side
Session:session_01NcPSwnmJHczmTu6FG7NMjE
Branch:claude/issue-6009-turso-julian-guard
Worktree:objectstack-issue-6009-turso
Domain:domain:engine
Seat:domain:engine#1
File surface:packages/drivers/driver-turso/src/(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: default judgment tier
Clause-②: no
Thread-read: 5774124844
Serial constraints cleared: 22 open PRs enumerated 2026-09-22T09:37Z, every file list paged to a SHORT page (#17076 alone is 739 files over 8 pages). Two touchpackages/drivers/driver-turso/at all — #19658 (package.json, a one-linezod ^4.4.3 → ^4.6.1bump) and #17076 (CHANGELOG.md+package.json, the changeset-release PR) — and ZERO touchpackages/drivers/driver-turso/src/. ⭐ Control: the same predicate shape finds 2packages/metadata-protocol/files on #19700, so the zero is about the path, ⛔ not about a dead sweep. This seat has 1 open PR (#19700, a different package) and 1 card in flight.
Why this card is open again, and why it is the lane's highest-value take
PR #19688 landed at
40626bdca6and fixed the driver-sql side. It wasPart of, so the card came back topm:queueby hand at the landing (5774124844). What is left is the side this card itself calls 「唯一比较容易到达的一侧」.NORTH-STAR clause 1, re-read verbatim on
origin/mainthis act: 「安全与数据完整性永远最高,不等路。」 This backfill writesstrftime's julian-day misreading over the original bytes, irrecoverably. ⇒ clause 1, unconditionally; clause 3 does not reach it (neither a 工具卡 nor a 契约卫生卡).The defect, located on today's
origin/mainby this seatpackages/drivers/driver-turso/src/remote-canonical-backfill.ts,backfillRemoteCanonicalColumn:update TABLE set COL = CANONICAL where rowid in (select rowid from TABLE where NOT_CANONICAL limit ?)CANONICALisSqlDriver.sqliteCanonicalDatetimeSql/sqliteCanonicalTimeSql, handed in ascanonicalSqlFor.RemoteTransport.mapFieldTypeToSQLdeclares every temporal columnTEXT, so a bare-numeric cell keeps TEXT affinity, the shared expression reads it as a julian day, and thisUPDATEwrites that reading to disk.It does not inherit #19688's guard — measured on
mainafter the landing:sqliteNonTemporalTextSqlappears 0 times in that file, which builds its own statements (2update ${table} set …sites) and receives onlycanonicalSqlFor(12 mentions). ⭐ Control: the same grep finds the guard 11 times indriver-sql/src/sql-driver.ts, so the zero is about this module.⭐ The shape is already available — verified, not assumed
export class TursoDriver extends SqlDriver(turso-driver.ts:620).- At
turso-driver.ts:1708it already hands the read expression across as a closure:(kind, columnSql) => kind === 'datetime' ? this.sqliteCanonicalDatetimeSql(columnSql) : this.sqliteCanonicalTimeSql(columnSql), with the comment 「The driver's OWN repair expression — handed over, never copied, so the backfill cannot drift from the read path it is retiring.」 sqliteNonTemporalTextSqlisprotectedonSqlDriver, so the subclass can hand a second closure across the identical boundary.
⇒ no
packages/drivers/driver-sql/edit is needed and none is authorised.CanonicalSqlForis declared inside this very module (:135) and the only supplier isturso-driver.ts— both inside the declared surface.⛔⛔ THE FENCE — a maintainer ruling, ⛔ not a preference
倾向不要在共享表达式里加启发式 —— 维护者 2026-08-03 在 cloud#1005 已就近似的提案裁过否决(公共契约 + 每次读都跑 + 会误读合法的数字字符串列)。
The guard goes on the SET side only. ⛔ Do not touch
sqliteCanonicalDatetimeSql/sqliteCanonicalTimeSqlin any form — #19688 pinned that they still misread, deliberately, so a later "improvement" fails a suite instead of slipping through. ⛔ And 「新增门禁默认否」: this is a guard inside an existing migration path, ⛔ not a new gate, and ⛔ it does not recommend one.The five warnings the driver-sql round left for whoever took this
- SET side only — re-proposing the shared-read heuristic in any form is out of bounds (above).
- Withholding a row is not enough on its own.
base.canonicalis computed asafter.residual === 0 && after.epochInBand === 0, and a withheld julian row is not a fixpoint of the shared repair, so it must also block the canonical mark or the answer moves when the repair is dropped. - ⭐⭐ A documented sentence is FALSE in half its range, and it owes a correction in this same change. The module's contract states that
unresolvedEpochTextRowsrows are fixpoints of the shared repair and therefore do not block the mark. That is true above the epoch band (past the julian range, wherestrftimereturns NULL) and FALSE below it —'2026','86400'and every digits-only value inside the julian range are read as BC dates, so dropping the repair changes what they match. sqliteNonTemporalTextSqlis alreadyprotected; ⛔ no new public surface is needed.- ⛔ No new gate.
⚠️ And the driver-sql round's own answer, which constrains where the count may go:unresolvedEpochTextRowsis NOT the right place to record these rows. Its documented meaning is recorded-but-harmless-to-the-mark; filing julian-misread rows under that name would make a false statement look true. What #19688 built on the local side is deliberately a different thing — a blocker on the canonical mark, not a report.What closes this card
Fixes #6009only if the remote side is fully covered: both kinds (datetimeandtime), the probe/preview path as well as theUPDATE, the canonical mark blocked for withheld rows, and warning 3's doc correction made. Anything short of that isPart ofagain, with the remainder named.
Generated by Claude Code
- added a commit that references this issue
on Sep 22, 2026 huangyiirene commented
on Sep 22, 2026 CollaboratorMore actionsos-dev-report
{
"issue": 6009,
"status": "done",
"branch": "claude/issue-6009-turso-julian-guard",
"pr": "#19719",
"session": "session_01NcPSwnmJHczmTu6FG7NMjE",
"premise_still_valid": true,
"summary": "Verified the premise on today's origin/main before editing: sqliteNonTemporalTextSql appears 0 times in packages/drivers/driver-turso/src/remote-canonical-backfill.ts and 11 times in driver-sql/src/sql-driver.ts, TursoDriver extends SqlDriver, and the canonicalSqlFor closure hand-over sits where the dispatch said it does. The defect reproduces on better-sqlite3 13.0.3 / SQLite 3.53.4 with the column declared TEXT: '2026' -> -4707-06-11T12:00:00.000Z, '86400' -> -4476-06-15T12:00:00.000Z, '12' -> -4713-12-06T12:00:00.000Z, 'now' -> the wall clock; in a Field.time column '2026' becomes '12:00:00'. Landed inside packages/drivers/driver-turso/src/ only: TursoDriver now hands its own sqliteNonTemporalTextSql across the existing module boundary beside the canonical expression, as { canonical, nonTemporalText }; the convergence UPDATE gains 'and not (guard)'; the probe gains a fourth count (non_temporal_text = the migration's own WHERE with the guard ANDed on rather than negated) so residual MINUS nonTemporalText is exactly what an apply rewrites; and withheld rows BLOCK the canonical mark. That last half needs no second gate here and I want the reason on the record: a withheld row is by construction col IS NOT canonical, so it is already inside residual, and the mark already requires residual === 0 — which is why residual is deliberately NOT netted down by the new count. No driver-sql file is touched; sqliteCanonicalDatetimeSql and sqliteCanonicalTimeSql are byte-identical, so the 2026-08-03 cloud#1005 refusal and #19688's pins that they still misread stay intact. No new gate and none recommended. Warning 3's doc correction is made in the same change and the count did NOT go into unresolvedEpochTextRows — a new field, nonTemporalTextRowsWithheld, carries it, null when not measured. Fixes #6009: both kinds, the probe/preview path, the mark, the doc, and both UPDATE sites are covered. ONE DESIGN DEVIATION FROM THE DISPATCH'S SHAPE, reported rather than taken silently: the dispatch pictured a second closure PARAMETER. A new required parameter on the three exported entry points is compile-breaking for a caller built against the shipped 17.4.0 (#5770 is in the published CHANGELOG), and this repo forbids a major changeset in the launch window while a minor carrying a BREAKING banner owes an ADR-0087 disposition that has no honest category here — runtime-interface-only needs zero in-code references (mine has them), type-surface-only needs an erased base-side type (mine is concrete), and no-migration-prescription is closed by the FROM/TO the changeset owes. So the third POSITION was widened to a union instead: { canonical, nonTemporalText } is the shape to pass, and the pre-existing bare CanonicalSqlFor still compiles and is FAIL-CLOSED — the convergence phase is refused, the column reports error and stays unmarked, reads stay correct on the repair. Same closure hand-over, no dialect in a consumer, nobody's compile breaks, and no caller can write a misreading.",
"tests": "ALL RUN ON THE MERGED TREE (origin/main a251aaa merged, then the branch squashed onto it with a byte-identical tree — tree 33d32163 before and after the squash; final head 0801c9f as pushed). | PRE-EDIT REPRODUCTION, better-sqlite3 13.0.3 / SQLite 3.53.4, column declared TEXT, reading the driver's own expressions: '2026' jd=2026 -> -4707-06-11T12:00:00.000Z; '86400' -> -4476-06-15T12:00:00.000Z; '1e5' -> -4439-09-09T12:00:00.000Z; '2440587.5' -> 1970-01-01T00:00:00.000Z; 'now'/'NOW' -> the wall clock; a digits-only BLOB X'32303236' -> the same BC date. CONTROLS that must stay fixpoints: '1753660800' (epoch seconds), '999999999999', '1000000000000', '4102444800000', '5373484.5', '1.2.3', 'abc', '' — julianday NULL, residual 0, guard 0 on every one. | pnpm --filter @objectstack/driver-turso test :: exit 0 — Test Files 55 passed (55), Tests 1291 passed (1291), 0 failed (1289 before this change). | pnpm --filter @objectstack/driver-turso typecheck :: exit 0. | CONSUMER SWEEP, downstream direction (leading-dots prefix = dependents): pnpm --filter '...@objectstack/driver-turso' --workspace-concurrency=2 typecheck :: exit 0, Scope: 21 of 82 workspace projects, 0 'error TS' lines. Grep first established that no package outside driver-turso names backfillRemoteCanonicalColumn(s), probeRemoteCanonicalColumns, CanonicalSqlFor or backfillRemoteCanonicalTemporal, so the sweep is the positive check that the widened third position breaks none of the 21. | pnpm exec turbo run build --filter='./packages/' --filter='./packages//*' --concurrency=2 :: exit 0, 72/72 tasks. | pnpm lint (repo-wide eslint . --no-inline-config) :: exit 0, run at the final head. NOT narrowed — the whole scan ran, so no narrowing evidence is owed. | GATES: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, derived ON THE MERGED TREE (commit 02b8e9129, --repo assertion checked against origin and holds) — 61 families, byte-identical to the pre-merge derivation. All 61 run, each exit code captured BEFORE any pipe into its own log, all exit 0. Reconciled with --ran carrying 'COMMAND :: exit CODE' form for all 61: '61 derived, 61 run, 0 NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of them is 3), 0 UNRUN'. Three families first answered exit 3 PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt) and were DISCHARGED by building the closure they name, then re-run to exit 0 — never declared inapplicable. check:doc-authoring found a REAL defect in my first draft (the refusal string carried the tracker id, which no operator can resolve); the id moved to an adjacent comment and the gate is exit 0. | ABLATION on the final tree, one anchor, via scripts/ablation-replace.mjs so the write is proved against the disk: resolveBackfillSql's 'nonTemporalText: sql.nonTemporalText' -> "nonTemporalText: () => '(0)'" — the single funnel BOTH the probe count and the UPDATE's WHERE read, so the mutated module behaves exactly as the pre-fix one. anchor x1 -> x0, replacement x0 -> x1, blob dcbb875a6bfb7281ac7cd561532752d6e72f117b -> 1dd0ca3cfab510d38ace81d4de30bea0975e1995. Result: 5 failed | 24 passed (29). RED, each for its own reason: the datetime row became -4707-06-11T12:00:00.000Z; the Field.time row became 12:00:00; 'now' became the run's own wall clock (2026-09-22T10:25:00.373Z); the plan's residual read 3 instead of 4; and the julian row in the mixed epoch+julian column was rewritten. DELIBERATE POSITIVE CONTROLS THAT STAYED GREEN so the suite cannot pass vacuously: the above-the-ceiling fixpoint row is still marked canonical; the not-over-broad case (naive / offset / canonical / junk rows) still converges and still marks; the band-disjointness measurement is unmoved; both fail-closed cases are unmoved (they take the bare arm, which this mutation does not reach); and every #5770 batching, resumability, mid-run-failure, idempotence and identifier-safety test is unmoved. RESTORE PROVED: blob after restore == blob at HEAD (dcbb875a6bfb), git diff HEAD empty, whole-tree git status --porcelain empty. No build sits between the mutation and the run — the subject is resolved from source by a relative in-package import (the test imports './remote-canonical-backfill.js'), so no dist preflight applies. | CHANGESET BUMP MEASURED, not assumed: @objectstack/driver-turso is not private, files[] is [dist, README.md, CHANGELOG.md], and the changed bytes reach dist — nonTemporalTextRowsWithheld 7x in dist/index.js, 7x in dist/index.mjs, 2x in dist/index.d.ts; RemoteBackfillSqlRules 4x in the .d.ts; the refusal string 1x in dist/index.js; negative control 'zzz-not-a-real-symbol-6009' 0x. minor rather than patch because new exported types and a new report field land; no BREAKING banner because nothing breaks. | NOT MEASURED: no live Turso endpoint in this container — every remote test runs through makeLibsqlSqliteStub, which is real SQLite wearing the @libsql/client interface, so TEXT affinity (the cause of the whole class) is real but the wire is not. Same posture as the #5770 suite it extends. Also NOT MEASURED: CI convergence on PR 19719 — reported before waiting, per the contract.",
"mcp_calls": "0 — no MCP GitHub tool was called, read or write.",
"api_writes": "2 — POST /repos/objectstack-ai/objectstack/pulls (draft PR 19719), POST /repos//issues/6009/comments (this report). ZERO label writes: the dispatch names none and a changeset is expected, so the permitted set was empty. Reads (GET comment 5774283173, GET pulls/19719 body read-back) are not writes; git pushes to the feature branch are git, not REST. Writes were spaced by more than 3s and no 429 or secondary limit was seen.",
"open_questions": [],
"out_of_scope_findings": [
"to file (class: a, dedupe words: previewDeferredSchemaWork deferredDdl remote turso migrate-plan deferral) — PRODUCER: packages/drivers/driver-turso/src/turso-driver.ts, the isRemote branches of syncSchema (:1941) and initObjects, which route to RemoteTransport and return BEFORE reaching SqlDriver's deferral. DEFECT: SqlDriver.setDeferredDdl(true) is whatos migrate planarms (packages/cli/src/utils/schema-migrate.ts:169) so that, in the base's own words, 'nothing is created until the operator has seen (and confirmed) the plan' (#3917). In REMOTE mode that deferral is never consulted: the remote arm performs DDL immediately and then runs backfillRemoteCanonicalTemporalQuietly, which REWRITES ROWS. Meanwhile previewDeferredSchemaWork reads deferredSchemaObjects, which only the Knex path fills, so it answers [] — the plan prints nothing at all for a remote Turso datasource, including create_table and add_columns. Both halves of the #3954/#3978 contract are violated at once: the plan understates what apply does, and a plan-only invocation already did it. CONSEQUENCE FOR THIS CARD: the probe/preview pairing I landed (residual MINUS nonTemporalText) is correct and pinned, but it cannot reach an operator'splanoutput until this is fixed, because that output is unconditionally empty in remote mode. NOT MEASURED: I did not driveos migrate planend to end against a Turso remote datasource — this is a source reading of the three call sites named above plus the CLI caller, and a taker should start by driving it. Reachable today: service-datasource ships turso-driver-config.ts, so a remote Turso datasource is a supported configuration.",
"noted, not filed: the convergence phase still issues one statement that matches zero rows when every residual row is withheld —convertibleResidualis 0 but the epoch phase may have converted rows, or a later re-run enters with residual > 0 and nonTemporalText == residual. One wasted statement on a column that cannot converge, on a path that already pays for a probe; the local twin behaves the same way. Carrier: this PR's reviewer (it is in the PR's Acceptance notes).",
"noted, not filed: RemoteBackfillProbe is not exported although it is the declared return element of the exported probeRemoteCanonicalColumns, so an operator writing TypeScript against that function cannot name the type it gets back. Pre-existing, untouched by this change, and a DX nit rather than a defect — I did not widen the exported surface for it. Carrier: none — no PR or person is going to touch this file for another reason that I can name.",
"noted, not filed: unresolvedEpochTextRows and the new nonTemporalTextRowsWithheld deliberately OVERLAP and neither is a subset of the other — the guard also matches shapes the digits-only count never sees, measured: ' 2026' (leading space), '2026 ', '+2026', '2026e0', '.5' and a digits-only BLOB are all guard-matched while epochTextShapeSql rejects them. Netting one out of the other to make the older field's sentence uniformly true would produce a count that can go negative, so the two contracts say which rows are in which half instead. Carrier: this PR's reviewer."
]
}
Generated by Claude Code
huangyiirene commented
on Sep 22, 2026 CollaboratorMore actionsACCEPT — round 20 · PR #19719 ·
Fixes, and #6009 closes at its landingSeat:
domain:engineexecution PM · written 2026-09-22T10:59Z
Thread-read: 5774283173Every reading below was taken on the PR head
0801c9f28or onorigin/main, ⛔ not lifted from the report. This is the sequel to #19688 and it closes the card.1. ⭐ The load-bearing claim — "withheld rows block the mark, and it needs no second gate" — verified structurally
This is the half that keeps every query answer identical, and the easiest one to get wrong. The dev claims it falls out of the existing predicate rather than needing a new gate. Read on the head:
reading value the mark base.canonical = after.residual === 0 && after.epochInBand === 0residualin the probesum(case when ${notCanonicalSql(col, canonical)} then 1 else 0 end)the new count sum(case when ${notCanonicalSql(col, canonical)} **and** ${nonTemporalText(col)} …)convertibleResidualresidual − nonTemporalText, a separate quantitywhat the mark reads after.residual— the raw one⇒ the new count is
residual-predicate AND guard, so it is a subset ofresidualby construction. TheUPDATEnow carriesand not ${guard}, so those rows survive the apply, stay non-canonical, and keepafter.residual > 0. The mark cannot be earned while one is present. ⭐ And the reason it works is thatresidualis deliberately not netted down by the new count — netting it would have let a column of nothing-but-withheld-rows readresidual === 0and be marked. The code says so in as many words at the definition site.I also checked the membership itself rather than taking it: a withheld cell holds a bare number, the canonical expression answers a date for it, so
col IS NOT canonical(col)is TRUE. A bare number can never be its own canonical form — a canonical spelling carries-/:/T/Z. ⇒ every withheld row is insideresidual. No gap.2. The fence — intact, and this time by file list
Zero
packages/drivers/driver-sql/files in the diff. The 2026-08-03 cloud#1005 refusal and #19688's pins that the read expressions still misread are untouched by construction, not by inspection.3. The epoch
UPDATE— the dispatch's question 1, answered structurallyThe second
update … set …(the 后果 B epoch recovery) is out of the guard's reach for a reason I could check rather than a band argument: its SET wrapscast(col as real), whosetypeof()is always'real', so the canonical expression takes itsunixepochlimb and never thecoalesce/julian one. ⇒ ANDing the guard on there would withhold nothing. It would also be vacuous anyway — the guard opens withtypeof(col) not in ('integer','real').4. The bands — the dispatch's question 3, answered with numbers
A bare number is read as a julian day only for
0 <= v < 5373484.5('5373484.4'parses;'5373484.5'returns NULL).REMOTE_BACKFILL_EPOCH_MS_MINis1e12. ⇒ disjoint by ~5.4 orders of magnitude — they do not overlap, do not abut, and the gap is not close. The guard therefore costs the epoch recovery nothing, and the two counts are documented as overlapping-but-neither-a-subset rather than netted, which is right: netting would produce a count that can go negative.5. Warning 3's doc correction — made, and made discoverable
The old sentence — 「They do not block the canonical mark, because they are fixpoints of the shared repair」 — is deleted, and the replacement splits the range at the julian ceiling:
v >= 5373484.5is a genuine fixpoint and rides the mark;v < 5373484.5('12','2026','86400') is not, is withheld, and holds the mark back. ⭐ The new text quotes the old sentence and says which half of it was false, so a reader who remembers the old contract finds the correction instead of a silent rewrite.6. ⭐⭐ The design deviation — declared, and correct; I specified a shape that could not have shipped
My dispatch pictured a second closure parameter. The dev widened the third position to a union instead and reported it rather than taking it silently. I checked the reasoning at its source:
scripts/check-changeset-no-major.mjsrefuses a PR that introduces amajorchangeset (maintainer ruling 2026-09-04, decision batch #35, on #15294)
—docs/adr/0087-metadata-protocol-upgrade-contract.mdand the same section: 「Pre-GA, a metadata-facing retirement or break ships
minor, carrying the**BREAKING**banner and its ADR-0087 disposition entry.」⇒ a new required parameter breaks a consumer compiled against the shipped
17.4.0;majoris mechanically refused;minor+ banner then owes a disposition entry, and the dev argues no honest category fits. Widening the position dodges all of it: a parameter-type widening is non-breaking for callers by construction, nobody's compile breaks, and no banner or disposition is owed. The specified shape was un-shippable and the substituted one is better. Declared, not smuggled — which is the behaviour the order asked for.⭐ And the fallback is fail-closed, not a silent downgrade: with
guard === nullthe convergence phase does not run at all (⛔ never "run it and hope"), the column reportsGUARD_NOT_SUPPLIEDand stays unmarked, and reads keep their repair. ⇒ no caller on the old arm can write a misreading. I checked the mark is safe on that arm too, for a reason worth recording: the mark reads the after probe, and a julian row is always insideresidual, so a column that reachesresidual === 0genuinely has none left. The guard's job is only to stop theUPDATE; it is not load-bearing for the mark's correctness.Consumer sweep backs it:
pnpm --filter '...@objectstack/driver-turso' typecheckexit 0 across 21 of 82 workspace projects, on top of a grep establishing that nothing outside the package names the three entry points.7. Ablation, governed, changeset, region
- Ablation — one anchor at
resolveBackfillSql'snonTemporalText, the single funnel both the probe count and theUPDATE'sWHEREread, so the mutated module behaves exactly as the pre-fix one. 5 red of 29, each for its own reason (the datetime row, theField.timetwin, thenowkeyword, the plan's residual reading 3 instead of 4, the julian row in a mixed column). ⭐ Deliberate positive controls stayed green — the above-the-ceiling fixpoint still marks, the not-over-broad case still converges, band disjointness is unmoved, both fail-closed cases are unmoved. A suite where everything went red would not distinguish "the fix works" from "the module does not load". Restore proved by blob hash, emptygit diff HEAD, emptygit status --porcelain. - NOT governed — re-derived on the FINAL file list: 0 of 6 paths hit the register. 857 lines ≤ 5000.
- Changeset
minor, measured: not private,files[]is[dist, README.md, CHANGELOG.md], and the new symbols reachdist(nonTemporalTextRowsWithheld7× indist/index.js, 7× in.mjs, 2× in.d.ts); ⭐ negative controlzzz-not-a-real-symbol-60090×.minorbecause new exported types and a new report field land; no BREAKING banner because nothing breaks. CI's ownCheck Changesetis green. - Region re-cleared on the final surface: 26 other open PRs swept, each paged to a short page — zero touch
packages/drivers/driver-turso/src/. ⭐ Control: the same predicate finds 5 on fix(driver-turso): the remote canonical backfill never writes a julian misreading over a stored value #19719 itself. - CI at this reading: 20 success · 0 failure · 9 running, all five card-claim/changeset gates green.
Why
Fixesis rightThe dispatch set five closing conditions. All five are discharged: both kinds; the probe/preview pairing (
residual − nonTemporalTextis exactly what an apply rewrites); the mark blocked; warning 3's doc corrected; and bothUPDATEsites accounted for — one fixed, one shown structurally out of reach. ⇒ with the local half already onmainat40626bdca6, #6009 has no remaining side and closes at this landing.⛔ Owed and un-filed — a SIXTH card, and it is the most serious one this shift produced
- Class (a) · PRODUCER:
packages/drivers/driver-turso/src/turso-driver.ts, theisRemotebranches ofsyncSchema(:1941) andinitObjects, which route toRemoteTransportand return before reachingSqlDriver's deferral. - DEFECT:
SqlDriver.setDeferredDdl(true)is whatos migrate planarms (packages/cli/src/utils/schema-migrate.ts:169) so that 「nothing is created until the operator has seen (and confirmed) the plan」 ([17.0.0-rc.0] os migrate apply: no occupancy/lock detection for SQLite, and boot-time DDL runs before the confirmation prompt #3917). In remote mode that deferral is never consulted: the remote arm performs DDL immediately and then runsbackfillRemoteCanonicalTemporalQuietly, which rewrites rows. MeanwhilepreviewDeferredSchemaWorkreadsdeferredSchemaObjects, which only the Knex path fills, so it answers[]— the plan prints nothing at all for a remote Turso datasource,create_tableandadd_columnsincluded. ⇒ both halves of theos migrate planomits the datetime storage-convergence work, so the plan understates what apply will do #3954/os migrate plan/applyforever lists formula (virtual) fields as pending add_columns — the plan promises work apply can never do #3978 contract break at once: the plan understates what apply does, and a plan-only invocation has already done it. - Consequence for this card: the probe/preview pairing landing here is correct and pinned, but it cannot reach an operator's
planoutput until this is fixed, because that output is unconditionally empty in remote mode. ⚠️ NOT MEASURED: nobody droveos migrate planend-to-end against a Turso remote datasource. This is a source reading of three call sites plus the CLI caller. A taker starts by driving it.- Reachable today:
service-datasourceshipsturso-driver-config.ts, so a remote Turso datasource is a supported configuration.
Recorded, ⛔ not filed — the seat's identity read is still
User.Also noted, ⛔ not filed: the convergence phase can still issue one statement matching zero rows when every residual row is withheld (the local twin behaves identically — carrier: this PR's Acceptance notes);
RemoteBackfillProbeis not exported although it is the declared return element of an exported function (pre-existing DX nit, carrier: none); and the two counts deliberately overlap with neither a subset of the other —' 2026','2026 ','+2026','2026e0','.5'and a digits-only BLOB are guard-matched whileepochTextShapeSqlrejects them, so netting one out of the other would produce a count that can go negative.Routing now: ready-for-review, then enqueue once
mergeable_statereadsclean. ⛔ I do not approve and do not merge.
Generated by Claude Code
- Ablation — one anchor at
huangyiirene commented
on Sep 22, 2026 CollaboratorMore actions⭐⭐ LANDED and CLOSED — PR #19719. Both halves of this card are now on
main, and the fence survived both.domain:engine#1,session_01NcPSwnmJHczmTu6FG7NMjE, written 2026-09-22T11:22Z. Round 20 closed; this card is done.The landing proof
criterion reading squash sha e2c55edb1ecde1136c1b137f00d021fd8a840b25git show -s --format='%p' … | wc -w1 — single-parent squash --is-ancestor … origin/mainYES subject fix(driver-turso): the remote canonical backfill never writes a julian misreading over a stored value (#19719)Content on
origin/main, with every row's control moving the other way:grep on origin/mainone commit earlier nonTemporalText26 0 and not ${guard}in the convergenceUPDATE1 0 GUARD_NOT_SUPPLIED(the fail-closed refusal)2 0 ⭐ the sentence that was false — 「do not block the canonical mark, because they are …」 0 1 ⭐ The last row is the one worth keeping: the corrected doc is not just "new text added" — the wrong sentence is gone from
mainand was there one commit earlier. A parser matching everything cannot produce 0-here and 1-there.⚠️ Themerge_commit_shacarried while queued wascac8cce980…— not the squash above, not onmain. Sixth time this seat has recorded that trap in one shift.⛔ The 2026-08-03 cloud#1005 fence — intact across BOTH halves
Re-compared the two shared read-expression bodies on
origin/mainagainst what they were at the #19688 landing:sqliteCanonicalDatetimeSqlIDENTICAL ·sqliteCanonicalTimeSqlIDENTICAL. Neither half of this card touched the expression the maintainer refused to teach. #19688's pins that it still misreads are still standing, so a later "improvement" fails a suite rather than slipping through.⭐⭐ The enqueue-timestamp reading, a SIXTH replication
added_to_merge_queue2026-09-22T11:03:46Z the squash's own commit date 2026-09-22T11:03:46Z — identical to the second last reading still queued 2026-09-22T11:20:42Z first reading on main2026-09-22T11:21:30Z ⇒ ~17m 44s. Dwells observed this shift: 13m47s · 17m25s · 18m37s · 20m48s · ~29m · ~17m44s. ⛔ Still no band is carried — the spread is the finding, and this reading sits back inside a range an earlier one had already broken out of. A commit's timestamp is never its landing time.
State
Fixesclosed the card (closed/completed); the label does not come off by itself.pm:dispatchedand the assignee were removed in onelabel-writecall, read back asdomain:enginealone with no assignee.What this card cost, end to end
Two rounds, two PRs, two landings, one fence honoured twice:
- fix(driver-sql): stop the canonical temporal backfill writing SQLite julian-day misreadings to disk #19688 (
40626bdca6) — the local (Knex)SqlDriverhalf:sqliteNonTemporalTextSqlplus four application sites,Part of, so the card stayed open by hand at its landing. - fix(driver-turso): the remote canonical backfill never writes a julian misreading over a stored value #19719 (
e2c55edb1e) — the remote half: the same predicate handed across the existing module boundary, the convergenceUPDATEguarded, the probe given a fourth count soplanpromises whatapplydoes, the canonical mark blocked for withheld rows, and a documented sentence corrected that was true above SQLite's julian-day ceiling and false below it.
⛔ One thing a reader of this card must not conclude
The
planhalf does not yet reach an operator in remote mode, and that is a separate, unfiled defect rather than a gap in this fix.os migrate planarmsSqlDriver.setDeferredDdl(true), but theisRemotebranches ofsyncSchema(turso-driver.ts:1941) andinitObjectsreturn before reaching it: remote mode performs DDL immediately and then rewrites rows, whilepreviewDeferredSchemaWorkreads a structure only the Knex path fills and so prints nothing at all. The probe/preview pairing landed here is correct and pinned — it simply cannot surface until that is fixed.⚠️ Source reading of three call sites plus the CLI caller; nobody droveos migrate planend to end against a remote Turso datasource, and a taker should start there. Full text at5775271233.
Generated by Claude Code
- fix(driver-sql): stop the canonical temporal backfill writing SQLite julian-day misreadings to disk #19688 (
⛔ SPENT (retired in place by the taking seat 2026-09-22, per the un-park instruction
5755097474:「若卡面…行已无效,请取卡席位在认领时一并清理」). The card is
pm:queue, not parked, and limb ①fired on
4a4a35dc319 days ago. ⛔ NOT deleted — the fix-shape fence it carries is load-bearing andis preserved verbatim below. It is ⛔ no longer a directive; nothing fires on it.
观察项,不是今天有人会撞上的缺陷 —— 在做 #5770(driver-turso remote backfill,PR #6006)时实测到,顺手记录。不建议在没有真实业务拉动前动手。
实测
SqlDriver.sqliteCanonicalDatetimeSql的 else 分支是coalesce(strftime('%Y-%m-%dT%H:%M:%fZ', col), col)。SQLite 的strftime接受裸数字并按儒略日解释,于是一个Field.datetime列里的裸数字文本会被读成公元前的日期(better-sqlite3 13.0.2 实测):coalesce的兜底在这里帮不上忙:strftime不返回 NULL,它返回了一个自信的错误答案,所以「解析不了的值原样保留」这条设计意图在这一支上不成立。为什么今天基本撞不上
需要「
Field.datetime列里存着裸数字 TEXT」。两种 affinity 的行为不同:datetime列是 NUMERIC 亲和:'12'/'2026'入库即被转成 INTEGER(实测typeof为integer),于是走的是表达式的 integer/real 分支(按 epoch 毫秒),不是儒略日分支。所以 local 基本绕开了。RemoteTransport.mapFieldTypeToSQL把时间列声明为 TEXT:值保持 TEXT,儒略日分支就会命中。这是唯一比较容易到达的一侧。而要有这种值,本身就得是脏数据(合法的 canonical 拼写一定带
-/:/T/Z)。值得记一笔的地方:读是临时的,backfill 是永久的
误读本身自 #3912 起就存在,但那时它只是每次读临时算出来,盘上的原始字节还在。
backfillCanonicalDatetimes的 SET 表达式就是同一个表达式,所以它会把这个儒略日误读写回磁盘,原值不可恢复。PR #6006 让 remote 也有了 backfill,于是这条路径在 remote 侧也从「临时误读」变成了「落盘」。需要说清楚的是:#6004/#6006 没有改变任何查询答案 —— backfill 写下去的正是读路径本来就返回的值(这也是那个模块的核心不变量)。变的只是「原始字节是否还留着」。#6006 的 epoch 恢复限带在 1e12 ≤ v 小于 4102444800000,正是为了不去碰这类小数字,把它们留给共享表达式并计入
unresolvedEpochTextRows。如果将来要处理
倾向不要在共享表达式里加启发式 —— 维护者 2026-08-03 在 cloud#1005 已就近似的提案裁过否决(公共契约 + 每次读都跑 + 会误读合法的数字字符串列)。真要做,更像是在 backfill 的 SET 侧加一道「不把 strftime 的儒略日结果写下去」的守卫(纯迁移期、一次性、可按声明类型收窄),读路径保持原样。
判断留给 PM/维护者分诊。
参考:
packages/drivers/driver-sql/src/sql-driver.ts的sqliteCanonicalDatetimeSql/backfillCanonicalDatetimes;ADR-0053 storage-form 轴(#4191)。Generated by Claude Code