SQLite Datetime Format
Status¶
Accepted
Superseded context: Lucia was removed entirely in PR #290. The lucia_sessions table name is
retained for historical/compatibility reasons but is now managed by the direct D1 session manager
in functions/utils/auth.js, not by Lucia. The INTEGER Unix-epoch expires_at invariant described
below still holds exactly as written.
Context¶
SetTimes uses Cloudflare D1 as its primary database. D1 is SQLite-compatible, and SQLite TEXT comparisons are strictly lexicographic — they compare strings character-by-character using ASCII values.
Two incompatible datetime string formats are in play:
- D1 native format:
datetime('now')returnsYYYY-MM-DD HH:MM:SS(space separator, no timezone suffix). - JavaScript native format:
new Date().toISOString()returnsYYYY-MM-DDThh:mm:ssZ(T separator, Z suffix).
The ASCII value of T (84) is greater than the ASCII value of space (32). This means that at any given instant, the T-separated ISO 8601 representation of a datetime sorts lexicographically higher than the equivalent space-separated SQLite representation of the same instant:
"2026-05-14 12:00:00" < "2026-05-14T12:00:00Z"
If a T-separated value is stored in an expires_at TEXT column and compared with expires_at > datetime('now'), the condition evaluates to true for every stored value — the record appears unexpired regardless of its actual expiry time. This is a silent correctness failure with no runtime error.
This comparison pattern is used across the codebase for invite codes, MFA challenges, trusted devices, and shared schedule links. A production incident (SEC-F1) confirmed the failure mode: invite codes with T-separated expires_at values never expired because every record always passed the > datetime('now') check.
A secondary complexity exists with the lucia_sessions table, which stores expires_at as INTEGER (Unix epoch seconds) per Lucia v3 internals. That table requires > unixepoch() for comparisons and is explicitly excluded from this convention.
Decision¶
All datetime values written to D1 TEXT columns must be normalized to space-separated SQLite format before storage using the pattern:
new Date(Date.now() + ms).toISOString().replace("T", " ").slice(0, 19);
A reusable helper toSqliteDateTime(date) is defined in functions/utils/authAttempts.js (line 55). New code that stores datetimes in D1 TEXT columns must use toSqliteDateTime() or the equivalent inline pattern. ISO 8601 strings from JavaScript must never be stored directly in D1 TEXT columns.
This convention applies to all expires_at TEXT columns in the schema, including:
invite_codes.expires_atmfa_challenges.expires_attrusted_devices.expires_atshared_schedules.expires_at- Any future TEXT datetime columns
The sole exception is lucia_sessions.expires_at, which is INTEGER and must be compared using > unixepoch().
Consequences¶
Positive¶
- POS-001: Datetime comparisons using
> datetime('now')and< datetime('now')produce correct results for all TEXTexpires_atcolumns. - POS-002: The
toSqliteDateTime()helper infunctions/utils/authAttempts.jscentralizes normalization logic, reducing the surface for future mistakes. - POS-003: The format is human-readable in D1 console queries and database exports without any conversion.
- POS-004: The production incident (SEC-F1) that permitted infinite invite code validity cannot recur when this convention is followed.
Negative¶
- NEG-001: The
lucia_sessions.expires_atcolumn uses INTEGER (Unix epoch) rather than TEXT, which requires a different comparison operator (> unixepoch()instead of> datetime('now')). Developers must be aware of this two-format system. - NEG-002:
toSqliteDateTime()is not exported from a shared utility index, so call sites outsideauthAttempts.jsinline the pattern directly (e.g.,functions/utils/trustedDevice.js,functions/utils/tokens.js,functions/api/admin/invite-codes.js). There is no compile-time enforcement; the convention must be maintained by convention and code review. - NEG-003: JavaScript
Dateobjects carry millisecond precision that is silently discarded by.slice(0, 19). Code that relies on sub-second expiry precision will not behave correctly.
Alternatives Considered¶
Store as ISO 8601 with T separator¶
- ALT-001: Description: Store
toISOString()output directly without transformation (e.g.,2026-05-14T12:00:00.000Z). - ALT-002: Rejection Reason: ASCII value of
T(84) > space (32), so a T-separated value always compares greater thandatetime('now')at the same position. The> datetime('now')expiry check silently never triggers. This is the root cause of production incident SEC-F1 and must not be reintroduced.
Store as Unix epoch integers¶
- ALT-003: Description: Store all
expires_atcolumns as INTEGER (Unix epoch seconds), matching the storage format Lucia v3 uses forlucia_sessions.expires_at. - ALT-004: Rejection Reason: All existing
expires_atTEXT columns are defined as TEXT in the schema with 38 migrations already applied. Migrating them all to INTEGER would require a coordinated schema migration and updates to every query that reads or compares those columns. The inconsistency Lucia v3 introduces for its own table is an accepted exception, not a model to extend to application-owned columns.
Use a database trigger or generated column to normalize on insert¶
- ALT-005: Description: Define a
BEFORE INSERTtrigger or a generated column that coerces any stored value to SQLite datetime format automatically. - ALT-006: Rejection Reason: Cloudflare D1 does not reliably support triggers or generated columns at migration time. D1's SQLite subset excludes the SQL features needed, and relying on schema-level enforcement that may silently fail or be unavailable in all D1 runtime versions is not a safe foundation for a security-critical property.
Implementation Notes¶
- IMP-001: The canonical helper is
toSqliteDateTime(date)atfunctions/utils/authAttempts.js:55. When adding a new datetime write, prefer importing and calling this function over inlining the pattern. - IMP-002: Any code path that constructs a D1 TEXT datetime from a JavaScript
DateorDate.now()must pass throughtoSqliteDateTime()or the equivalent inline.toISOString().replace("T", " ").slice(0, 19)pattern. - IMP-003: Code review checklist: flag any
.toISOString()result stored in a D1 column without the.replace("T", " ")transformation. - IMP-004: The
lucia_sessionstable is owned by Lucia v3 internals and uses INTEGER epochs. Compare itsexpires_atcolumn with> unixepoch(), never with> datetime('now'). - IMP-005: Success criterion: no
expires_atTEXT column in D1 ever contains aTcharacter. This can be audited with:SELECT COUNT(*) FROM <table> WHERE expires_at LIKE '%T%'.
References¶
- REF-001:
CLAUDE.md— "SQLite datetime format" section documents this invariant for AI assistants working in this codebase. - REF-002:
functions/utils/authAttempts.js:55—toSqliteDateTime()helper implementation. - REF-003:
functions/utils/trustedDevice.js:75—createTrustedDeviceexpiresAt normalization. - REF-004:
functions/api/admin/auth/login.js:280— MFA challenge expiresAt normalization. - REF-005: SQLite documentation — Date and Time Functions — specifies that
datetime('now')returnsYYYY-MM-DD HH:MM:SSformat. - REF-006: ADR-0003: Cloudflare D1 as Primary Database — background on D1 adoption and its SQLite compatibility constraints.