"""SQLAlchemy-backed light-app registry store (global / shared).""" from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.light_apps.base import DuplicateLightAppNameError, LightAppStore from deerflow.persistence.light_apps.model import LightAppRow # Columns a caller may set/patch (everything except identity + timestamps). _WRITABLE = { "name", "description", "app_type", "url", "route_params", "mount_location", "icon", "enabled", "sort_order", "updated_by", } def _to_dict(row: LightAppRow) -> dict[str, Any]: data = row.to_dict() for key in ("created_at", "updated_at"): value = data.get(key) if isinstance(value, datetime): data[key] = value.isoformat() data["route_params"] = data.pop("route_params_json", None) or [] return data def _column_for(key: str) -> str: return "route_params_json" if key == "route_params" else key class LightAppRepository(LightAppStore): def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: self._sf = session_factory async def list_apps( self, *, app_type: str | None = None, mount_location: str | None = None, enabled_only: bool = False, ) -> list[dict[str, Any]]: stmt = select(LightAppRow) if app_type: stmt = stmt.where(LightAppRow.app_type == app_type) if mount_location: stmt = stmt.where(LightAppRow.mount_location == mount_location) if enabled_only: stmt = stmt.where(LightAppRow.enabled.is_(True)) stmt = stmt.order_by(LightAppRow.sort_order.asc(), LightAppRow.created_at.desc()) async with self._sf() as session: result = await session.execute(stmt) return [_to_dict(row) for row in result.scalars()] async def get_app(self, app_id: str) -> dict[str, Any] | None: async with self._sf() as session: row = await session.get(LightAppRow, app_id) return _to_dict(row) if row is not None else None async def _name_taken(self, session: AsyncSession, name: str, *, exclude_id: str | None = None) -> bool: stmt = select(LightAppRow.app_id).where(LightAppRow.name == name) if exclude_id is not None: stmt = stmt.where(LightAppRow.app_id != exclude_id) result = await session.execute(stmt.limit(1)) return result.first() is not None async def create_app(self, data: dict[str, Any]) -> dict[str, Any]: row = LightAppRow( app_id=data.get("app_id") or uuid.uuid4().hex[:12], name=data["name"], description=data.get("description"), app_type=data["app_type"], url=data["url"], route_params_json=data.get("route_params") or [], mount_location=data.get("mount_location"), icon=data.get("icon"), enabled=data.get("enabled", True), sort_order=data.get("sort_order", 0), created_by=data.get("created_by"), updated_by=data.get("created_by"), ) async with self._sf() as session: if await self._name_taken(session, row.name): raise DuplicateLightAppNameError(row.name) session.add(row) try: await session.commit() except IntegrityError as exc: await session.rollback() raise DuplicateLightAppNameError(row.name) from exc await session.refresh(row) return _to_dict(row) async def update_app(self, app_id: str, data: dict[str, Any]) -> dict[str, Any] | None: async with self._sf() as session: row = await session.get(LightAppRow, app_id) if row is None: return None new_name = data.get("name") if new_name is not None and new_name != row.name and await self._name_taken(session, new_name, exclude_id=app_id): raise DuplicateLightAppNameError(new_name) for key, value in data.items(): if key not in _WRITABLE: continue setattr(row, _column_for(key), value) row.updated_at = datetime.now(UTC) try: await session.commit() except IntegrityError as exc: await session.rollback() raise DuplicateLightAppNameError(new_name or row.name) from exc await session.refresh(row) return _to_dict(row) async def delete_app(self, app_id: str) -> bool: async with self._sf() as session: row = await session.get(LightAppRow, app_id) if row is None: return False await session.delete(row) await session.commit() return True