mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 17:06:05 +00:00
Group them by bounded context instead of by technology, one file per
port, and align the directory name with the AWS Prescriptive Guidance
layout (entrypoints / domain-with-ports / adapters).
app/infra/persistence/feedback.py
-> app/adapters/feedback/feedback_repository.py owned persistence
-> app/adapters/feedback/run_lookup.py anti-corruption layer
`persistence/` promised a technology-first classification that its own
contents contradicted: RunStoreRunLookup lived there while its docstring
said "no new SQL". Splitting per port makes that distinction structural.
SqlFeedbackRepository and _tz_aware move unchanged -- verified by
comparing their AST against the original rather than by eye. run_lookup.py
additionally gains a RunStore annotation behind TYPE_CHECKING (the module
is imported lazily by the composition root, so this keeps the runtime
import cost at zero), a docstring stating that this context owns no table
and writes no SQL against it, and a TODO recording the condition under
which the body is replaced: when the run context publishes a contract of
its own, the RunLookup port itself does not move.
Each module docstring opens with a fixed marker so the two kinds of
secondary adapter stay greppable:
grep -rl "anti-corruption layer" app/adapters/
Filenames deliberately carry no sql_ / acl_ prefix: a prefix encodes an
implementation property, so switching storage would force a rename even
though the port -- and therefore the import path -- has not changed. The
class name already carries it. A prefix earns its place once one port has
several production implementations, which is not yet the case here.
app/infra/ held nothing else and is removed.
112 lines
4.4 KiB
Python
112 lines
4.4 KiB
Python
"""Regression tests for #3120: SQLite-backed stores must emit tz-aware ISO timestamps.
|
|
|
|
SQLAlchemy's ``DateTime(timezone=True)`` is a no-op on SQLite because the
|
|
backend has no native timezone type, so values read back are naive
|
|
``datetime`` instances. The four SQL ``_row_to_dict`` helpers therefore
|
|
have to normalize through :func:`deerflow.utils.time.coerce_iso` instead
|
|
of calling ``.isoformat()`` directly; otherwise the API ships
|
|
timezone-less strings (e.g. ``"2026-05-20T06:10:22.970977"``) and the
|
|
frontend's ``new Date(...)`` parses them as local time, shifting recent
|
|
threads by the local UTC offset.
|
|
"""
|
|
|
|
import re
|
|
|
|
import pytest
|
|
|
|
_TZ_SUFFIX_RE = re.compile(r"(?:\+\d{2}:\d{2}|Z)$")
|
|
|
|
|
|
def _assert_tz_aware(value: str | None, *, context: str) -> None:
|
|
assert value, f"{context}: expected ISO string, got {value!r}"
|
|
assert _TZ_SUFFIX_RE.search(value), f"{context}: timestamp lacks tz suffix: {value!r}"
|
|
|
|
|
|
async def _init_sqlite(tmp_path):
|
|
from deerflow.persistence.engine import get_session_factory, init_engine
|
|
|
|
url = f"sqlite+aiosqlite:///{tmp_path / 'tz.db'}"
|
|
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
|
return get_session_factory()
|
|
|
|
|
|
async def _cleanup():
|
|
from deerflow.persistence.engine import close_engine
|
|
|
|
await close_engine()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_thread_meta_emits_tz_aware_timestamps(tmp_path):
|
|
from deerflow.persistence.thread_meta import ThreadMetaRepository
|
|
|
|
repo = ThreadMetaRepository(await _init_sqlite(tmp_path))
|
|
try:
|
|
created = await repo.create("t-tz", user_id="u1", display_name="tz")
|
|
_assert_tz_aware(created["created_at"], context="thread_meta.create.created_at")
|
|
_assert_tz_aware(created["updated_at"], context="thread_meta.create.updated_at")
|
|
|
|
# Second read from DB exercises the same _row_to_dict path on a
|
|
# value that SQLite has round-tripped (where tzinfo is lost).
|
|
fetched = await repo.get("t-tz", user_id="u1")
|
|
_assert_tz_aware(fetched["created_at"], context="thread_meta.get.created_at")
|
|
_assert_tz_aware(fetched["updated_at"], context="thread_meta.get.updated_at")
|
|
|
|
listed = await repo.search(user_id="u1")
|
|
assert listed, "search must return the created row"
|
|
_assert_tz_aware(listed[0]["created_at"], context="thread_meta.search.created_at")
|
|
_assert_tz_aware(listed[0]["updated_at"], context="thread_meta.search.updated_at")
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_repository_emits_tz_aware_timestamps(tmp_path):
|
|
from deerflow.persistence.run import RunRepository
|
|
|
|
repo = RunRepository(await _init_sqlite(tmp_path))
|
|
try:
|
|
await repo.put("r-tz", thread_id="t-tz", user_id="u1")
|
|
row = await repo.get("r-tz", user_id="u1")
|
|
_assert_tz_aware(row["created_at"], context="run.get.created_at")
|
|
_assert_tz_aware(row["updated_at"], context="run.get.updated_at")
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_feedback_repository_emits_tz_aware_timestamps(tmp_path):
|
|
from app.adapters.feedback.feedback_repository import SqlFeedbackRepository
|
|
from deerflow.domain.feedback import Feedback
|
|
|
|
repo = SqlFeedbackRepository(await _init_sqlite(tmp_path))
|
|
try:
|
|
fb = await repo.save(Feedback.create(run_id="r-tz", thread_id="t-tz", rating=1, user_id="u1"))
|
|
# The port exchanges domain objects (datetime), not ISO strings;
|
|
# serialization to ISO happens in the router. Assert tz-awareness here.
|
|
assert fb.created_at.tzinfo is not None, "feedback.save.created_at lacks tzinfo"
|
|
read_back = (await repo.latest_per_run_in_thread("t-tz", user_id="u1"))["r-tz"]
|
|
assert read_back.created_at.tzinfo is not None, "feedback read-back lacks tzinfo"
|
|
finally:
|
|
await _cleanup()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_run_event_store_emits_tz_aware_timestamps(tmp_path):
|
|
from deerflow.runtime.events.store.db import DbRunEventStore
|
|
|
|
store = DbRunEventStore(await _init_sqlite(tmp_path))
|
|
try:
|
|
await store.put(
|
|
thread_id="t-tz",
|
|
run_id="r-tz",
|
|
event_type="log",
|
|
category="log",
|
|
content="hello",
|
|
)
|
|
events = await store.list_events("t-tz", "r-tz", user_id=None)
|
|
assert events, "expected at least one event"
|
|
_assert_tz_aware(events[0]["created_at"], context="run_event.list.created_at")
|
|
finally:
|
|
await _cleanup()
|