"""SQLAlchemy-backed roundtable business-chain store. The ``seats`` JSON column is stored as a serialized string in ``PortableLongText`` and round-tripped here with ``json.dumps`` / ``json.loads`` — same approach as ``roundtable_drafts`` / ``ai_writing_sessions``. All write/read paths are scoped by ``user_id`` for ownership isolation; a row that belongs to another user is treated as not found. """ from __future__ import annotations import json from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.roundtable_chains.model import RoundtableChainRow _UNSET = object() def _loads(value: Any) -> Any: if not isinstance(value, str): return value if not value: return None try: return json.loads(value) except Exception: return None def _dumps(value: Any) -> str | None: if value is None: return None return json.dumps(value, ensure_ascii=False) class RoundtableChainRepository: def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: self._sf = session_factory @staticmethod def _to_dict(row: RoundtableChainRow) -> dict[str, Any]: return { "id": row.id, "title": row.title, "description": row.description, "seats": _loads(row.seats) or [], "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at, "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at, } async def list_chains(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]: stmt = ( select(RoundtableChainRow) .where(RoundtableChainRow.user_id == user_id) .order_by(RoundtableChainRow.updated_at.desc()) .limit(limit) ) async with self._sf() as session: result = await session.execute(stmt) return [self._to_dict(row) for row in result.scalars()] async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None: async with self._sf() as session: row = await session.get(RoundtableChainRow, chain_id) if row is None or row.user_id != user_id: return None return self._to_dict(row) async def create_chain(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]: now = datetime.now(UTC) row = RoundtableChainRow( id=data["id"], user_id=user_id, title=(data.get("title") or "").strip(), description=(data.get("description") or None), seats=_dumps(data.get("seats") or []), created_at=now, updated_at=now, ) async with self._sf() as session: session.add(row) await session.commit() await session.refresh(row) return self._to_dict(row) async def update_chain( self, chain_id: str, user_id: str, *, title=_UNSET, description=_UNSET, seats=_UNSET, ) -> dict[str, Any] | None: async with self._sf() as session: row = await session.get(RoundtableChainRow, chain_id) if row is None or row.user_id != user_id: return None if title is not _UNSET and title is not None: row.title = str(title).strip() if description is not _UNSET: row.description = description or None if seats is not _UNSET: row.seats = _dumps(seats) row.updated_at = datetime.now(UTC) await session.commit() await session.refresh(row) return self._to_dict(row) async def delete_chain(self, chain_id: str, user_id: str) -> bool: async with self._sf() as session: row = await session.get(RoundtableChainRow, chain_id) if row is None or row.user_id != user_id: return False await session.delete(row) await session.commit() return True