Skip to content

fix(driver-sql): canonical ISO-8601-Z for user NOW()-default temporal fields on SQLite - #2346

Merged
os-zhuang merged 1 commit into
mainfrom
claude/nostalgic-solomon-8e0177
Jun 26, 2026
Merged

fix(driver-sql): canonical ISO-8601-Z for user NOW()-default temporal fields on SQLite#2346
os-zhuang merged 1 commit into
mainfrom
claude/nostalgic-solomon-8e0177

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

What

The ADR-0074 follow-up it explicitly scoped out: a user-declaredField.datetime (or date/time) with defaultValue: 'NOW()' got col.defaultTo(knex.fn.now()) in createColumn. On SQLite that compiles to CURRENT_TIMESTAMP — a timezone-naive'YYYY-MM-DD HH:MM:SS' (no millis, no zone) that Date.parse reads as local time, so the stored UTC wall-clock shifts by the host offset on a non-UTC runtime. The same class of bug ADR-0074 fixed for created_at/updated_at.

Worse, the same datetime column mixed storage: an explicit JS Date is bound by better-sqlite3 as INTEGER epoch ms, while an omitted value took the naive TEXT default — one column holding both.

Fix (SQLite-only; Postgres/MySQL keep native now() and are unaffected)

  • WritenowColumnDefault(type) emits a per-type canonical default via strftime: datetime → ISO-8601-Z (2026-06-26T10:34:13.891Z), date → YYYY-MM-DD, time → HH:MM:SS.fff.
  • ReadformatOutput folds every Field.datetime storage form (INTEGER epoch ms, canonical ISO-Z, legacy naive TEXT → interpreted as UTC) to one canonical ISO-8601-Z instant via normalizeSqliteDatetimeOutput. Idempotent, total, repairs legacy rows on read with no data migration.

A DDL default only governs newly-created columns, so existing-table coverage comes from the read normalization (not app-side stamping like ADR-0074's audit fix) — an explicit Date stays INTEGER regardless, so the INTEGER-vs-TEXT split is inherent to SQLite and resolved at the read boundary. Decision rationale lives in the changeset + the nowColumnDefault comment (no new ADR).

Reconciliation with ADR-0074 (#2342, just merged)

Rebased onto ADR-0074. normalizeSqliteDatetimeOutputreuses its repairNaiveUtcAuditTimestamp for the string shapes (single source of the zone-naive→UTC rules) and adds the INTEGER epoch-ms / Date folding. Consequently a datetime-typed audit column now also presents as canonical ISO-Zmore consistent than before (ADR-0074's own string-typed audit columns already present as ISO-Z). ADR-0074's "datetime-typed created_at reads as epoch-ms number" test is updated to expect the canonical string.

Testing

  • @objectstack/driver-sql: 227 passed (incl. 10 new in sql-driver-user-datetime-default-format.test.ts; ADR-0074's reconciled test green).
  • @objectstack/service-analytics: 164 passed — analytics SQL bucketing reads these columns via strftime, bypassing formatOutput; verified ISO-Z TEXT buckets identically to the old naive TEXT, so no regression.

🤖 Generated with Claude Code

… fields on SQLite
A user-declared Field.datetime (or date/time) with defaultValue:'NOW()' took the
knex.fn.now() -> CURRENT_TIMESTAMP column default on SQLite, storing a
timezone-naive 'YYYY-MM-DD HH:MM:SS' that Date.parse reads as LOCAL time (the same
class of bug ADR-0074 fixed for the builtin audit columns, scoped out there for
user fields). Worse, the same column mixed storage: an explicit Date is bound by
better-sqlite3 as INTEGER epoch ms while an omitted value took the naive TEXT
default.
- createColumn's NOW() default now emits a per-type canonical via strftime
(datetime ISO-8601-Z, date YYYY-MM-DD, time HH:MM:SS.fff) through the new
nowColumnDefault helper.
- formatOutput folds every Field.datetime storage form (INTEGER epoch ms,
canonical ISO-Z, legacy naive TEXT) to one canonical ISO-8601-Z instant
(normalizeSqliteDatetimeOutput), repairing legacy rows on read with no data
migration. SQLite-only; Postgres/MySQL keep native now() and are unaffected.
Follow-up to ADR-0074 (no new ADR), rebased onto it. normalizeSqliteDatetimeOutput
reuses ADR-0074's repairNaiveUtcAuditTimestamp for the string shapes and adds the
INTEGER epoch-ms / Date folding, so a datetime-typed audit column now also presents
as canonical ISO-Z (consistent with every datetime field) — ADR-0074's
"datetime-typed created_at reads as epoch-ms number" test is updated accordingly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJun 26, 2026 12:17pm

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling size/m labels Jun 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/core/plugins.mdx(via @objectstack/driver-sql)
  • content/docs/concepts/implementation-status.mdx(via @objectstack/driver-sql)
  • content/docs/concepts/terminology.mdx(via @objectstack/driver-sql)
  • content/docs/getting-started/glossary.mdx(via @objectstack/driver-sql)
  • content/docs/guides/driver-configuration.mdx(via @objectstack/driver-sql)
  • content/docs/guides/packages.mdx(via @objectstack/driver-sql)
  • content/docs/protocol/objectos/index.mdx(via @objectstack/driver-sql)
  • content/docs/protocol/objectos/lifecycle.mdx(via @objectstack/driver-sql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit 8a7e9f1 into mainJun 26, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/nostalgic-solomon-8e0177 branch June 26, 2026 12:21
os-zhuang added a commit that referenced this pull request Jun 26, 2026
…ad (SQLite) (#2347)
Field.time is a tz-naive time-of-day, not an instant (#2004). A
defaultValue:'NOW()' time column historically took the full SQLite
CURRENT_TIMESTAMP default, so a defaulted/legacy row read back a full
'YYYY-MM-DD HH:MM:SS' timestamp instead of a time-of-day.
formatOutput now repairs a Field.time value to just its time portion (toTimeOnly):
a legacy full timestamp (or a full ISO value that leaked into the column) is
sliced to HH:MM[:SS[.fff]], while a value already stored as a bare time-of-day is
left untouched. Deliberately NARROW + read-only (no write/filter counterpart) so
it introduces no write/read asymmetry and preserves exact round-trips for bare
time-of-day values (the field-zoo f_time guard). Runs for every dialect (a native
TIME column already returns a time-of-day → no-op). Adds a timeFields registry +
registration mirroring dateFields/datetimeFields.
Completes the temporal read normalization alongside #2346: datetime -> ISO-8601-Z,
date -> YYYY-MM-DD, time -> wall-clock time-of-day.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@os-zhuang