From 51cec3751420fea1d9ae1c895b295d45e8c8f807 Mon Sep 17 00:00:00 2001 From: Zaki Date: Sat, 7 Mar 2026 17:32:18 -0800 Subject: [PATCH] fix(libsql): standardize timestamp storage to RFC 3339 with UTC offset (#663) Replace all `datetime('now')` defaults in libsql_migrations.rs with `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` so new rows get proper RFC 3339 timestamps (e.g. `2024-01-15T10:30:00.123Z`) instead of naive datetimes (e.g. `2024-01-15 10:30:00`). Add tracing::warn! to parse_timestamp() naive fallback paths so legacy timestamps are still accepted but produce a visible deprecation signal. Backward compatible: no data migration needed; existing naive timestamps continue to parse correctly via the multi-format fallback. Co-Authored-By: Claude Opus 4.6 --- src/db/libsql/mod.rs | 102 +++++++++++++++++++++++++++++++++ src/db/libsql_migrations.rs | 110 ++++++++++++++++++------------------ 2 files changed, 157 insertions(+), 55 deletions(-) diff --git a/src/db/libsql/mod.rs b/src/db/libsql/mod.rs index 6ff8ca6b..299c09cb 100644 --- a/src/db/libsql/mod.rs +++ b/src/db/libsql/mod.rs @@ -169,10 +169,18 @@ pub(crate) fn parse_timestamp(s: &str) -> Result, String> { } // Naive with fractional seconds (legacy or SQLite datetime() output) if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") { + tracing::warn!( + timestamp = s, + "parsing naive timestamp without timezone; assuming UTC — consider re-running migrations" + ); return Ok(ndt.and_utc()); } // Naive without fractional seconds (legacy format) if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + tracing::warn!( + timestamp = s, + "parsing naive timestamp without timezone; assuming UTC — consider re-running migrations" + ); return Ok(ndt.and_utc()); } Err(format!("unparseable timestamp: {:?}", s)) @@ -510,4 +518,98 @@ mod tests { ); } } + + #[test] + fn test_parse_timestamp_rfc3339() { + use super::parse_timestamp; + + // Standard RFC 3339 with Z suffix + let dt = parse_timestamp("2024-01-15T10:30:00.123Z").unwrap(); + assert_eq!( + dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + "2024-01-15T10:30:00.123Z" + ); + + // RFC 3339 with +00:00 offset + let dt = parse_timestamp("2024-01-15T10:30:00.000+00:00").unwrap(); + assert_eq!( + dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + "2024-01-15T10:30:00.000Z" + ); + } + + #[test] + fn test_parse_timestamp_naive_fallback() { + use super::parse_timestamp; + + // Naive with fractional seconds (legacy datetime('now') output) + let dt = parse_timestamp("2024-01-15 10:30:00.123").unwrap(); + assert_eq!( + dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + "2024-01-15T10:30:00.123Z" + ); + + // Naive without fractional seconds + let dt = parse_timestamp("2024-01-15 10:30:00").unwrap(); + assert_eq!( + dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + "2024-01-15T10:30:00.000Z" + ); + } + + #[test] + fn test_parse_timestamp_invalid() { + use super::parse_timestamp; + + assert!(parse_timestamp("not-a-timestamp").is_err()); + assert!(parse_timestamp("").is_err()); + } + + #[tokio::test] + async fn test_default_timestamps_are_rfc3339() { + // Verify that DEFAULT column values produce RFC 3339 timestamps + // after the migration change from datetime('now') to strftime. + // Use file-based DB because in-memory doesn't share schema across connections. + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test_ts.db"); + let backend = LibSqlBackend::new_local(&db_path).await.unwrap(); + backend.run_migrations().await.unwrap(); + + let conn = backend.connect().await.unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO conversations (id, channel, user_id) VALUES (?1, ?2, ?3)", + libsql::params![id.clone(), "test", "user1"], + ) + .await + .unwrap(); + + let mut rows = conn + .query( + "SELECT started_at, last_activity FROM conversations WHERE id = ?1", + libsql::params![id], + ) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + let started_at: String = row.get(0).unwrap(); + let last_activity: String = row.get(1).unwrap(); + + // Must end with 'Z' (RFC 3339 UTC) and contain 'T' separator + assert!( + started_at.ends_with('Z') && started_at.contains('T'), + "started_at should be RFC 3339, got: {started_at}" + ); + assert!( + last_activity.ends_with('Z') && last_activity.contains('T'), + "last_activity should be RFC 3339, got: {last_activity}" + ); + + // Must be parseable by the RFC 3339 parser directly (not just naive fallback) + use chrono::DateTime; + assert!( + DateTime::parse_from_rfc3339(&started_at).is_ok(), + "started_at not valid RFC 3339: {started_at}" + ); + } } diff --git a/src/db/libsql_migrations.rs b/src/db/libsql_migrations.rs index 084ae53d..63708235 100644 --- a/src/db/libsql_migrations.rs +++ b/src/db/libsql_migrations.rs @@ -26,7 +26,7 @@ pub const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS _migrations ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, - applied_at TEXT NOT NULL DEFAULT (datetime('now')) + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -- ==================== Conversations ==================== @@ -36,8 +36,8 @@ CREATE TABLE IF NOT EXISTS conversations ( channel TEXT NOT NULL, user_id TEXT NOT NULL, thread_id TEXT, - started_at TEXT NOT NULL DEFAULT (datetime('now')), - last_activity TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_activity TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), metadata TEXT NOT NULL DEFAULT '{}' ); @@ -59,7 +59,7 @@ CREATE TABLE IF NOT EXISTS conversation_messages ( conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL, content TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_conversation_messages_conversation @@ -91,7 +91,7 @@ CREATE TABLE IF NOT EXISTS agent_jobs ( failure_reason TEXT, stuck_since TEXT, repair_attempts INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), started_at TEXT, completed_at TEXT ); @@ -116,7 +116,7 @@ CREATE TABLE IF NOT EXISTS job_actions ( duration_ms INTEGER, success INTEGER NOT NULL, error_message TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE(job_id, sequence_num) ); @@ -137,8 +137,8 @@ CREATE TABLE IF NOT EXISTS dynamic_tools ( failure_count INTEGER NOT NULL DEFAULT 0, last_error TEXT, status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_dynamic_tools_status ON dynamic_tools(status); @@ -156,7 +156,7 @@ CREATE TABLE IF NOT EXISTS llm_calls ( output_tokens INTEGER NOT NULL, cost TEXT NOT NULL, purpose TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_llm_calls_job ON llm_calls(job_id); @@ -176,7 +176,7 @@ CREATE TABLE IF NOT EXISTS estimation_snapshots ( actual_time_secs INTEGER, estimated_value TEXT NOT NULL, actual_value TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_estimation_category ON estimation_snapshots(category); @@ -192,7 +192,7 @@ CREATE TABLE IF NOT EXISTS repair_attempts ( action_taken TEXT NOT NULL, success INTEGER NOT NULL, error_message TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_repair_attempts_target ON repair_attempts(target_type, target_id); @@ -206,8 +206,8 @@ CREATE TABLE IF NOT EXISTS memory_documents ( agent_id TEXT, path TEXT NOT NULL, content TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), metadata TEXT NOT NULL DEFAULT '{}', UNIQUE (user_id, agent_id, path) ); @@ -222,7 +222,7 @@ CREATE TRIGGER IF NOT EXISTS update_memory_documents_updated_at FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at BEGIN - UPDATE memory_documents SET updated_at = datetime('now') WHERE id = NEW.id; + UPDATE memory_documents SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = NEW.id; END; -- ==================== Workspace: Memory Chunks ==================== @@ -234,7 +234,7 @@ CREATE TABLE IF NOT EXISTS memory_chunks ( chunk_index INTEGER NOT NULL, content TEXT NOT NULL, embedding BLOB, - created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE (document_id, chunk_index) ); @@ -296,8 +296,8 @@ CREATE TABLE IF NOT EXISTS secrets ( expires_at TEXT, last_used_at TEXT, usage_count INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE (user_id, name) ); @@ -318,8 +318,8 @@ CREATE TABLE IF NOT EXISTS wasm_tools ( source_url TEXT, trust_level TEXT NOT NULL DEFAULT 'user', status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE (user_id, name, version) ); @@ -340,8 +340,8 @@ CREATE TABLE IF NOT EXISTS wasm_channels ( binary_hash BLOB NOT NULL, capabilities_json TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE (user_id, name) ); @@ -359,8 +359,8 @@ CREATE TABLE IF NOT EXISTS tool_capabilities ( max_response_body_bytes INTEGER NOT NULL DEFAULT 10485760, workspace_read_prefixes TEXT NOT NULL DEFAULT '[]', http_timeout_secs INTEGER NOT NULL DEFAULT 30, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE (wasm_tool_id) ); @@ -373,7 +373,7 @@ CREATE TABLE IF NOT EXISTS leak_detection_patterns ( severity TEXT NOT NULL DEFAULT 'high', action TEXT NOT NULL DEFAULT 'block', enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -- ==================== Rate Limit State ==================== @@ -382,9 +382,9 @@ CREATE TABLE IF NOT EXISTS tool_rate_limit_state ( id TEXT PRIMARY KEY, wasm_tool_id TEXT NOT NULL REFERENCES wasm_tools(id) ON DELETE CASCADE, user_id TEXT NOT NULL, - minute_window_start TEXT NOT NULL DEFAULT (datetime('now')), + minute_window_start TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), minute_count INTEGER NOT NULL DEFAULT 0, - hour_window_start TEXT NOT NULL DEFAULT (datetime('now')), + hour_window_start TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), hour_count INTEGER NOT NULL DEFAULT 0, UNIQUE (wasm_tool_id, user_id) ); @@ -400,7 +400,7 @@ CREATE TABLE IF NOT EXISTS secret_usage_log ( target_path TEXT, success INTEGER NOT NULL, error_message TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_secret_usage_user ON secret_usage_log(user_id); @@ -415,7 +415,7 @@ CREATE TABLE IF NOT EXISTS leak_detection_events ( source TEXT NOT NULL, action_taken TEXT NOT NULL, context_preview TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); -- ==================== Tool Failures ==================== @@ -425,8 +425,8 @@ CREATE TABLE IF NOT EXISTS tool_failures ( tool_name TEXT NOT NULL UNIQUE, error_message TEXT, error_count INTEGER DEFAULT 1, - first_failure TEXT DEFAULT (datetime('now')), - last_failure TEXT DEFAULT (datetime('now')), + first_failure TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_failure TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), last_build_result TEXT, repaired_at TEXT, repair_attempts INTEGER DEFAULT 0 @@ -441,7 +441,7 @@ CREATE TABLE IF NOT EXISTS job_events ( job_id TEXT NOT NULL REFERENCES agent_jobs(id), event_type TEXT NOT NULL, data TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_job_events_job ON job_events(job_id, id); @@ -471,8 +471,8 @@ CREATE TABLE IF NOT EXISTS routines ( next_fire_at TEXT, run_count INTEGER NOT NULL DEFAULT 0, consecutive_failures INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE (user_id, name) ); @@ -485,13 +485,13 @@ CREATE TABLE IF NOT EXISTS routine_runs ( routine_id TEXT NOT NULL REFERENCES routines(id) ON DELETE CASCADE, trigger_type TEXT NOT NULL, trigger_detail TEXT, - started_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), completed_at TEXT, status TEXT NOT NULL DEFAULT 'running', result_summary TEXT, tokens_used INTEGER, job_id TEXT REFERENCES agent_jobs(id), - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE INDEX IF NOT EXISTS idx_routine_runs_routine ON routine_runs(routine_id); @@ -502,7 +502,7 @@ CREATE TABLE IF NOT EXISTS settings ( user_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), PRIMARY KEY (user_id, key) ); @@ -558,24 +558,24 @@ CREATE INDEX IF NOT EXISTS idx_heartbeat_next_run ON heartbeat_state(next_run); -- Pre-populate leak detection patterns (matches PostgreSQL V2 migration). INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, action, enabled, created_at) VALUES - ('550e8400-e29b-41d4-a716-446655440001', 'openai_api_key', 'sk-(?:proj-)?[a-zA-Z0-9]{20,}(?:T3BlbkFJ[a-zA-Z0-9_-]*)?', 'critical', 'block', 1, datetime('now')), - ('550e8400-e29b-41d4-a716-446655440002', 'anthropic_api_key', 'sk-ant-api[a-zA-Z0-9_-]{90,}', 'critical', 'block', 1, datetime('now')), - ('550e8400-e29b-41d4-a716-446655440003', 'aws_access_key', 'AKIA[0-9A-Z]{16}', 'critical', 'block', 1, datetime('now')), - ('550e8400-e29b-41d4-a716-446655440004', 'aws_secret_key', '(?