fix(persistence): emit tz-aware timestamps from SQLite-backed stores (#3130)

SQLAlchemy's DateTime(timezone=True) is a no-op on SQLite (the backend
has no native tz type), so values round-tripped through the DB come
back as naive datetimes. The four SQL _row_to_dict helpers were calling
.isoformat() directly on those naive values, shipping timezone-less
strings like "2026-05-20T06:10:22.970977" out of the API. The browser's
new Date(...) then parses them as local time, shifting recent threads
in /threads/search by the local UTC offset (about 8h in Asia/Shanghai).

Route the four call sites through coerce_iso() instead — it already
normalizes naive values as UTC and emits "+00:00" so the wire format
always carries tz. No data migration is needed; existing SQLite rows
read back via the corrected serializer.

PostgreSQL deployments are unaffected because timestamptz preserves
tzinfo end-to-end.

Closes #3120
This commit is contained in:
Xinmin Zeng
2026-05-21 16:22:09 +08:00
committed by GitHub
parent 923f516deb
commit 31513c2ccb
5 changed files with 122 additions and 5 deletions
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
from deerflow.utils.time import coerce_iso
class FeedbackRepository:
@@ -24,7 +25,8 @@ class FeedbackRepository:
d = row.to_dict()
val = d.get("created_at")
if isinstance(val, datetime):
d["created_at"] = val.isoformat()
# SQLite drops tzinfo on read; normalize via ``coerce_iso`` so output is always tz-aware.
d["created_at"] = coerce_iso(val)
return d
async def create(
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.run.model import RunRow
from deerflow.runtime.runs.store.base import RunStore
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
from deerflow.utils.time import coerce_iso
class RunRepository(RunStore):
@@ -68,11 +69,13 @@ class RunRepository(RunStore):
# Remap JSON columns to match RunStore interface
d["metadata"] = d.pop("metadata_json", {})
d["kwargs"] = d.pop("kwargs_json", {})
# Convert datetime to ISO string for consistency with MemoryRunStore
# Convert datetime to ISO string for consistency with MemoryRunStore.
# SQLite drops tzinfo on read despite ``DateTime(timezone=True)`` —
# ``coerce_iso`` normalizes naive datetimes as UTC.
for key in ("created_at", "updated_at"):
val = d.get(key)
if isinstance(val, datetime):
d[key] = val.isoformat()
d[key] = coerce_iso(val)
return d
async def put(
@@ -13,6 +13,7 @@ from deerflow.persistence.json_compat import json_match
from deerflow.persistence.thread_meta.base import InvalidMetadataFilterError, ThreadMetaStore
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
from deerflow.utils.time import coerce_iso
logger = logging.getLogger(__name__)
@@ -28,7 +29,9 @@ class ThreadMetaRepository(ThreadMetaStore):
for key in ("created_at", "updated_at"):
val = d.get(key)
if isinstance(val, datetime):
d[key] = val.isoformat()
# SQLite drops tzinfo despite ``DateTime(timezone=True)``;
# ``coerce_iso`` normalizes naive values as UTC so the wire format always carries tz.
d[key] = coerce_iso(val)
return d
async def create(
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.runtime.events.store.base import RunEventStore
from deerflow.runtime.user_context import AUTO, _AutoSentinel, get_current_user, resolve_user_id
from deerflow.utils.time import coerce_iso
logger = logging.getLogger(__name__)
@@ -32,7 +33,9 @@ class DbRunEventStore(RunEventStore):
d["metadata"] = d.pop("event_metadata", {})
val = d.get("created_at")
if isinstance(val, datetime):
d["created_at"] = val.isoformat()
# SQLite drops tzinfo on read despite ``DateTime(timezone=True)``;
# ``coerce_iso`` normalizes naive datetimes as UTC.
d["created_at"] = coerce_iso(val)
d.pop("id", None)
# Restore structured content that was JSON-serialized on write.
raw = d.get("content", "")