2026-06-16 16:18:49 -07:00
import logging
2026-03-31 18:39:45 -07:00
import uuid
2025-03-14 18:53:14 -07:00
from collections . abc import AsyncGenerator
2026-02-28 23:59:28 -08:00
from contextlib import asynccontextmanager
2025-07-24 14:43:48 -07:00
from datetime import UTC , datetime
2026-02-26 18:24:57 -08:00
from enum import StrEnum
2025-03-14 18:53:14 -07:00
2026-02-28 23:59:28 -08:00
import anyio
2025-03-14 18:53:14 -07:00
from fastapi import Depends
2025-07-25 10:52:34 -07:00
from fastapi_users . db import SQLAlchemyBaseUserTableUUID , SQLAlchemyUserDatabase
2025-03-14 18:53:14 -07:00
from pgvector . sqlalchemy import Vector
from sqlalchemy import (
ARRAY ,
2025-07-24 14:43:48 -07:00
JSON ,
TIMESTAMP ,
2026-04-15 17:02:00 -07:00
BigInteger ,
2025-03-14 18:53:14 -07:00
Boolean ,
2026-05-27 23:34:46 +05:30
CheckConstraint ,
2025-03-14 18:53:14 -07:00
Column ,
Enum as SQLAlchemyEnum ,
ForeignKey ,
2026-03-10 01:26:37 -07:00
Index ,
2025-03-14 18:53:14 -07:00
Integer ,
String ,
Text ,
2025-08-02 11:47:49 -07:00
UniqueConstraint ,
2025-03-14 18:53:14 -07:00
text ,
)
2025-11-23 15:23:31 +05:30
from sqlalchemy . dialects . postgresql import JSONB , UUID
2025-03-14 18:53:14 -07:00
from sqlalchemy . ext . asyncio import AsyncSession , async_sessionmaker , create_async_engine
2026-04-08 14:48:40 +05:30
from sqlalchemy . orm import DeclarativeBase , Mapped , backref , declared_attr , relationship
2025-03-14 18:53:14 -07:00
from app . config import config
2025-05-21 20:56:23 -07:00
if config . AUTH_TYPE == " GOOGLE " :
2025-07-25 10:52:34 -07:00
from fastapi_users . db import SQLAlchemyBaseOAuthAccountTableUUID
2025-05-21 20:56:23 -07:00
2026-06-16 16:18:49 -07:00
logger = logging . getLogger ( __name__ )
2025-03-14 18:53:14 -07:00
DATABASE_URL = config . DATABASE_URL
2026-02-26 18:24:57 -08:00
class DocumentType ( StrEnum ) :
2025-03-14 18:53:14 -07:00
EXTENSION = " EXTENSION "
CRAWLED_URL = " CRAWLED_URL "
FILE = " FILE "
SLACK_CONNECTOR = " SLACK_CONNECTOR "
2026-01-07 15:15:49 -08:00
TEAMS_CONNECTOR = " TEAMS_CONNECTOR "
2026-03-28 14:31:25 +05:30
ONEDRIVE_FILE = " ONEDRIVE_FILE "
2025-03-14 18:53:14 -07:00
NOTION_CONNECTOR = " NOTION_CONNECTOR "
2025-04-09 18:46:10 -07:00
YOUTUBE_VIDEO = " YOUTUBE_VIDEO "
2025-04-13 13:56:22 -07:00
GITHUB_CONNECTOR = " GITHUB_CONNECTOR "
2025-04-15 23:10:35 -07:00
LINEAR_CONNECTOR = " LINEAR_CONNECTOR "
2025-06-02 18:30:38 +07:00
DISCORD_CONNECTOR = " DISCORD_CONNECTOR "
2025-07-24 11:33:38 +02:00
JIRA_CONNECTOR = " JIRA_CONNECTOR "
2025-07-26 14:43:31 +02:00
CONFLUENCE_CONNECTOR = " CONFLUENCE_CONNECTOR "
2025-07-30 21:35:27 +02:00
CLICKUP_CONNECTOR = " CLICKUP_CONNECTOR "
2025-08-02 00:05:55 +02:00
GOOGLE_CALENDAR_CONNECTOR = " GOOGLE_CALENDAR_CONNECTOR "
2025-08-04 00:52:07 +02:00
GOOGLE_GMAIL_CONNECTOR = " GOOGLE_GMAIL_CONNECTOR "
2025-12-29 20:38:26 +02:00
GOOGLE_DRIVE_FILE = " GOOGLE_DRIVE_FILE "
2025-08-26 13:56:31 +02:00
AIRTABLE_CONNECTOR = " AIRTABLE_CONNECTOR "
2025-09-28 14:59:10 -07:00
LUMA_CONNECTOR = " LUMA_CONNECTOR "
2025-10-12 09:39:04 +05:30
ELASTICSEARCH_CONNECTOR = " ELASTICSEARCH_CONNECTOR "
2025-12-04 14:08:44 +08:00
BOOKSTACK_CONNECTOR = " BOOKSTACK_CONNECTOR "
2025-12-30 09:00:59 -08:00
CIRCLEBACK = " CIRCLEBACK "
2026-01-21 15:21:06 -08:00
OBSIDIAN_CONNECTOR = " OBSIDIAN_CONNECTOR "
2025-12-16 12:28:30 +05:30
NOTE = " NOTE "
2026-03-30 21:56:50 +05:30
DROPBOX_FILE = " DROPBOX_FILE "
2026-01-22 22:33:28 +05:30
COMPOSIO_GOOGLE_DRIVE_CONNECTOR = " COMPOSIO_GOOGLE_DRIVE_CONNECTOR "
COMPOSIO_GMAIL_CONNECTOR = " COMPOSIO_GMAIL_CONNECTOR "
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR = " COMPOSIO_GOOGLE_CALENDAR_CONNECTOR "
2026-04-02 10:35:32 +05:30
LOCAL_FOLDER_FILE = " LOCAL_FOLDER_FILE "
2025-03-14 18:53:14 -07:00
2025-07-24 14:43:48 -07:00
2026-03-20 03:41:32 +05:30
# Native Google document types → their legacy Composio equivalents.
# Old documents may still carry the Composio type until they are re-indexed;
# search, browse, and indexing must transparently handle both.
NATIVE_TO_LEGACY_DOCTYPE : dict [ str , str ] = {
" GOOGLE_DRIVE_FILE " : " COMPOSIO_GOOGLE_DRIVE_CONNECTOR " ,
" GOOGLE_GMAIL_CONNECTOR " : " COMPOSIO_GMAIL_CONNECTOR " ,
" GOOGLE_CALENDAR_CONNECTOR " : " COMPOSIO_GOOGLE_CALENDAR_CONNECTOR " ,
}
2026-02-26 18:24:57 -08:00
class SearchSourceConnectorType ( StrEnum ) :
2025-07-24 14:43:48 -07:00
SERPER_API = " SERPER_API " # NOT IMPLEMENTED YET : DON'T REMEMBER WHY : MOST PROBABLY BECAUSE WE NEED TO CRAWL THE RESULTS RETURNED BY IT
2025-03-14 18:53:14 -07:00
TAVILY_API = " TAVILY_API "
2025-10-12 20:43:45 +05:30
SEARXNG_API = " SEARXNG_API "
2025-04-27 15:53:33 -07:00
LINKUP_API = " LINKUP_API "
2025-10-15 17:29:18 +08:00
BAIDU_SEARCH_API = " BAIDU_SEARCH_API " # Baidu AI Search API for Chinese web search
2025-03-14 18:53:14 -07:00
SLACK_CONNECTOR = " SLACK_CONNECTOR "
2026-01-07 15:15:49 -08:00
TEAMS_CONNECTOR = " TEAMS_CONNECTOR "
2026-03-28 14:31:25 +05:30
ONEDRIVE_CONNECTOR = " ONEDRIVE_CONNECTOR "
2025-03-14 18:53:14 -07:00
NOTION_CONNECTOR = " NOTION_CONNECTOR "
2025-04-13 13:56:22 -07:00
GITHUB_CONNECTOR = " GITHUB_CONNECTOR "
2025-04-15 23:10:35 -07:00
LINEAR_CONNECTOR = " LINEAR_CONNECTOR "
2025-06-02 18:30:38 +07:00
DISCORD_CONNECTOR = " DISCORD_CONNECTOR "
2025-07-24 11:33:38 +02:00
JIRA_CONNECTOR = " JIRA_CONNECTOR "
2025-07-26 14:43:31 +02:00
CONFLUENCE_CONNECTOR = " CONFLUENCE_CONNECTOR "
2025-07-30 21:35:27 +02:00
CLICKUP_CONNECTOR = " CLICKUP_CONNECTOR "
2025-08-02 00:05:55 +02:00
GOOGLE_CALENDAR_CONNECTOR = " GOOGLE_CALENDAR_CONNECTOR "
2025-08-04 00:52:07 +02:00
GOOGLE_GMAIL_CONNECTOR = " GOOGLE_GMAIL_CONNECTOR "
2025-12-28 15:53:35 +02:00
GOOGLE_DRIVE_CONNECTOR = " GOOGLE_DRIVE_CONNECTOR "
2025-08-26 13:56:31 +02:00
AIRTABLE_CONNECTOR = " AIRTABLE_CONNECTOR "
2025-09-28 14:59:10 -07:00
LUMA_CONNECTOR = " LUMA_CONNECTOR "
2025-10-12 09:39:04 +05:30
ELASTICSEARCH_CONNECTOR = " ELASTICSEARCH_CONNECTOR "
2025-11-21 20:45:59 -08:00
WEBCRAWLER_CONNECTOR = " WEBCRAWLER_CONNECTOR "
2025-12-04 14:08:44 +08:00
BOOKSTACK_CONNECTOR = " BOOKSTACK_CONNECTOR "
2025-12-30 09:00:59 -08:00
CIRCLEBACK_CONNECTOR = " CIRCLEBACK_CONNECTOR "
2026-01-22 22:34:49 -08:00
OBSIDIAN_CONNECTOR = (
" OBSIDIAN_CONNECTOR " # Self-hosted only - Local Obsidian vault indexing
)
2026-01-13 13:46:01 -08:00
MCP_CONNECTOR = " MCP_CONNECTOR " # Model Context Protocol - User-defined API tools
2026-03-30 21:56:50 +05:30
DROPBOX_CONNECTOR = " DROPBOX_CONNECTOR "
2026-01-22 22:33:28 +05:30
COMPOSIO_GOOGLE_DRIVE_CONNECTOR = " COMPOSIO_GOOGLE_DRIVE_CONNECTOR "
COMPOSIO_GMAIL_CONNECTOR = " COMPOSIO_GMAIL_CONNECTOR "
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR = " COMPOSIO_GOOGLE_CALENDAR_CONNECTOR "
2025-07-24 14:43:48 -07:00
2026-03-21 22:13:41 -07:00
class VideoPresentationStatus ( StrEnum ) :
PENDING = " pending "
GENERATING = " generating "
READY = " ready "
FAILED = " failed "
2026-02-05 21:59:31 +05:30
class DocumentStatus :
"""
Helper class for document processing status ( stored as JSONB ) .
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
Status values :
- { " state " : " ready " } - Document is fully processed and searchable
- { " state " : " pending " } - Document is queued , waiting to be processed
- { " state " : " processing " } - Document is currently being processed ( only 1 at a time )
- { " state " : " failed " , " reason " : " ... " } - Processing failed with reason
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
Usage :
document . status = DocumentStatus . pending ( )
document . status = DocumentStatus . processing ( )
document . status = DocumentStatus . ready ( )
document . status = DocumentStatus . failed ( " LLM rate limit exceeded " )
"""
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
# State constants
READY = " ready "
PENDING = " pending "
PROCESSING = " processing "
FAILED = " failed "
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
@staticmethod
def ready ( ) - > dict :
""" Return status dict for a ready/searchable document. """
return { " state " : DocumentStatus . READY }
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
@staticmethod
def pending ( ) - > dict :
""" Return status dict for a document waiting to be processed. """
return { " state " : DocumentStatus . PENDING }
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
@staticmethod
def processing ( ) - > dict :
""" Return status dict for a document being processed. """
return { " state " : DocumentStatus . PROCESSING }
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
@staticmethod
def failed ( reason : str , * * extra_details ) - > dict :
"""
Return status dict for a failed document .
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
Args :
reason : Human - readable failure reason
* * extra_details : Optional additional details ( duplicate_of , error_code , etc . )
"""
2026-02-06 05:35:15 +05:30
status = {
" state " : DocumentStatus . FAILED ,
" reason " : reason [ : 500 ] ,
} # Truncate long reasons
2026-02-05 21:59:31 +05:30
if extra_details :
status . update ( extra_details )
return status
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
@staticmethod
def get_state ( status : dict | None ) - > str | None :
""" Extract state from status dict, returns None if invalid. """
if status is None :
return None
return status . get ( " state " ) if isinstance ( status , dict ) else None
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
@staticmethod
def is_state ( status : dict | None , state : str ) - > bool :
""" Check if status matches a given state. """
return DocumentStatus . get_state ( status ) == state
2026-02-06 05:35:15 +05:30
2026-02-05 21:59:31 +05:30
@staticmethod
def get_failure_reason ( status : dict | None ) - > str | None :
""" Extract failure reason from status dict. """
if status is None or not isinstance ( status , dict ) :
return None
if status . get ( " state " ) == DocumentStatus . FAILED :
return status . get ( " reason " )
return None
2026-06-10 21:47:23 +05:30
class ConnectionScope ( StrEnum ) :
GLOBAL = " GLOBAL "
SEARCH_SPACE = " SEARCH_SPACE "
USER = " USER "
2025-10-13 20:07:32 -07:00
2026-02-05 16:43:48 -08:00
2026-06-10 21:47:23 +05:30
class ModelSource ( StrEnum ) :
DISCOVERED = " DISCOVERED "
MANUAL = " MANUAL "
2026-04-07 18:49:04 +02:00
2026-02-26 18:24:57 -08:00
class LogLevel ( StrEnum ) :
2025-07-16 01:10:33 -07:00
DEBUG = " DEBUG "
INFO = " INFO "
WARNING = " WARNING "
ERROR = " ERROR "
CRITICAL = " CRITICAL "
2025-07-24 14:43:48 -07:00
2026-02-26 18:24:57 -08:00
class LogStatus ( StrEnum ) :
2025-07-16 01:10:33 -07:00
IN_PROGRESS = " IN_PROGRESS "
SUCCESS = " SUCCESS "
FAILED = " FAILED "
2025-07-24 14:43:48 -07:00
2026-02-26 18:24:57 -08:00
class IncentiveTaskType ( StrEnum ) :
2026-01-26 23:32:30 -08:00
"""
Enum for incentive task types that users can complete to earn free pages .
Each task can only be completed once per user .
When adding new tasks :
1. Add a new enum value here
2. Add the task configuration to INCENTIVE_TASKS_CONFIG below
3. Create an Alembic migration to add the enum value to PostgreSQL
"""
GITHUB_STAR = " GITHUB_STAR "
2026-01-28 21:58:49 -08:00
REDDIT_FOLLOW = " REDDIT_FOLLOW "
2026-02-03 22:32:39 -08:00
DISCORD_JOIN = " DISCORD_JOIN "
2026-01-26 23:32:30 -08:00
# Future tasks can be added here:
# GITHUB_ISSUE = "GITHUB_ISSUE"
# SOCIAL_SHARE = "SOCIAL_SHARE"
# REFER_FRIEND = "REFER_FRIEND"
2026-03-31 18:39:45 -07:00
class PagePurchaseStatus ( StrEnum ) :
PENDING = " pending "
COMPLETED = " completed "
FAILED = " failed "
2026-06-10 16:49:03 -07:00
class CreditPurchaseStatus ( StrEnum ) :
2026-04-15 17:02:00 -07:00
PENDING = " pending "
COMPLETED = " completed "
FAILED = " failed "
2026-01-26 23:32:30 -08:00
# Centralized configuration for incentive tasks
# This makes it easy to add new tasks without changing code in multiple places
INCENTIVE_TASKS_CONFIG = {
IncentiveTaskType . GITHUB_STAR : {
" title " : " Star our GitHub repository " ,
" description " : " Show your support by starring SurfSense on GitHub " ,
2026-06-10 16:49:03 -07:00
# Credit reward in USD micro-units (1_000_000 == $1.00). $0.03.
" credit_micros_reward " : 30000 ,
2026-01-26 23:32:30 -08:00
" action_url " : " https://github.com/MODSetter/SurfSense " ,
} ,
2026-01-28 21:58:49 -08:00
IncentiveTaskType . REDDIT_FOLLOW : {
" title " : " Join our Subreddit " ,
" description " : " Join the SurfSense community on Reddit " ,
2026-06-10 16:49:03 -07:00
" credit_micros_reward " : 30000 ,
2026-01-28 21:58:49 -08:00
" action_url " : " https://www.reddit.com/r/SurfSense/ " ,
} ,
2026-02-03 22:32:39 -08:00
IncentiveTaskType . DISCORD_JOIN : {
" title " : " Join our Discord " ,
" description " : " Join the SurfSense community on Discord " ,
2026-06-10 16:49:03 -07:00
" credit_micros_reward " : 40000 ,
2026-02-03 22:32:39 -08:00
" action_url " : " https://discord.gg/ejRNvftDp9 " ,
} ,
2026-01-26 23:32:30 -08:00
# Future tasks can be configured here:
# IncentiveTaskType.GITHUB_ISSUE: {
# "title": "Create an issue",
# "description": "Help improve SurfSense by reporting bugs or suggesting features",
2026-06-10 16:49:03 -07:00
# "credit_micros_reward": 50000,
2026-01-26 23:32:30 -08:00
# "action_url": "https://github.com/MODSetter/SurfSense/issues/new/choose",
# },
}
2026-02-26 18:24:57 -08:00
class Permission ( StrEnum ) :
2025-11-27 22:45:04 -08:00
"""
2026-06-26 18:18:50 +02:00
Granular permissions for workspace resources .
2025-11-27 22:45:04 -08:00
Use ' * ' ( FULL_ACCESS ) to grant all permissions .
"""
# Documents
DOCUMENTS_CREATE = " documents:create "
DOCUMENTS_READ = " documents:read "
DOCUMENTS_UPDATE = " documents:update "
DOCUMENTS_DELETE = " documents:delete "
# Chats
CHATS_CREATE = " chats:create "
CHATS_READ = " chats:read "
CHATS_UPDATE = " chats:update "
CHATS_DELETE = " chats:delete "
2026-01-15 16:39:24 +02:00
# Comments
COMMENTS_CREATE = " comments:create "
COMMENTS_READ = " comments:read "
COMMENTS_DELETE = " comments:delete "
2025-11-27 22:45:04 -08:00
# LLM Configs
LLM_CONFIGS_CREATE = " llm_configs:create "
LLM_CONFIGS_READ = " llm_configs:read "
LLM_CONFIGS_UPDATE = " llm_configs:update "
LLM_CONFIGS_DELETE = " llm_configs:delete "
# Podcasts
PODCASTS_CREATE = " podcasts:create "
PODCASTS_READ = " podcasts:read "
PODCASTS_UPDATE = " podcasts:update "
PODCASTS_DELETE = " podcasts:delete "
2026-03-21 22:13:41 -07:00
# Video Presentations
VIDEO_PRESENTATIONS_CREATE = " video_presentations:create "
VIDEO_PRESENTATIONS_READ = " video_presentations:read "
VIDEO_PRESENTATIONS_UPDATE = " video_presentations:update "
VIDEO_PRESENTATIONS_DELETE = " video_presentations:delete "
2026-02-05 16:43:48 -08:00
# Image Generations
IMAGE_GENERATIONS_CREATE = " image_generations:create "
IMAGE_GENERATIONS_READ = " image_generations:read "
IMAGE_GENERATIONS_DELETE = " image_generations:delete "
2026-04-07 18:49:04 +02:00
# Vision LLM Configs
VISION_CONFIGS_CREATE = " vision_configs:create "
VISION_CONFIGS_READ = " vision_configs:read "
VISION_CONFIGS_DELETE = " vision_configs:delete "
2025-11-27 22:45:04 -08:00
# Connectors
CONNECTORS_CREATE = " connectors:create "
CONNECTORS_READ = " connectors:read "
CONNECTORS_UPDATE = " connectors:update "
CONNECTORS_DELETE = " connectors:delete "
# Logs
LOGS_READ = " logs:read "
LOGS_DELETE = " logs:delete "
# Members
MEMBERS_INVITE = " members:invite "
MEMBERS_VIEW = " members:view "
MEMBERS_REMOVE = " members:remove "
MEMBERS_MANAGE_ROLES = " members:manage_roles "
# Roles
ROLES_CREATE = " roles:create "
ROLES_READ = " roles:read "
ROLES_UPDATE = " roles:update "
ROLES_DELETE = " roles:delete "
2026-06-26 18:18:50 +02:00
# Workspace Settings
2025-11-27 22:45:04 -08:00
SETTINGS_VIEW = " settings:view "
SETTINGS_UPDATE = " settings:update "
2026-06-26 18:18:50 +02:00
SETTINGS_DELETE = " settings:delete " # Delete the entire workspace
2025-11-27 22:45:04 -08:00
2026-06-19 20:26:28 +05:30
# API Access
API_ACCESS_MANAGE = " api_access:manage "
2026-02-02 14:04:08 +02:00
# Public Sharing
PUBLIC_SHARING_VIEW = " public_sharing:view "
PUBLIC_SHARING_CREATE = " public_sharing:create "
PUBLIC_SHARING_DELETE = " public_sharing:delete "
2026-05-27 15:30:34 +02:00
# Automations
AUTOMATIONS_CREATE = " automations:create "
AUTOMATIONS_READ = " automations:read "
AUTOMATIONS_UPDATE = " automations:update "
AUTOMATIONS_DELETE = " automations:delete "
AUTOMATIONS_EXECUTE = " automations:execute "
2025-11-27 22:45:04 -08:00
# Full access wildcard
FULL_ACCESS = " * "
# Predefined role permission sets for convenience
2026-01-20 02:59:32 -08:00
# Note: Only Owner, Editor, and Viewer roles are supported.
# Owner has full access (*), Editor can do everything except delete, Viewer has read-only access.
2025-11-27 22:45:04 -08:00
DEFAULT_ROLE_PERMISSIONS = {
" Owner " : [ Permission . FULL_ACCESS . value ] ,
" Editor " : [
2026-01-20 02:59:32 -08:00
# Documents (no delete)
2025-11-27 22:45:04 -08:00
Permission . DOCUMENTS_CREATE . value ,
Permission . DOCUMENTS_READ . value ,
Permission . DOCUMENTS_UPDATE . value ,
2026-01-20 02:59:32 -08:00
# Chats (no delete)
2025-11-27 22:45:04 -08:00
Permission . CHATS_CREATE . value ,
Permission . CHATS_READ . value ,
Permission . CHATS_UPDATE . value ,
2026-01-15 16:42:09 +02:00
# Comments (no delete)
Permission . COMMENTS_CREATE . value ,
Permission . COMMENTS_READ . value ,
2026-01-20 02:59:32 -08:00
# LLM Configs (no delete)
2025-11-27 22:45:04 -08:00
Permission . LLM_CONFIGS_CREATE . value ,
2026-01-20 02:59:32 -08:00
Permission . LLM_CONFIGS_READ . value ,
2025-11-27 22:45:04 -08:00
Permission . LLM_CONFIGS_UPDATE . value ,
2026-01-20 02:59:32 -08:00
# Podcasts (no delete)
2025-11-27 22:45:04 -08:00
Permission . PODCASTS_CREATE . value ,
Permission . PODCASTS_READ . value ,
Permission . PODCASTS_UPDATE . value ,
2026-03-21 22:13:41 -07:00
# Video Presentations (no delete)
Permission . VIDEO_PRESENTATIONS_CREATE . value ,
Permission . VIDEO_PRESENTATIONS_READ . value ,
Permission . VIDEO_PRESENTATIONS_UPDATE . value ,
2026-02-05 16:43:48 -08:00
# Image Generations (create and read, no delete)
Permission . IMAGE_GENERATIONS_CREATE . value ,
Permission . IMAGE_GENERATIONS_READ . value ,
2026-04-07 18:49:04 +02:00
# Vision Configs (create and read, no delete)
Permission . VISION_CONFIGS_CREATE . value ,
Permission . VISION_CONFIGS_READ . value ,
2026-01-20 02:59:32 -08:00
# Connectors (no delete)
2025-11-27 22:45:04 -08:00
Permission . CONNECTORS_CREATE . value ,
Permission . CONNECTORS_READ . value ,
Permission . CONNECTORS_UPDATE . value ,
2026-01-20 02:59:32 -08:00
# Logs (read only)
2025-11-27 22:45:04 -08:00
Permission . LOGS_READ . value ,
2026-01-20 02:59:32 -08:00
# Members (can invite and view only, cannot manage roles or remove)
Permission . MEMBERS_INVITE . value ,
2025-11-27 22:45:04 -08:00
Permission . MEMBERS_VIEW . value ,
2026-01-20 02:59:32 -08:00
# Roles (read only - cannot create, update, or delete)
2025-11-27 22:45:04 -08:00
Permission . ROLES_READ . value ,
2026-01-20 02:59:32 -08:00
# Settings (view only, no update or delete)
2025-11-27 22:45:04 -08:00
Permission . SETTINGS_VIEW . value ,
2026-02-02 14:04:08 +02:00
# Public Sharing (can create and view, no delete)
Permission . PUBLIC_SHARING_VIEW . value ,
Permission . PUBLIC_SHARING_CREATE . value ,
2026-05-27 15:30:34 +02:00
# Automations (no delete)
Permission . AUTOMATIONS_CREATE . value ,
Permission . AUTOMATIONS_READ . value ,
Permission . AUTOMATIONS_UPDATE . value ,
Permission . AUTOMATIONS_EXECUTE . value ,
2025-11-27 22:45:04 -08:00
] ,
" Viewer " : [
# Documents (read only)
Permission . DOCUMENTS_READ . value ,
# Chats (read only)
Permission . CHATS_READ . value ,
2026-01-20 02:59:32 -08:00
# Comments (can create and read, but not delete)
2026-01-15 16:42:09 +02:00
Permission . COMMENTS_CREATE . value ,
Permission . COMMENTS_READ . value ,
2025-11-27 22:45:04 -08:00
# LLM Configs (read only)
Permission . LLM_CONFIGS_READ . value ,
# Podcasts (read only)
Permission . PODCASTS_READ . value ,
2026-03-21 22:13:41 -07:00
# Video Presentations (read only)
Permission . VIDEO_PRESENTATIONS_READ . value ,
2026-02-05 16:43:48 -08:00
# Image Generations (read only)
Permission . IMAGE_GENERATIONS_READ . value ,
2026-04-07 18:49:04 +02:00
# Vision Configs (read only)
Permission . VISION_CONFIGS_READ . value ,
2025-11-27 22:45:04 -08:00
# Connectors (read only)
Permission . CONNECTORS_READ . value ,
# Logs (read only)
Permission . LOGS_READ . value ,
# Members (view only)
Permission . MEMBERS_VIEW . value ,
# Roles (read only)
Permission . ROLES_READ . value ,
# Settings (view only)
Permission . SETTINGS_VIEW . value ,
2026-02-02 14:04:08 +02:00
# Public Sharing (view only)
Permission . PUBLIC_SHARING_VIEW . value ,
2026-05-27 15:30:34 +02:00
# Automations (read only)
Permission . AUTOMATIONS_READ . value ,
2025-11-27 22:45:04 -08:00
] ,
}
2025-03-14 18:53:14 -07:00
class Base ( DeclarativeBase ) :
pass
2025-07-24 14:43:48 -07:00
2025-03-14 18:53:14 -07:00
class TimestampMixin :
@declared_attr
2025-07-24 14:43:48 -07:00
def created_at ( cls ) : # noqa: N805
return Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
index = True ,
)
2025-03-14 18:53:14 -07:00
class BaseModel ( Base ) :
__abstract__ = True
__allow_unmapped__ = True
id = Column ( Integer , primary_key = True , index = True )
2025-07-24 14:43:48 -07:00
2026-02-26 18:24:57 -08:00
class NewChatMessageRole ( StrEnum ) :
2025-12-21 16:16:50 -08:00
""" Role enum for new chat messages. """
USER = " user "
ASSISTANT = " assistant "
SYSTEM = " system "
2026-02-26 18:24:57 -08:00
class ChatVisibility ( StrEnum ) :
2026-01-13 00:17:12 -08:00
"""
Visibility / sharing level for chat threads .
PRIVATE : Only the creator can see / access the chat ( default )
2026-06-26 18:18:50 +02:00
SEARCH_SPACE : All members of the workspace can see / access the chat
2026-01-13 00:17:12 -08:00
PUBLIC : ( Future ) Anyone with the link can access the chat
"""
PRIVATE = " PRIVATE "
SEARCH_SPACE = " SEARCH_SPACE "
# PUBLIC = "PUBLIC" # Reserved for future implementation
2026-05-28 04:37:27 +05:30
class ExternalChatPlatform ( StrEnum ) :
2026-05-27 23:34:46 +05:30
TELEGRAM = " telegram "
WHATSAPP = " whatsapp "
2026-06-01 12:35:52 +05:30
SLACK = " slack "
2026-06-01 20:58:17 +05:30
DISCORD = " discord "
2026-05-27 23:34:46 +05:30
SIGNAL = " signal "
2026-05-28 04:37:27 +05:30
class ExternalChatAccountMode ( StrEnum ) :
2026-05-27 23:34:46 +05:30
CLOUD_SHARED = " cloud_shared "
SELF_HOST_BYO = " self_host_byo "
2026-05-28 04:37:27 +05:30
class ExternalChatHealthStatus ( StrEnum ) :
2026-05-27 23:34:46 +05:30
UNKNOWN = " unknown "
OK = " ok "
FAILING = " failing "
2026-05-28 04:37:27 +05:30
class ExternalChatBindingState ( StrEnum ) :
2026-05-27 23:34:46 +05:30
PENDING = " pending "
BOUND = " bound "
REVOKED = " revoked "
SUSPENDED = " suspended "
2026-05-28 04:37:27 +05:30
class ExternalChatPeerKind ( StrEnum ) :
2026-05-27 23:34:46 +05:30
DIRECT = " direct "
GROUP = " group "
CHANNEL = " channel "
UNKNOWN = " unknown "
2026-05-28 04:37:27 +05:30
class ExternalChatEventKind ( StrEnum ) :
2026-05-27 23:34:46 +05:30
MESSAGE = " message "
EDITED_MESSAGE = " edited_message "
CALLBACK_QUERY = " callback_query "
OTHER = " other "
2026-05-28 04:37:27 +05:30
class ExternalChatEventStatus ( StrEnum ) :
2026-05-27 23:34:46 +05:30
RECEIVED = " received "
PROCESSING = " processing "
PROCESSED = " processed "
IGNORED = " ignored "
FAILED = " failed "
def _enum_values ( enum_cls ) :
return [ item . value for item in enum_cls ]
2025-12-21 16:16:50 -08:00
class NewChatThread ( BaseModel , TimestampMixin ) :
"""
Thread model for the new chat feature using assistant - ui .
Each thread represents a conversation with message history .
LangGraph checkpointer uses thread_id for state persistence .
"""
__tablename__ = " new_chat_threads "
title = Column ( String ( 500 ) , nullable = False , default = " New Chat " , index = True )
archived = Column ( Boolean , nullable = False , default = False )
updated_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
onupdate = lambda : datetime . now ( UTC ) ,
index = True ,
)
2026-01-13 00:17:12 -08:00
# Visibility/sharing control
visibility = Column (
SQLAlchemyEnum ( ChatVisibility ) ,
nullable = False ,
default = ChatVisibility . PRIVATE ,
server_default = " PRIVATE " ,
index = True ,
)
2025-12-21 16:16:50 -08:00
# Foreign keys
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2025-12-21 16:16:50 -08:00
)
2026-01-13 00:17:12 -08:00
# Track who created this chat thread (for visibility filtering)
created_by_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " SET NULL " ) ,
nullable = True , # Nullable for existing records before migration
index = True ,
)
2026-01-27 13:33:36 +02:00
# Clone tracking - for audit and history bootstrap
cloned_from_thread_id = Column (
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
2026-01-30 17:08:07 +02:00
cloned_from_snapshot_id = Column (
Integer ,
ForeignKey ( " public_chat_snapshots.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
2026-01-27 13:33:36 +02:00
cloned_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = True ,
)
# Flag to bootstrap LangGraph checkpointer with DB messages on first message
needs_history_bootstrap = Column (
Boolean ,
nullable = False ,
default = False ,
server_default = " false " ,
)
2026-06-13 12:45:43 +05:30
# Auto model pin for this thread: concrete resolved global LLM
2026-05-01 19:32:42 +05:30
# config id. NULL means no pin; Auto will resolve on the next turn.
# Single-writer invariant: only app.services.auto_model_pin_service sets
2026-06-26 18:18:50 +02:00
# or clears this column (plus bulk clears when a workspace's
2026-06-13 12:45:43 +05:30
# chat_model_id changes). Unindexed: all reads are by primary key.
2026-05-01 19:32:42 +05:30
pinned_llm_config_id = Column ( Integer , nullable = True )
2026-01-27 13:33:36 +02:00
2026-05-28 13:11:05 +05:30
# Surface metadata for first-party SurfSense and external chat threads.
# Zero publishes all chat-message sources; the UI can decide which surfaces to render.
2026-06-09 00:42:26 -07:00
source = Column (
Text , nullable = False , default = " surfsense " , server_default = " surfsense "
)
2026-05-28 04:37:27 +05:30
external_chat_binding_id = Column (
2026-05-27 23:34:46 +05:30
BigInteger ,
2026-05-28 04:37:27 +05:30
ForeignKey ( " external_chat_bindings.id " , ondelete = " SET NULL " ) ,
2026-05-27 23:34:46 +05:30
nullable = True ,
index = True ,
)
2025-12-21 16:16:50 -08:00
# Relationships
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , back_populates = " new_chat_threads " )
2026-01-13 00:17:12 -08:00
created_by = relationship ( " User " , back_populates = " new_chat_threads " )
2025-12-21 16:16:50 -08:00
messages = relationship (
" NewChatMessage " ,
back_populates = " thread " ,
order_by = " NewChatMessage.created_at " ,
cascade = " all, delete-orphan " ,
)
2026-01-29 16:05:36 +02:00
snapshots = relationship (
" PublicChatSnapshot " ,
back_populates = " thread " ,
cascade = " all, delete-orphan " ,
2026-01-30 17:08:07 +02:00
foreign_keys = " [PublicChatSnapshot.thread_id] " ,
2026-01-29 16:05:36 +02:00
)
2026-04-14 13:26:53 +05:30
token_usages = relationship (
" TokenUsage " ,
back_populates = " thread " ,
cascade = " all, delete-orphan " ,
)
2026-05-28 04:37:27 +05:30
external_chat_binding = relationship (
" ExternalChatBinding " ,
foreign_keys = [ external_chat_binding_id ] ,
2026-05-27 23:34:46 +05:30
back_populates = " threads " ,
)
2025-12-21 16:16:50 -08:00
class NewChatMessage ( BaseModel , TimestampMixin ) :
"""
Message model for the new chat feature .
Stores individual messages in assistant - ui format .
"""
__tablename__ = " new_chat_messages "
2026-05-04 03:06:15 -07:00
# Partial unique index on (thread_id, turn_id, role) where turn_id IS NOT NULL.
# Mirrors alembic migration 141. Lets the streaming agent and the
# legacy frontend appendMessage call coexist idempotently — the second
# writer trips the unique and recovers without creating a duplicate row.
# Partial so legacy NULL turn_id rows and clone/snapshot inserts in
# app/services/public_chat_service.py (which omit turn_id) are unaffected.
__table_args__ = (
Index (
" uq_new_chat_messages_thread_turn_role " ,
" thread_id " ,
" turn_id " ,
" role " ,
unique = True ,
postgresql_where = text ( " turn_id IS NOT NULL " ) ,
) ,
)
2025-12-21 16:16:50 -08:00
role = Column ( SQLAlchemyEnum ( NewChatMessageRole ) , nullable = False )
# Content stored as JSONB to support rich content (text, tool calls, etc.)
content = Column ( JSONB , nullable = False )
# Foreign key to thread
thread_id = Column (
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
2026-01-14 17:56:45 +02:00
# Track who sent this message (for shared chats)
author_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
2026-04-29 07:20:31 -07:00
# Per-turn correlation id sourced from ``configurable.turn_id`` at
# streaming time (``f"{chat_id}:{ms}"``). Nullable because legacy rows
# predate the column. Used by C1's edit-from-arbitrary-position to map
# a message back to the LangGraph checkpoint that produced its turn.
turn_id = Column ( String ( 64 ) , nullable = True , index = True )
2026-05-27 23:34:46 +05:30
# Mirrors the parent thread source for publication-level filtering.
# This denormalization avoids join-dependent logical replication rules.
2026-06-09 00:42:26 -07:00
source = Column (
Text , nullable = False , default = " surfsense " , server_default = " surfsense "
)
2026-05-27 23:34:46 +05:30
platform_metadata = Column ( JSONB , nullable = True )
2026-01-14 17:56:45 +02:00
# Relationships
2025-12-21 16:16:50 -08:00
thread = relationship ( " NewChatThread " , back_populates = " messages " )
2026-01-14 17:56:45 +02:00
author = relationship ( " User " )
2026-01-15 16:34:03 +02:00
comments = relationship (
" ChatComment " ,
back_populates = " message " ,
cascade = " all, delete-orphan " ,
)
2026-04-14 13:26:53 +05:30
token_usage = relationship (
" TokenUsage " ,
back_populates = " message " ,
uselist = False ,
cascade = " all, delete-orphan " ,
)
2026-05-28 04:37:27 +05:30
class ExternalChatAccount ( Base , TimestampMixin ) :
__tablename__ = " external_chat_accounts "
2026-05-27 23:34:46 +05:30
__allow_unmapped__ = True
id = Column ( BigInteger , primary_key = True , index = True )
platform = Column (
SQLAlchemyEnum (
2026-05-28 04:37:27 +05:30
ExternalChatPlatform ,
name = " external_chat_platform " ,
2026-05-27 23:34:46 +05:30
values_callable = _enum_values ,
) ,
nullable = False ,
)
mode = Column (
SQLAlchemyEnum (
2026-05-28 04:37:27 +05:30
ExternalChatAccountMode ,
name = " external_chat_account_mode " ,
2026-05-27 23:34:46 +05:30
values_callable = _enum_values ,
) ,
nullable = False ,
)
owner_user_id = Column (
UUID ( as_uuid = True ) , ForeignKey ( " user.id " , ondelete = " CASCADE " ) , nullable = True
)
2026-06-26 18:18:50 +02:00
owner_workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = True ,
2026-05-27 23:34:46 +05:30
)
2026-06-09 00:42:26 -07:00
is_system_account = Column (
Boolean , nullable = False , default = False , server_default = " false "
)
2026-05-27 23:34:46 +05:30
encrypted_credentials = Column ( Text , nullable = True )
2026-05-28 04:37:27 +05:30
bot_username = Column ( String ( 255 ) , nullable = True )
webhook_secret = Column ( String ( 64 ) , nullable = True )
2026-06-09 00:42:26 -07:00
cursor_state = Column (
JSONB , nullable = False , default = dict , server_default = text ( " ' {} ' ::jsonb " )
)
2026-05-27 23:34:46 +05:30
health_status = Column (
SQLAlchemyEnum (
2026-05-28 04:37:27 +05:30
ExternalChatHealthStatus ,
name = " external_chat_health_status " ,
2026-05-27 23:34:46 +05:30
values_callable = _enum_values ,
) ,
nullable = False ,
2026-05-28 04:37:27 +05:30
default = ExternalChatHealthStatus . UNKNOWN ,
server_default = ExternalChatHealthStatus . UNKNOWN . value ,
2026-05-27 23:34:46 +05:30
)
last_health_check_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
suspended_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
suspended_reason = Column ( Text , nullable = True )
updated_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
onupdate = lambda : datetime . now ( UTC ) ,
server_default = text ( " (now() AT TIME ZONE ' utc ' ) " ) ,
)
owner = relationship ( " User " , foreign_keys = [ owner_user_id ] )
2026-06-26 18:18:50 +02:00
owner_workspace = relationship (
" Workspace " , foreign_keys = [ owner_workspace_id ]
2026-06-09 00:42:26 -07:00
)
2026-05-27 23:34:46 +05:30
bindings = relationship (
2026-05-28 04:37:27 +05:30
" ExternalChatBinding " ,
2026-05-27 23:34:46 +05:30
back_populates = " account " ,
cascade = " all, delete-orphan " ,
)
inbound_events = relationship (
2026-05-28 04:37:27 +05:30
" ExternalChatInboundEvent " ,
2026-05-27 23:34:46 +05:30
back_populates = " account " ,
cascade = " all, delete-orphan " ,
)
__table_args__ = (
CheckConstraint (
" (is_system_account = true AND owner_user_id IS NULL) OR "
" (is_system_account = false AND owner_user_id IS NOT NULL) " ,
2026-05-28 04:37:27 +05:30
name = " ck_external_chat_accounts_owner_shape " ,
2026-05-27 23:34:46 +05:30
) ,
Index (
2026-05-28 04:37:27 +05:30
" uq_external_chat_accounts_owner_platform " ,
2026-05-27 23:34:46 +05:30
" owner_user_id " ,
" platform " ,
unique = True ,
postgresql_where = text ( " is_system_account = false " ) ,
) ,
Index (
2026-05-28 04:37:27 +05:30
" uq_external_chat_accounts_system_platform " ,
2026-05-27 23:34:46 +05:30
" platform " ,
unique = True ,
2026-06-01 12:35:52 +05:30
postgresql_where = text (
2026-06-01 20:58:17 +05:30
" is_system_account = true "
" AND NOT (cursor_state ? ' team_id ' ) "
" AND NOT (cursor_state ? ' guild_id ' ) "
2026-06-01 12:35:52 +05:30
) ,
) ,
Index (
" uq_external_chat_accounts_slack_team " ,
" platform " ,
text ( " (cursor_state ->> ' team_id ' ) " ) ,
unique = True ,
postgresql_where = text (
" is_system_account = true AND cursor_state ? ' team_id ' "
) ,
2026-05-27 23:34:46 +05:30
) ,
2026-06-01 20:58:17 +05:30
Index (
" uq_external_chat_accounts_discord_guild " ,
" platform " ,
text ( " (cursor_state ->> ' guild_id ' ) " ) ,
unique = True ,
postgresql_where = text (
" is_system_account = true AND cursor_state ? ' guild_id ' "
) ,
) ,
2026-05-28 04:37:27 +05:30
Index (
" uq_external_chat_accounts_webhook_secret " ,
" webhook_secret " ,
unique = True ,
postgresql_where = text ( " webhook_secret IS NOT NULL " ) ,
) ,
2026-05-27 23:34:46 +05:30
)
2026-05-28 04:37:27 +05:30
class ExternalChatBinding ( Base , TimestampMixin ) :
__tablename__ = " external_chat_bindings "
2026-05-27 23:34:46 +05:30
__allow_unmapped__ = True
id = Column ( BigInteger , primary_key = True , index = True )
account_id = Column (
BigInteger ,
2026-05-28 04:37:27 +05:30
ForeignKey ( " external_chat_accounts.id " , ondelete = " CASCADE " ) ,
2026-05-27 23:34:46 +05:30
nullable = False ,
index = True ,
)
user_id = Column (
UUID ( as_uuid = True ) , ForeignKey ( " user.id " , ondelete = " CASCADE " ) , nullable = False
)
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2026-05-27 23:34:46 +05:30
)
state = Column (
SQLAlchemyEnum (
2026-05-28 04:37:27 +05:30
ExternalChatBindingState ,
name = " external_chat_binding_state " ,
2026-05-27 23:34:46 +05:30
values_callable = _enum_values ,
) ,
nullable = False ,
2026-05-28 04:37:27 +05:30
default = ExternalChatBindingState . PENDING ,
server_default = ExternalChatBindingState . PENDING . value ,
2026-05-27 23:34:46 +05:30
)
pairing_code = Column ( Text , nullable = True )
pairing_code_expires_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
external_peer_id = Column ( Text , nullable = True )
external_peer_kind = Column (
SQLAlchemyEnum (
2026-05-28 04:37:27 +05:30
ExternalChatPeerKind ,
name = " external_chat_peer_kind " ,
2026-05-27 23:34:46 +05:30
values_callable = _enum_values ,
) ,
nullable = False ,
2026-05-28 04:37:27 +05:30
default = ExternalChatPeerKind . UNKNOWN ,
server_default = ExternalChatPeerKind . UNKNOWN . value ,
2026-05-27 23:34:46 +05:30
)
external_thread_id = Column ( Text , nullable = True )
external_display_name = Column ( Text , nullable = True )
external_username = Column ( Text , nullable = True )
2026-06-09 00:42:26 -07:00
external_metadata = Column (
JSONB , nullable = False , default = dict , server_default = text ( " ' {} ' ::jsonb " )
)
2026-05-28 04:37:27 +05:30
new_chat_thread_id = Column (
2026-05-27 23:34:46 +05:30
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
revoked_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
suspended_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
suspended_reason = Column ( Text , nullable = True )
updated_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
onupdate = lambda : datetime . now ( UTC ) ,
server_default = text ( " (now() AT TIME ZONE ' utc ' ) " ) ,
)
2026-05-28 04:37:27 +05:30
account = relationship ( " ExternalChatAccount " , back_populates = " bindings " )
2026-05-27 23:34:46 +05:30
user = relationship ( " User " , foreign_keys = [ user_id ] )
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , foreign_keys = [ workspace_id ] )
2026-05-28 04:37:27 +05:30
new_chat_thread = relationship ( " NewChatThread " , foreign_keys = [ new_chat_thread_id ] )
2026-05-27 23:34:46 +05:30
threads = relationship (
" NewChatThread " ,
2026-05-28 04:37:27 +05:30
back_populates = " external_chat_binding " ,
foreign_keys = " NewChatThread.external_chat_binding_id " ,
2026-05-27 23:34:46 +05:30
)
inbound_events = relationship (
2026-05-28 04:37:27 +05:30
" ExternalChatInboundEvent " ,
2026-05-27 23:34:46 +05:30
back_populates = " binding " ,
2026-05-28 04:37:27 +05:30
foreign_keys = " ExternalChatInboundEvent.external_chat_binding_id " ,
2026-05-27 23:34:46 +05:30
)
__table_args__ = (
Index (
2026-05-28 04:37:27 +05:30
" uq_external_chat_bindings_account_peer_active " ,
2026-05-27 23:34:46 +05:30
" account_id " ,
" external_peer_id " ,
unique = True ,
postgresql_where = text (
" state IN ( ' bound ' , ' suspended ' ) AND external_peer_id IS NOT NULL "
) ,
) ,
Index (
2026-05-28 04:37:27 +05:30
" uq_external_chat_bindings_pairing_code_pending " ,
2026-05-27 23:34:46 +05:30
" pairing_code " ,
unique = True ,
postgresql_where = text ( " state = ' pending ' " ) ,
) ,
2026-05-28 04:37:27 +05:30
Index ( " ix_external_chat_bindings_user_state " , " user_id " , " state " ) ,
2026-06-09 00:42:26 -07:00
Index (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
" ix_external_chat_bindings_workspace_state " , " workspace_id " , " state "
2026-06-09 00:42:26 -07:00
) ,
2026-05-27 23:34:46 +05:30
)
2026-05-28 04:37:27 +05:30
class ExternalChatInboundEvent ( Base , TimestampMixin ) :
__tablename__ = " external_chat_inbound_events "
2026-05-27 23:34:46 +05:30
__allow_unmapped__ = True
id = Column ( BigInteger , primary_key = True , index = True )
account_id = Column (
BigInteger ,
2026-05-28 04:37:27 +05:30
ForeignKey ( " external_chat_accounts.id " , ondelete = " CASCADE " ) ,
2026-05-27 23:34:46 +05:30
nullable = False ,
index = True ,
)
2026-05-28 04:37:27 +05:30
external_chat_binding_id = Column (
2026-05-27 23:34:46 +05:30
BigInteger ,
2026-05-28 04:37:27 +05:30
ForeignKey ( " external_chat_bindings.id " , ondelete = " SET NULL " ) ,
2026-05-27 23:34:46 +05:30
nullable = True ,
index = True ,
)
platform = Column (
SQLAlchemyEnum (
2026-05-28 04:37:27 +05:30
ExternalChatPlatform ,
name = " external_chat_platform " ,
2026-05-27 23:34:46 +05:30
values_callable = _enum_values ,
) ,
nullable = False ,
)
event_dedupe_key = Column ( Text , nullable = False )
external_event_id = Column ( Text , nullable = True )
external_message_id = Column ( Text , nullable = True )
event_kind = Column (
SQLAlchemyEnum (
2026-05-28 04:37:27 +05:30
ExternalChatEventKind ,
name = " external_chat_event_kind " ,
2026-05-27 23:34:46 +05:30
values_callable = _enum_values ,
) ,
nullable = False ,
)
raw_payload = Column ( JSONB , nullable = True )
2026-05-28 04:37:27 +05:30
request_id = Column ( String ( 64 ) , nullable = True )
2026-05-27 23:34:46 +05:30
status = Column (
SQLAlchemyEnum (
2026-05-28 04:37:27 +05:30
ExternalChatEventStatus ,
name = " external_chat_event_status " ,
2026-05-27 23:34:46 +05:30
values_callable = _enum_values ,
) ,
nullable = False ,
2026-05-28 04:37:27 +05:30
default = ExternalChatEventStatus . RECEIVED ,
server_default = ExternalChatEventStatus . RECEIVED . value ,
2026-05-27 23:34:46 +05:30
)
attempt_count = Column ( Integer , nullable = False , default = 0 , server_default = " 0 " )
last_error = Column ( Text , nullable = True )
received_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
server_default = text ( " (now() AT TIME ZONE ' utc ' ) " ) ,
)
processed_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
2026-05-28 04:37:27 +05:30
account = relationship ( " ExternalChatAccount " , back_populates = " inbound_events " )
binding = relationship ( " ExternalChatBinding " , back_populates = " inbound_events " )
2026-05-27 23:34:46 +05:30
__table_args__ = (
UniqueConstraint (
" account_id " ,
" event_dedupe_key " ,
2026-05-28 04:37:27 +05:30
name = " uq_external_chat_inbound_account_dedupe_key " ,
) ,
Index ( " ix_external_chat_inbound_status_received_at " , " status " , " received_at " ) ,
Index (
" ix_external_chat_inbound_binding_received_at " ,
" external_chat_binding_id " ,
" received_at " ,
) ,
Index (
" ix_external_chat_inbound_request_id " ,
" request_id " ,
postgresql_where = text ( " request_id IS NOT NULL " ) ,
2026-05-27 23:34:46 +05:30
) ,
)
2026-04-14 13:26:53 +05:30
class TokenUsage ( BaseModel , TimestampMixin ) :
"""
Tracks LLM token consumption per assistant turn .
One row per usage event . For chat , linked to a specific message via message_id .
The usage_type column enables future extension to track non - chat usage
( indexing , image generation , podcasts , etc . ) without schema changes .
"""
__tablename__ = " token_usage "
2026-05-04 03:06:15 -07:00
# Partial unique index on (message_id) where message_id IS NOT NULL.
# Mirrors alembic migration 142. Lets the streaming agent's
# ``finalize_assistant_turn`` and the legacy frontend ``append_message``
# recovery branch both use ``INSERT ... ON CONFLICT DO NOTHING`` without
# racing on a SELECT-then-INSERT window. Partial so non-chat usage rows
# (indexing, image generation, podcasts) — which keep ``message_id`` NULL
# because there is no per-message anchor — are unaffected.
__table_args__ = (
Index (
" uq_token_usage_message_id " ,
" message_id " ,
unique = True ,
postgresql_where = text ( " message_id IS NOT NULL " ) ,
) ,
)
2026-04-14 13:26:53 +05:30
prompt_tokens = Column ( Integer , nullable = False , default = 0 )
completion_tokens = Column ( Integer , nullable = False , default = 0 )
total_tokens = Column ( Integer , nullable = False , default = 0 )
2026-05-02 14:34:23 -07:00
cost_micros = Column ( BigInteger , nullable = False , default = 0 , server_default = " 0 " )
2026-04-14 13:26:53 +05:30
model_breakdown = Column ( JSONB , nullable = True )
call_details = Column ( JSONB , nullable = True )
usage_type = Column ( String ( 50 ) , nullable = False , default = " chat " , index = True )
thread_id = Column (
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " CASCADE " ) ,
nullable = True ,
index = True ,
)
message_id = Column (
Integer ,
ForeignKey ( " new_chat_messages.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
2026-06-26 18:18:50 +02:00
workspace_id = Column (
2026-04-14 13:26:53 +05:30
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
2026-04-14 13:26:53 +05:30
nullable = False ,
index = True ,
)
user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
# Relationships
thread = relationship ( " NewChatThread " , back_populates = " token_usages " )
message = relationship ( " NewChatMessage " , back_populates = " token_usage " )
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " )
2026-04-14 13:26:53 +05:30
user = relationship ( " User " )
2026-01-15 16:34:03 +02:00
2026-01-29 16:05:36 +02:00
class PublicChatSnapshot ( BaseModel , TimestampMixin ) :
"""
Immutable snapshot of a chat thread for public sharing .
Each snapshot is a frozen copy of the chat at a specific point in time .
The snapshot_data JSONB contains all messages and metadata needed to
render the public chat without querying the original thread .
"""
__tablename__ = " public_chat_snapshots "
# Link to original thread - CASCADE DELETE when thread is deleted
thread_id = Column (
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
# Public access token (unique URL identifier)
share_token = Column (
String ( 64 ) ,
nullable = False ,
unique = True ,
index = True ,
)
content_hash = Column (
String ( 64 ) ,
nullable = False ,
index = True ,
)
snapshot_data = Column ( JSONB , nullable = False )
message_ids = Column ( ARRAY ( Integer ) , nullable = False )
created_by_user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
# Relationships
2026-01-30 17:08:07 +02:00
thread = relationship (
" NewChatThread " ,
back_populates = " snapshots " ,
foreign_keys = " [PublicChatSnapshot.thread_id] " ,
)
2026-01-29 16:05:36 +02:00
created_by = relationship ( " User " )
# Constraints
__table_args__ = (
# Prevent duplicate snapshots of the same content for the same thread
2026-02-01 21:17:24 -08:00
UniqueConstraint (
" thread_id " , " content_hash " , name = " uq_snapshot_thread_content_hash "
) ,
2026-01-29 16:05:36 +02:00
)
2026-01-15 16:34:03 +02:00
class ChatComment ( BaseModel , TimestampMixin ) :
"""
Comment model for comments on AI chat responses .
Supports one level of nesting ( replies to comments , but no replies to replies ) .
"""
__tablename__ = " chat_comments "
message_id = Column (
Integer ,
ForeignKey ( " new_chat_messages.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
2026-03-23 19:49:28 +02:00
# Denormalized thread_id for efficient Zero subscriptions (one per thread)
2026-01-22 17:27:42 +02:00
thread_id = Column (
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
2026-01-15 16:34:03 +02:00
parent_id = Column (
Integer ,
ForeignKey ( " chat_comments.id " , ondelete = " CASCADE " ) ,
nullable = True ,
index = True ,
)
author_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
content = Column ( Text , nullable = False )
updated_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
onupdate = lambda : datetime . now ( UTC ) ,
index = True ,
)
# Relationships
message = relationship ( " NewChatMessage " , back_populates = " comments " )
2026-01-22 17:27:42 +02:00
thread = relationship ( " NewChatThread " )
2026-01-15 16:34:03 +02:00
author = relationship ( " User " )
parent = relationship (
" ChatComment " , remote_side = " ChatComment.id " , backref = " replies "
)
mentions = relationship (
" ChatCommentMention " ,
back_populates = " comment " ,
cascade = " all, delete-orphan " ,
)
2025-12-21 16:16:50 -08:00
2026-01-15 16:37:46 +02:00
class ChatCommentMention ( BaseModel , TimestampMixin ) :
"""
Tracks @mentions in chat comments for notification purposes .
"""
__tablename__ = " chat_comment_mentions "
comment_id = Column (
Integer ,
ForeignKey ( " chat_comments.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
mentioned_user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
# Relationships
comment = relationship ( " ChatComment " , back_populates = " mentions " )
mentioned_user = relationship ( " User " )
2026-01-20 16:17:54 +02:00
class ChatSessionState ( BaseModel ) :
"""
Tracks real - time session state for shared chat collaboration .
2026-03-23 19:49:28 +02:00
One record per thread , synced via Zero .
2026-01-20 16:17:54 +02:00
"""
__tablename__ = " chat_session_state "
thread_id = Column (
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " CASCADE " ) ,
nullable = False ,
unique = True ,
index = True ,
)
ai_responding_to_user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
updated_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
onupdate = lambda : datetime . now ( UTC ) ,
)
thread = relationship ( " NewChatThread " )
ai_responding_to_user = relationship ( " User " )
2026-03-27 01:39:15 -07:00
class Folder ( BaseModel , TimestampMixin ) :
__tablename__ = " folders "
name = Column ( String ( 255 ) , nullable = False , index = True )
position = Column ( String ( 50 ) , nullable = False , index = True )
parent_id = Column (
Integer ,
ForeignKey ( " folders.id " , ondelete = " CASCADE " ) ,
nullable = True ,
index = True ,
)
2026-06-26 18:18:50 +02:00
workspace_id = Column (
2026-03-27 01:39:15 -07:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
2026-03-27 01:39:15 -07:00
nullable = False ,
index = True ,
)
created_by_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
updated_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
onupdate = lambda : datetime . now ( UTC ) ,
index = True ,
)
2026-04-02 23:46:21 +05:30
folder_metadata = Column ( " metadata " , JSONB , nullable = True )
2026-03-27 01:39:15 -07:00
parent = relationship ( " Folder " , remote_side = " Folder.id " , backref = " children " )
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , back_populates = " folders " )
2026-03-27 01:39:15 -07:00
created_by = relationship ( " User " , back_populates = " folders " )
documents = relationship ( " Document " , back_populates = " folder " , passive_deletes = True )
2025-03-14 18:53:14 -07:00
class Document ( BaseModel , TimestampMixin ) :
__tablename__ = " documents "
2025-07-24 14:43:48 -07:00
2025-04-30 00:10:50 -07:00
title = Column ( String , nullable = False , index = True )
2025-03-14 18:53:14 -07:00
document_type = Column ( SQLAlchemyEnum ( DocumentType ) , nullable = False )
document_metadata = Column ( JSON , nullable = True )
2025-07-24 14:43:48 -07:00
2025-03-14 18:53:14 -07:00
content = Column ( Text , nullable = False )
2026-04-28 21:30:53 -07:00
# ``content_hash`` is intentionally NOT globally unique. In a real
# filesystem two files at different paths can hold identical bytes,
# and the agent's ``write_file`` flow needs that semantic to support
# copy / duplicate operations. Path uniqueness lives on
2026-06-26 18:18:50 +02:00
# ``unique_identifier_hash`` (per workspace). The hash remains
2026-04-28 21:30:53 -07:00
# indexed because connector indexers consult it as a change-detection
# / cross-source dedup hint via :func:`check_duplicate_document`.
# See migration 133.
content_hash = Column ( String , nullable = False , index = True )
2025-10-14 21:09:11 -07:00
unique_identifier_hash = Column ( String , nullable = True , index = True , unique = True )
2025-03-14 18:53:14 -07:00
embedding = Column ( Vector ( config . embedding_model_instance . dimension ) )
2025-07-24 14:43:48 -07:00
2025-11-23 15:23:31 +05:30
# BlockNote live editing state (NULL when never edited)
2026-02-17 11:34:11 +05:30
# DEPRECATED: Will be removed in a future migration. Use source_markdown instead.
2025-11-23 15:23:31 +05:30
blocknote_document = Column ( JSONB , nullable = True )
2025-11-23 16:39:23 +05:30
2026-02-17 11:34:11 +05:30
# Full raw markdown content for the Plate.js editor.
# This is the source of truth for document content in the editor.
# Populated from markdown at ingestion time, or from blocknote_document migration.
source_markdown = Column ( Text , nullable = True )
# Background reindex flag (set when editor content is saved)
2025-11-23 15:23:31 +05:30
content_needs_reindexing = Column (
Boolean , nullable = False , default = False , server_default = text ( " false " )
)
2025-11-23 16:39:23 +05:30
2025-12-12 01:32:14 -08:00
# Track when document was last updated by indexers, processors, or editor
updated_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True , index = True )
2025-07-24 14:43:48 -07:00
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2025-07-24 14:43:48 -07:00
)
2026-02-02 12:32:24 +05:30
2026-03-27 01:39:15 -07:00
folder_id = Column (
Integer ,
ForeignKey ( " folders.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
2026-02-02 12:32:24 +05:30
# Track who created/uploaded this document
created_by_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " SET NULL " ) ,
nullable = True , # Nullable for backward compatibility with existing records
index = True ,
)
2026-02-02 16:23:26 +05:30
# Track which connector created this document (for cleanup on connector deletion)
connector_id = Column (
Integer ,
ForeignKey ( " search_source_connectors.id " , ondelete = " SET NULL " ) ,
nullable = True , # Nullable for manually uploaded docs without connector
index = True ,
)
2026-02-05 21:59:31 +05:30
# Processing status for real-time visibility (JSONB)
# Format: {"state": "ready"} or {"state": "processing"} or {"state": "failed", "reason": "..."}
# Default to {"state": "ready"} for backward compatibility with existing documents
status = Column (
JSONB ,
nullable = False ,
default = DocumentStatus . ready ,
2026-02-06 05:35:15 +05:30
server_default = text ( ' \' { " state " : " ready " } \' ::jsonb ' ) ,
2026-02-05 21:59:31 +05:30
index = True ,
)
2026-02-02 12:32:24 +05:30
# Relationships
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , back_populates = " documents " )
2026-03-27 01:39:15 -07:00
folder = relationship ( " Folder " , back_populates = " documents " )
2026-02-02 12:32:24 +05:30
created_by = relationship ( " User " , back_populates = " documents " )
2026-02-02 16:23:26 +05:30
connector = relationship ( " SearchSourceConnector " , back_populates = " documents " )
2025-07-24 14:43:48 -07:00
chunks = relationship (
2026-06-12 18:52:45 +02:00
" Chunk " ,
back_populates = " document " ,
cascade = " all, delete-orphan " ,
order_by = " Chunk.position " ,
2025-07-24 14:43:48 -07:00
)
2026-06-02 16:10:44 +02:00
# Original upload + future derived artifacts (redacted, filled-form).
# Model lives in app.file_storage.persistence to keep that feature cohesive.
files = relationship (
" DocumentFile " , back_populates = " document " , cascade = " all, delete-orphan "
)
2025-07-24 14:43:48 -07:00
2025-03-14 18:53:14 -07:00
2026-04-02 10:35:32 +05:30
class DocumentVersion ( BaseModel , TimestampMixin ) :
__tablename__ = " document_versions "
__table_args__ = (
UniqueConstraint ( " document_id " , " version_number " , name = " uq_document_version " ) ,
)
document_id = Column (
Integer ,
ForeignKey ( " documents.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
version_number = Column ( Integer , nullable = False )
source_markdown = Column ( Text , nullable = True )
content_hash = Column ( String , nullable = False )
title = Column ( String , nullable = True )
2026-04-08 14:48:40 +05:30
document = relationship (
" Document " , backref = backref ( " versions " , passive_deletes = True )
)
2026-04-02 10:35:32 +05:30
2025-03-14 18:53:14 -07:00
class Chunk ( BaseModel , TimestampMixin ) :
__tablename__ = " chunks "
2025-07-24 14:43:48 -07:00
2025-03-14 18:53:14 -07:00
content = Column ( Text , nullable = False )
embedding = Column ( Vector ( config . embedding_model_instance . dimension ) )
2026-06-12 18:52:45 +02:00
# Explicit document order; ids don't follow it since incremental
2026-06-18 08:55:47 -07:00
# re-indexing keeps unchanged rows across edits. Deliberately not indexed:
# ordering reads are document-scoped (covered by ix_chunks_document_id) and
# building a position index on the large chunks table is not worth it.
position = Column ( Integer , nullable = False , server_default = " 0 " )
2025-07-24 14:43:48 -07:00
document_id = Column (
2026-03-10 17:36:26 -07:00
Integer ,
ForeignKey ( " documents.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
2025-07-24 14:43:48 -07:00
)
2025-03-14 18:53:14 -07:00
document = relationship ( " Document " , back_populates = " chunks " )
2025-07-24 14:43:48 -07:00
2026-03-21 22:13:41 -07:00
class VideoPresentation ( BaseModel , TimestampMixin ) :
""" Video presentation model for storing AI-generated video presentations.
The slides JSONB stores per - slide data including Remotion component code ,
audio file paths , and durations . The frontend compiles the code and renders
the video using Remotion Player .
"""
__tablename__ = " video_presentations "
title = Column ( String ( 500 ) , nullable = False )
slides = Column ( JSONB , nullable = True )
scene_codes = Column ( JSONB , nullable = True )
status = Column (
SQLAlchemyEnum (
VideoPresentationStatus ,
name = " video_presentation_status " ,
create_type = False ,
values_callable = lambda x : [ e . value for e in x ] ,
) ,
nullable = False ,
default = VideoPresentationStatus . READY ,
server_default = " ready " ,
index = True ,
)
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2026-03-21 22:13:41 -07:00
)
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , back_populates = " video_presentations " )
2026-03-21 22:13:41 -07:00
thread_id = Column (
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
thread = relationship ( " NewChatThread " )
2026-02-11 16:28:56 +05:30
class Report ( BaseModel , TimestampMixin ) :
2026-04-15 20:40:40 +05:30
""" Report model for storing generated reports (Markdown or Typst). """
2026-02-11 16:28:56 +05:30
__tablename__ = " reports "
title = Column ( String ( 500 ) , nullable = False )
2026-04-15 20:40:40 +05:30
content = Column ( Text , nullable = True )
content_type = Column ( String ( 20 ) , nullable = False , server_default = " markdown " )
2026-02-11 16:28:56 +05:30
report_metadata = Column ( JSONB , nullable = True ) # section headings, word count, etc.
2026-02-13 02:43:26 +05:30
report_style = Column (
String ( 100 ) , nullable = True
) # e.g. "executive_summary", "deep_research"
2026-02-11 16:28:56 +05:30
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2026-02-11 16:28:56 +05:30
)
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , back_populates = " reports " )
2026-02-11 16:28:56 +05:30
2026-02-12 03:19:38 +05:30
# Versioning: reports sharing the same report_group_id are versions of the same report.
# For v1, report_group_id = the report's own id (set after insert).
report_group_id = Column ( Integer , nullable = True , index = True )
2026-02-11 16:28:56 +05:30
thread_id = Column (
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
thread = relationship ( " NewChatThread " )
2026-06-10 21:47:23 +05:30
class Connection ( BaseModel , TimestampMixin ) :
__tablename__ = " connections "
2026-02-05 16:43:48 -08:00
2026-06-12 02:17:22 +05:30
provider = Column ( String ( 100 ) , nullable = False , index = True )
2026-06-10 21:47:23 +05:30
base_url = Column ( String ( 500 ) , nullable = True )
api_key = Column ( String , nullable = True )
extra = Column ( JSONB , nullable = False , default = dict , server_default = " {} " )
scope = Column ( SQLAlchemyEnum ( ConnectionScope ) , nullable = False , index = True )
enabled = Column ( Boolean , nullable = False , default = True , server_default = " true " )
2026-02-05 16:43:48 -08:00
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = True ,
2026-02-05 17:18:27 -08:00
)
2026-02-09 18:30:52 +05:30
user_id = Column (
2026-06-10 21:47:23 +05:30
UUID ( as_uuid = True ) , ForeignKey ( " user.id " , ondelete = " CASCADE " ) , nullable = True
2026-02-09 18:30:52 +05:30
)
2026-04-07 18:49:04 +02:00
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , back_populates = " connections " )
2026-06-10 21:47:23 +05:30
user = relationship ( " User " , back_populates = " connections " )
models = relationship (
" Model " ,
back_populates = " connection " ,
order_by = " Model.id " ,
cascade = " all, delete-orphan " ,
passive_deletes = True ,
)
2026-04-07 18:49:04 +02:00
2026-06-10 21:47:23 +05:30
__table_args__ = (
CheckConstraint (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
" (scope = ' GLOBAL ' AND workspace_id IS NULL AND user_id IS NULL) OR "
" (scope = ' SEARCH_SPACE ' AND workspace_id IS NOT NULL AND user_id IS NOT NULL) OR "
2026-06-10 21:47:23 +05:30
" (scope = ' USER ' AND user_id IS NOT NULL) " ,
name = " ck_connections_scope_owner " ,
) ,
)
2026-04-07 18:49:04 +02:00
2026-06-10 21:47:23 +05:30
class Model ( BaseModel , TimestampMixin ) :
__tablename__ = " models "
2026-04-07 18:49:04 +02:00
2026-06-10 21:47:23 +05:30
connection_id = Column (
2026-06-13 21:59:35 +05:30
Integer ,
ForeignKey ( " connections.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
2026-06-10 21:47:23 +05:30
)
model_id = Column ( String ( 255 ) , nullable = False )
display_name = Column ( String ( 255 ) , nullable = True )
source = Column (
SQLAlchemyEnum ( ModelSource ) ,
nullable = False ,
default = ModelSource . DISCOVERED ,
server_default = ModelSource . DISCOVERED . value ,
)
2026-06-12 02:17:22 +05:30
supports_chat = Column ( Boolean , nullable = True )
max_input_tokens = Column ( Integer , nullable = True )
supports_image_input = Column ( Boolean , nullable = True )
supports_tools = Column ( Boolean , nullable = True )
supports_image_generation = Column ( Boolean , nullable = True )
2026-06-13 21:59:35 +05:30
capabilities_override = Column (
JSONB , nullable = False , default = dict , server_default = " {} "
2026-04-07 18:49:04 +02:00
)
2026-06-10 21:47:23 +05:30
enabled = Column ( Boolean , nullable = False , default = True , server_default = " true " )
billing_tier = Column ( String ( 50 ) , nullable = True , index = True )
catalog = Column ( JSONB , nullable = False , default = dict , server_default = " {} " )
2026-04-07 18:49:04 +02:00
2026-06-10 21:47:23 +05:30
connection = relationship ( " Connection " , back_populates = " models " )
__table_args__ = (
2026-06-13 21:59:35 +05:30
UniqueConstraint (
" connection_id " , " model_id " , name = " uq_models_connection_model_id "
) ,
2026-06-10 21:47:23 +05:30
Index ( " ix_models_model_id " , " model_id " ) ,
2026-04-07 18:49:04 +02:00
)
2026-02-05 16:43:48 -08:00
class ImageGeneration ( BaseModel , TimestampMixin ) :
"""
Stores image generation requests and results using litellm . aimage_generation ( ) .
Since aimage_generation is a single async call ( not a background job ) ,
there is no status enum . A row with response_data means success ;
a row with error_message means failure .
Response data is stored as JSONB matching the litellm output format :
{
" created " : int ,
" data " : [ { " b64_json " : str | None , " revised_prompt " : str | None , " url " : str | None } ] ,
" usage " : { " prompt_tokens " : int , " completion_tokens " : int , " total_tokens " : int }
}
"""
__tablename__ = " image_generations "
# Request parameters (matching litellm.aimage_generation() params)
prompt = Column ( Text , nullable = False )
model = Column ( String ( 200 ) , nullable = True ) # e.g., "dall-e-3", "gpt-image-1"
n = Column ( Integer , nullable = True , default = 1 )
quality = Column (
String ( 50 ) , nullable = True
) # "auto", "high", "medium", "low", "hd", "standard"
size = Column (
String ( 50 ) , nullable = True
) # "1024x1024", "1536x1024", "1024x1536", etc.
style = Column ( String ( 50 ) , nullable = True ) # Model-specific style parameter
2026-02-05 17:18:27 -08:00
response_format = Column ( String ( 50 ) , nullable = True ) # "url" or "b64_json"
2026-02-05 16:43:48 -08:00
2026-06-13 12:45:43 +05:30
# Image generation model provenance.
# 0 = Auto mode, negative IDs = GLOBAL models, positive IDs = Model records.
image_gen_model_id = Column ( Integer , nullable = True )
2026-02-05 16:43:48 -08:00
# Response data (full litellm response as JSONB) — present on success
response_data = Column ( JSONB , nullable = True )
# Error message — present on failure
error_message = Column ( Text , nullable = True )
# Signed access token for serving images via <img> tags.
# Stored in DB so it survives SECRET_KEY rotation.
access_token = Column ( String ( 64 ) , nullable = True , index = True )
# Foreign keys
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2026-02-05 16:43:48 -08:00
)
created_by_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
# Relationships
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , back_populates = " image_generations " )
2026-02-05 16:43:48 -08:00
created_by = relationship ( " User " , back_populates = " image_generations " )
2026-06-26 18:18:50 +02:00
class Workspace ( BaseModel , TimestampMixin ) :
2026-06-26 13:27:16 +02:00
__tablename__ = " workspaces "
2025-07-24 14:43:48 -07:00
2025-03-14 18:53:14 -07:00
name = Column ( String ( 100 ) , nullable = False , index = True )
description = Column ( String ( 500 ) , nullable = True )
2025-07-24 14:43:48 -07:00
2025-11-19 15:04:46 -08:00
citations_enabled = Column (
Boolean , nullable = False , default = True
) # Enable/disable citations
2026-06-19 20:26:28 +05:30
api_access_enabled = Column (
Boolean , nullable = False , default = False , server_default = " false "
)
2025-11-19 15:04:46 -08:00
qna_custom_instructions = Column (
Text , nullable = True , default = " "
) # User's custom instructions
2025-11-27 22:45:04 -08:00
2026-04-08 23:21:24 +05:30
shared_memory_md = Column ( Text , nullable = True , server_default = " " )
2026-06-13 12:45:43 +05:30
# Connection/model role bindings.
2026-06-10 21:47:23 +05:30
# Note: ID values preserve the existing convention:
# - 0: Auto mode
# - Negative IDs: Global virtual models from global_llm_config.yaml
2026-06-26 18:18:50 +02:00
# - Positive IDs: User/workspace models from the models table
2026-06-10 21:47:23 +05:30
chat_model_id = Column (
2026-06-13 01:37:12 +05:30
Integer , nullable = True , default = 0 , server_default = " 0 "
2026-01-29 15:28:31 -08:00
) # For agent/chat operations, defaults to Auto mode
2026-06-10 21:47:23 +05:30
image_gen_model_id = Column (
2026-06-13 01:37:12 +05:30
Integer , nullable = True , default = 0 , server_default = " 0 "
2026-06-10 21:47:23 +05:30
) # For image generation, defaults to Auto mode when eligible
vision_model_id = Column (
2026-06-13 01:37:12 +05:30
Integer , nullable = True , default = 0 , server_default = " 0 "
2026-04-03 17:40:27 +02:00
) # For vision/screenshot analysis, defaults to Auto mode
2025-11-27 22:45:04 -08:00
2026-04-14 01:43:30 -07:00
ai_file_sort_enabled = Column (
Boolean , nullable = False , default = False , server_default = " false "
)
2025-07-24 14:43:48 -07:00
user_id = Column (
UUID ( as_uuid = True ) , ForeignKey ( " user.id " , ondelete = " CASCADE " ) , nullable = False
)
2026-06-26 18:18:50 +02:00
user = relationship ( " User " , back_populates = " workspaces " )
2025-07-24 14:43:48 -07:00
2026-03-27 01:39:15 -07:00
folders = relationship (
" Folder " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2026-03-27 01:39:15 -07:00
order_by = " Folder.position " ,
cascade = " all, delete-orphan " ,
)
2025-07-24 14:43:48 -07:00
documents = relationship (
" Document " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2025-07-24 14:43:48 -07:00
order_by = " Document.id " ,
cascade = " all, delete-orphan " ,
)
2025-12-21 16:16:50 -08:00
new_chat_threads = relationship (
" NewChatThread " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2025-12-21 16:16:50 -08:00
order_by = " NewChatThread.updated_at.desc() " ,
cascade = " all, delete-orphan " ,
)
2025-12-21 22:26:33 -08:00
podcasts = relationship (
" Podcast " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2025-12-21 22:26:33 -08:00
order_by = " Podcast.id.desc() " ,
cascade = " all, delete-orphan " ,
)
2026-03-21 22:13:41 -07:00
video_presentations = relationship (
" VideoPresentation " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2026-03-21 22:13:41 -07:00
order_by = " VideoPresentation.id.desc() " ,
cascade = " all, delete-orphan " ,
)
2026-02-11 16:28:56 +05:30
reports = relationship (
" Report " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2026-02-11 16:28:56 +05:30
order_by = " Report.id.desc() " ,
cascade = " all, delete-orphan " ,
)
2026-02-05 16:43:48 -08:00
image_generations = relationship (
" ImageGeneration " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2026-02-05 16:43:48 -08:00
order_by = " ImageGeneration.id.desc() " ,
cascade = " all, delete-orphan " ,
)
2025-07-24 14:43:48 -07:00
logs = relationship (
" Log " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2025-07-24 14:43:48 -07:00
order_by = " Log.id " ,
cascade = " all, delete-orphan " ,
)
2026-01-16 11:32:06 -08:00
notifications = relationship (
" Notification " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2026-01-16 11:32:06 -08:00
order_by = " Notification.created_at.desc() " ,
cascade = " all, delete-orphan " ,
)
2025-10-08 21:13:01 -07:00
search_source_connectors = relationship (
" SearchSourceConnector " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2025-10-08 21:13:01 -07:00
order_by = " SearchSourceConnector.id " ,
cascade = " all, delete-orphan " ,
)
2026-06-10 21:47:23 +05:30
connections = relationship (
" Connection " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2026-06-10 21:47:23 +05:30
order_by = " Connection.id " ,
2026-04-07 18:49:04 +02:00
cascade = " all, delete-orphan " ,
2026-06-10 21:47:23 +05:30
passive_deletes = True ,
2026-04-07 18:49:04 +02:00
)
2025-07-24 14:43:48 -07:00
2026-05-27 13:45:32 +02:00
automations = relationship (
" Automation " ,
2026-06-26 18:18:50 +02:00
back_populates = " workspace " ,
2026-05-27 13:45:32 +02:00
order_by = " Automation.id " ,
cascade = " all, delete-orphan " ,
passive_deletes = True ,
)
2025-11-27 22:45:04 -08:00
# RBAC relationships
roles = relationship (
2026-06-26 18:18:50 +02:00
" WorkspaceRole " ,
back_populates = " workspace " ,
order_by = " WorkspaceRole.id " ,
2025-11-27 22:45:04 -08:00
cascade = " all, delete-orphan " ,
)
memberships = relationship (
2026-06-26 18:18:50 +02:00
" WorkspaceMembership " ,
back_populates = " workspace " ,
order_by = " WorkspaceMembership.id " ,
2025-11-27 22:45:04 -08:00
cascade = " all, delete-orphan " ,
)
invites = relationship (
2026-06-26 18:18:50 +02:00
" WorkspaceInvite " ,
back_populates = " workspace " ,
order_by = " WorkspaceInvite.id " ,
2025-10-10 00:50:29 -07:00
cascade = " all, delete-orphan " ,
)
2025-07-24 14:43:48 -07:00
2025-03-14 18:53:14 -07:00
class SearchSourceConnector ( BaseModel , TimestampMixin ) :
__tablename__ = " search_source_connectors "
2025-08-02 11:47:49 -07:00
__table_args__ = (
2025-10-08 21:13:01 -07:00
UniqueConstraint (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
" workspace_id " ,
2025-10-08 21:13:01 -07:00
" user_id " ,
" connector_type " ,
2026-01-13 13:46:01 -08:00
" name " ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
name = " uq_workspace_user_connector_type_name " ,
2025-10-08 21:13:01 -07:00
) ,
2026-04-21 03:18:44 +05:30
# Mirrors migration 129; backs the ``/obsidian/connect`` upsert.
Index (
" search_source_connectors_obsidian_plugin_vault_uniq " ,
" user_id " ,
text ( " (config->> ' vault_id ' ) " ) ,
unique = True ,
postgresql_where = text (
" connector_type = ' OBSIDIAN_CONNECTOR ' "
" AND config->> ' source ' = ' plugin ' "
" AND config->> ' vault_id ' IS NOT NULL "
) ,
) ,
2026-04-21 04:21:33 +05:30
# Cross-device dedup: same vault content from different devices
# cannot produce two connector rows.
Index (
" search_source_connectors_obsidian_plugin_fingerprint_uniq " ,
" user_id " ,
text ( " (config->> ' vault_fingerprint ' ) " ) ,
unique = True ,
postgresql_where = text (
" connector_type = ' OBSIDIAN_CONNECTOR ' "
" AND config->> ' source ' = ' plugin ' "
" AND config->> ' vault_fingerprint ' IS NOT NULL "
) ,
) ,
2025-08-02 11:47:49 -07:00
)
2025-07-24 14:43:48 -07:00
2025-03-14 18:53:14 -07:00
name = Column ( String ( 100 ) , nullable = False , index = True )
2025-08-02 11:47:49 -07:00
connector_type = Column ( SQLAlchemyEnum ( SearchSourceConnectorType ) , nullable = False )
2025-03-14 18:53:14 -07:00
is_indexable = Column ( Boolean , nullable = False , default = False )
last_indexed_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
config = Column ( JSON , nullable = False )
2025-07-24 14:43:48 -07:00
2026-04-10 16:45:51 +02:00
# Vision LLM for image files - disabled by default to save cost/time.
# When enabled, images are described via a vision language model instead
# of falling back to the document parser.
enable_vision_llm = Column (
Boolean , nullable = False , default = False , server_default = " false "
)
2025-10-22 16:14:25 -07:00
# Periodic indexing fields
periodic_indexing_enabled = Column ( Boolean , nullable = False , default = False )
indexing_frequency_minutes = Column ( Integer , nullable = True )
next_scheduled_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2025-10-08 21:13:01 -07:00
)
2026-06-26 18:18:50 +02:00
workspace = relationship (
" Workspace " , back_populates = " search_source_connectors "
2025-10-08 21:13:01 -07:00
)
2025-07-24 14:43:48 -07:00
user_id = Column (
UUID ( as_uuid = True ) , ForeignKey ( " user.id " , ondelete = " CASCADE " ) , nullable = False
)
2026-02-09 18:30:52 +05:30
user = relationship ( " User " , back_populates = " search_source_connectors " )
2025-03-14 18:53:14 -07:00
2026-02-02 16:23:26 +05:30
# Documents created by this connector (for cleanup on connector deletion)
documents = relationship ( " Document " , back_populates = " connector " )
2025-07-24 14:43:48 -07:00
2025-07-16 01:10:33 -07:00
class Log ( BaseModel , TimestampMixin ) :
__tablename__ = " logs "
2025-07-24 14:43:48 -07:00
2025-07-16 01:10:33 -07:00
level = Column ( SQLAlchemyEnum ( LogLevel ) , nullable = False , index = True )
status = Column ( SQLAlchemyEnum ( LogStatus ) , nullable = False , index = True )
message = Column ( Text , nullable = False )
2025-07-24 14:43:48 -07:00
source = Column (
String ( 200 ) , nullable = True , index = True
) # Service/component that generated the log
2025-07-16 01:10:33 -07:00
log_metadata = Column ( JSON , nullable = True , default = { } ) # Additional context data
2025-07-24 14:43:48 -07:00
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2025-07-24 14:43:48 -07:00
)
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , back_populates = " logs " )
2025-07-16 01:10:33 -07:00
2025-07-24 14:43:48 -07:00
2026-01-26 23:32:30 -08:00
class UserIncentiveTask ( BaseModel , TimestampMixin ) :
"""
Tracks completed incentive tasks for users .
Each user can only complete each task type once .
2026-06-10 16:49:03 -07:00
When a task is completed , the user ' s credit_micros_balance is increased.
2026-01-26 23:32:30 -08:00
"""
__tablename__ = " user_incentive_tasks "
__table_args__ = (
UniqueConstraint (
" user_id " ,
" task_type " ,
name = " uq_user_incentive_task " ,
) ,
)
user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
task_type = Column ( SQLAlchemyEnum ( IncentiveTaskType ) , nullable = False , index = True )
2026-06-10 16:49:03 -07:00
# Credit reward granted in USD micro-units (1_000_000 == $1.00).
credit_micros_awarded = Column ( BigInteger , nullable = False )
2026-01-26 23:32:30 -08:00
completed_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
)
user = relationship ( " User " , back_populates = " incentive_tasks " )
2026-03-31 18:39:45 -07:00
class PagePurchase ( Base , TimestampMixin ) :
""" Tracks Stripe checkout sessions used to grant additional page credits. """
__tablename__ = " page_purchases "
__allow_unmapped__ = True
id = Column ( UUID ( as_uuid = True ) , primary_key = True , default = uuid . uuid4 )
user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
stripe_checkout_session_id = Column (
String ( 255 ) , nullable = False , unique = True , index = True
)
stripe_payment_intent_id = Column ( String ( 255 ) , nullable = True , index = True )
quantity = Column ( Integer , nullable = False )
pages_granted = Column ( Integer , nullable = False )
amount_total = Column ( Integer , nullable = True )
currency = Column ( String ( 10 ) , nullable = True )
status = Column (
SQLAlchemyEnum ( PagePurchaseStatus ) ,
nullable = False ,
default = PagePurchaseStatus . PENDING ,
server_default = text ( " ' PENDING ' ::pagepurchasestatus " ) ,
index = True ,
)
completed_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
user = relationship ( " User " , back_populates = " page_purchases " )
2026-06-10 16:49:03 -07:00
class CreditPurchase ( Base , TimestampMixin ) :
""" Tracks Stripe checkout sessions used to grant credit (USD micro-units).
2026-05-02 14:34:23 -07:00
2026-06-10 16:49:03 -07:00
Renamed from ` ` premium_token_purchases ` ` in migration 156 as part of the
unified - credits wallet . ` ` credit_micros_granted ` ` stores the USD - micro
amount added to ` ` user . credit_micros_balance ` ` on fulfillment .
` ` source ` ` distinguishes a user - initiated checkout from an automatic
off - session top - up ( auto - reload ) , added in the auto - reload migration .
2026-05-02 14:34:23 -07:00
"""
2026-04-15 17:02:00 -07:00
2026-06-10 16:49:03 -07:00
__tablename__ = " credit_purchases "
2026-04-15 17:02:00 -07:00
__allow_unmapped__ = True
id = Column ( UUID ( as_uuid = True ) , primary_key = True , default = uuid . uuid4 )
user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
stripe_checkout_session_id = Column (
String ( 255 ) , nullable = False , unique = True , index = True
)
stripe_payment_intent_id = Column ( String ( 255 ) , nullable = True , index = True )
quantity = Column ( Integer , nullable = False )
2026-05-02 14:34:23 -07:00
credit_micros_granted = Column ( BigInteger , nullable = False )
2026-04-15 17:02:00 -07:00
amount_total = Column ( Integer , nullable = True )
currency = Column ( String ( 10 ) , nullable = True )
2026-06-10 16:49:03 -07:00
source = Column (
String ( 20 ) , nullable = False , default = " checkout " , server_default = " checkout "
)
2026-04-15 17:02:00 -07:00
status = Column (
2026-06-10 16:49:03 -07:00
SQLAlchemyEnum ( CreditPurchaseStatus ) ,
2026-04-15 17:02:00 -07:00
nullable = False ,
2026-06-10 16:49:03 -07:00
default = CreditPurchaseStatus . PENDING ,
2026-04-15 17:02:00 -07:00
index = True ,
)
completed_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
2026-06-10 16:49:03 -07:00
user = relationship ( " User " , back_populates = " credit_purchases " )
2026-04-15 17:02:00 -07:00
2026-06-26 18:18:50 +02:00
class WorkspaceRole ( BaseModel , TimestampMixin ) :
2025-11-27 22:45:04 -08:00
"""
2026-06-26 18:18:50 +02:00
Custom roles that can be defined per workspace .
Each workspace can have multiple roles with different permission sets .
2025-11-27 22:45:04 -08:00
"""
2026-06-26 12:31:49 +02:00
__tablename__ = " workspace_roles "
2025-11-27 22:45:04 -08:00
__table_args__ = (
UniqueConstraint (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
" workspace_id " ,
2025-11-27 22:45:04 -08:00
" name " ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
name = " uq_workspace_role_name " ,
2025-11-27 22:45:04 -08:00
) ,
)
name = Column ( String ( 100 ) , nullable = False , index = True )
description = Column ( String ( 500 ) , nullable = True )
# List of Permission enum values (e.g., ["documents:read", "chats:create"])
permissions = Column ( ARRAY ( String ) , nullable = False , default = [ ] )
# Whether this role is assigned to new members by default when they join via invite
is_default = Column ( Boolean , nullable = False , default = False )
2026-01-20 02:59:32 -08:00
# System roles (Owner, Editor, Viewer) cannot be deleted
2025-11-27 22:45:04 -08:00
is_system_role = Column ( Boolean , nullable = False , default = False )
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2025-11-27 22:45:04 -08:00
)
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , back_populates = " roles " )
2025-11-27 22:45:04 -08:00
memberships = relationship (
2026-06-26 18:18:50 +02:00
" WorkspaceMembership " , back_populates = " role " , passive_deletes = True
2025-11-27 22:45:04 -08:00
)
invites = relationship (
2026-06-26 18:18:50 +02:00
" WorkspaceInvite " , back_populates = " role " , passive_deletes = True
2025-11-27 22:45:04 -08:00
)
2026-06-26 18:18:50 +02:00
class WorkspaceMembership ( BaseModel , TimestampMixin ) :
2025-11-27 22:45:04 -08:00
"""
2026-06-26 18:18:50 +02:00
Tracks user membership in workspaces with their assigned role .
Each user can be a member of multiple workspaces with different roles .
2025-11-27 22:45:04 -08:00
"""
2026-06-26 12:30:38 +02:00
__tablename__ = " workspace_memberships "
2025-10-10 00:50:29 -07:00
__table_args__ = (
UniqueConstraint (
" user_id " ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
" workspace_id " ,
name = " uq_user_workspace_membership " ,
2025-10-10 00:50:29 -07:00
) ,
)
2025-07-24 14:43:48 -07:00
user_id = Column (
UUID ( as_uuid = True ) , ForeignKey ( " user.id " , ondelete = " CASCADE " ) , nullable = False
)
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2025-10-10 00:50:29 -07:00
)
2025-11-27 22:45:04 -08:00
role_id = Column (
Integer ,
2026-06-26 12:31:49 +02:00
ForeignKey ( " workspace_roles.id " , ondelete = " SET NULL " ) ,
2025-11-27 22:45:04 -08:00
nullable = True ,
)
2026-06-26 18:18:50 +02:00
# Indicates if this user is the original creator/owner of the workspace
2025-11-27 22:45:04 -08:00
is_owner = Column ( Boolean , nullable = False , default = False )
# Timestamp when the user joined (via invite or as creator)
joined_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
)
# Reference to the invite used to join (null if owner/creator)
invited_by_invite_id = Column (
Integer ,
2026-06-26 12:29:29 +02:00
ForeignKey ( " workspace_invites.id " , ondelete = " SET NULL " ) ,
2025-11-27 22:45:04 -08:00
nullable = True ,
)
2025-10-10 00:50:29 -07:00
2026-06-26 18:18:50 +02:00
user = relationship ( " User " , back_populates = " workspace_memberships " )
workspace = relationship ( " Workspace " , back_populates = " memberships " )
role = relationship ( " WorkspaceRole " , back_populates = " memberships " )
2025-11-27 22:45:04 -08:00
invited_by_invite = relationship (
2026-06-26 18:18:50 +02:00
" WorkspaceInvite " , back_populates = " used_by_memberships "
2025-11-27 22:45:04 -08:00
)
2025-10-10 00:50:29 -07:00
2025-06-09 15:50:15 -07:00
2026-06-26 18:18:50 +02:00
class WorkspaceInvite ( BaseModel , TimestampMixin ) :
2025-11-27 22:45:04 -08:00
"""
2026-06-26 18:18:50 +02:00
Invite links for workspaces .
2025-11-27 22:45:04 -08:00
Users can create invite links with specific roles that others can use to join .
"""
2025-07-24 14:43:48 -07:00
2026-06-26 12:29:29 +02:00
__tablename__ = " workspace_invites "
2025-07-24 14:43:48 -07:00
2025-11-27 22:45:04 -08:00
# Unique invite code (used in invite URLs)
invite_code = Column ( String ( 64 ) , nullable = False , unique = True , index = True )
2025-07-24 14:43:48 -07:00
2026-06-26 18:18:50 +02:00
workspace_id = Column (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
nullable = False ,
2025-07-24 14:43:48 -07:00
)
2025-11-27 22:45:04 -08:00
# Role to assign when invite is used (null means use default role)
role_id = Column (
Integer ,
2026-06-26 12:31:49 +02:00
ForeignKey ( " workspace_roles.id " , ondelete = " SET NULL " ) ,
2025-11-27 22:45:04 -08:00
nullable = True ,
)
# User who created this invite
created_by_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " SET NULL " ) ,
nullable = True ,
)
# Expiration timestamp (null means never expires)
expires_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
# Maximum number of times this invite can be used (null means unlimited)
max_uses = Column ( Integer , nullable = True )
# Number of times this invite has been used
uses_count = Column ( Integer , nullable = False , default = 0 )
# Whether this invite is currently active
is_active = Column ( Boolean , nullable = False , default = True )
# Optional custom name/label for the invite
name = Column ( String ( 100 ) , nullable = True )
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " , back_populates = " invites " )
role = relationship ( " WorkspaceRole " , back_populates = " invites " )
2025-11-27 22:45:04 -08:00
created_by = relationship ( " User " , back_populates = " created_invites " )
used_by_memberships = relationship (
2026-06-26 18:18:50 +02:00
" WorkspaceMembership " ,
2025-11-27 22:45:04 -08:00
back_populates = " invited_by_invite " ,
passive_deletes = True ,
)
2025-07-16 01:10:33 -07:00
2025-07-24 14:43:48 -07:00
2026-03-29 00:07:08 +02:00
class PromptMode ( StrEnum ) :
2026-03-31 20:12:04 +02:00
transform = " transform "
explore = " explore "
2026-03-27 21:02:36 +02:00
2026-03-29 00:07:08 +02:00
class Prompt ( BaseModel , TimestampMixin ) :
__tablename__ = " prompts "
2026-03-31 16:27:04 +02:00
__table_args__ = (
UniqueConstraint (
" user_id " ,
2026-03-31 18:05:42 +02:00
" default_prompt_slug " ,
name = " uq_prompt_user_default_slug " ,
2026-03-31 16:27:04 +02:00
) ,
)
2026-03-27 21:02:36 +02:00
user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
2026-06-26 18:18:50 +02:00
workspace_id = Column (
2026-03-27 21:02:36 +02:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
2026-03-27 21:02:36 +02:00
nullable = True ,
index = True ,
)
2026-03-31 18:05:42 +02:00
default_prompt_slug = Column ( String ( 100 ) , nullable = True , index = True )
2026-03-27 21:02:36 +02:00
name = Column ( String ( 200 ) , nullable = False )
prompt = Column ( Text , nullable = False )
2026-03-31 20:12:04 +02:00
mode = Column (
SQLAlchemyEnum ( PromptMode , name = " prompt_mode " , create_type = False ) ,
nullable = False ,
)
2026-03-31 18:05:42 +02:00
version = Column ( Integer , nullable = False , default = 1 )
2026-03-30 19:33:16 +02:00
is_public = Column ( Boolean , nullable = False , default = False )
2026-03-27 21:02:36 +02:00
user = relationship ( " User " )
2026-06-26 18:18:50 +02:00
workspace = relationship ( " Workspace " )
2026-03-27 21:02:36 +02:00
2025-05-21 20:56:23 -07:00
if config . AUTH_TYPE == " GOOGLE " :
2025-07-24 14:43:48 -07:00
2025-05-21 20:56:23 -07:00
class OAuthAccount ( SQLAlchemyBaseOAuthAccountTableUUID , Base ) :
pass
2025-03-14 18:53:14 -07:00
2025-05-21 20:56:23 -07:00
class User ( SQLAlchemyBaseUserTableUUID , Base ) :
oauth_accounts : Mapped [ list [ OAuthAccount ] ] = relationship (
" OAuthAccount " , lazy = " joined "
)
2026-06-26 18:18:50 +02:00
workspaces = relationship ( " Workspace " , back_populates = " user " )
2026-01-16 11:32:06 -08:00
notifications = relationship (
" Notification " ,
back_populates = " user " ,
order_by = " Notification.created_at.desc() " ,
cascade = " all, delete-orphan " ,
)
2025-06-09 15:50:15 -07:00
2025-11-27 22:45:04 -08:00
# RBAC relationships
2026-06-26 18:18:50 +02:00
workspace_memberships = relationship (
" WorkspaceMembership " ,
2025-07-24 14:43:48 -07:00
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2025-11-27 22:45:04 -08:00
created_invites = relationship (
2026-06-26 18:18:50 +02:00
" WorkspaceInvite " ,
2025-11-27 22:45:04 -08:00
back_populates = " created_by " ,
passive_deletes = True ,
)
2025-06-09 15:50:15 -07:00
2026-01-13 00:17:12 -08:00
# Chat threads created by this user
new_chat_threads = relationship (
" NewChatThread " ,
back_populates = " created_by " ,
passive_deletes = True ,
)
2026-02-02 12:32:24 +05:30
# Documents created/uploaded by this user
documents = relationship (
" Document " ,
back_populates = " created_by " ,
passive_deletes = True ,
)
2026-03-27 01:39:15 -07:00
# Folders created by this user
folders = relationship (
" Folder " ,
back_populates = " created_by " ,
passive_deletes = True ,
)
2026-02-05 16:43:48 -08:00
# Image generations created by this user
image_generations = relationship (
" ImageGeneration " ,
back_populates = " created_by " ,
passive_deletes = True ,
)
2026-02-09 18:30:52 +05:30
# Connectors created by this user
search_source_connectors = relationship (
" SearchSourceConnector " ,
back_populates = " user " ,
passive_deletes = True ,
)
2026-06-10 21:47:23 +05:30
connections = relationship (
" Connection " ,
2026-04-07 18:49:04 +02:00
back_populates = " user " ,
passive_deletes = True ,
)
2026-05-27 13:45:32 +02:00
# Automations created by this user
automations = relationship (
" Automation " ,
back_populates = " created_by " ,
passive_deletes = True ,
)
2026-01-26 23:32:30 -08:00
# Incentive tasks completed by this user
incentive_tasks = relationship (
" UserIncentiveTask " ,
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2026-03-31 18:39:45 -07:00
page_purchases = relationship (
" PagePurchase " ,
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2026-06-10 16:49:03 -07:00
credit_purchases = relationship (
" CreditPurchase " ,
2026-04-15 17:02:00 -07:00
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2026-01-26 23:32:30 -08:00
2026-06-10 16:49:03 -07:00
# Unified credit wallet (USD micro-units, 1_000_000 == $1.00).
# Decreases on use (ETL pages + premium model calls), increases on
# purchase / incentive grant / auto-reload. May dip slightly negative
# when an actual cost exceeds its pre-charge estimate; UI clamps at $0.
credit_micros_balance = Column (
2026-04-15 17:02:00 -07:00
BigInteger ,
nullable = False ,
2026-06-10 16:49:03 -07:00
default = config . DEFAULT_CREDIT_MICROS_BALANCE ,
server_default = str ( config . DEFAULT_CREDIT_MICROS_BALANCE ) ,
2026-04-15 17:02:00 -07:00
)
2026-06-10 16:49:03 -07:00
# In-flight reservation holds (released/settled at finalize).
credit_micros_reserved = Column (
2026-04-15 17:02:00 -07:00
BigInteger , nullable = False , default = 0 , server_default = " 0 "
)
2026-06-10 16:49:03 -07:00
# Auto-reload (off-session Stripe top-up), behind AUTO_RELOAD_ENABLED.
# ``stripe_customer_id`` + ``auto_reload_payment_method_id`` are the
# saved-card plumbing; thresholds are micro-USD. ``auto_reload_failed_at``
# is set (and ``auto_reload_enabled`` flipped off) when an off-session
# charge is declined so the UI can prompt the user to fix their card.
stripe_customer_id = Column ( String , nullable = True )
auto_reload_enabled = Column (
Boolean , nullable = False , default = False , server_default = " false "
2026-04-15 17:02:00 -07:00
)
2026-06-10 16:49:03 -07:00
auto_reload_threshold_micros = Column ( BigInteger , nullable = True )
auto_reload_amount_micros = Column ( BigInteger , nullable = True )
auto_reload_payment_method_id = Column ( String , nullable = True )
auto_reload_failed_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
2026-04-15 17:02:00 -07:00
2026-01-14 14:37:16 +02:00
# User profile from OAuth
display_name = Column ( String , nullable = True )
avatar_url = Column ( String , nullable = True )
2026-03-08 18:24:29 +05:30
last_login = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
2026-04-08 23:21:24 +05:30
memory_md = Column ( Text , nullable = True , server_default = " " )
2026-02-05 16:18:45 +02:00
# Refresh tokens for this user
refresh_tokens = relationship (
" RefreshToken " ,
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2026-06-19 20:26:28 +05:30
personal_access_tokens = relationship (
" PersonalAccessToken " ,
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2026-02-05 16:18:45 +02:00
2025-05-21 20:56:23 -07:00
else :
2025-03-14 18:53:14 -07:00
2025-07-24 14:43:48 -07:00
class User ( SQLAlchemyBaseUserTableUUID , Base ) :
2026-06-26 18:18:50 +02:00
workspaces = relationship ( " Workspace " , back_populates = " user " )
2026-01-16 11:32:06 -08:00
notifications = relationship (
" Notification " ,
back_populates = " user " ,
order_by = " Notification.created_at.desc() " ,
cascade = " all, delete-orphan " ,
)
2025-06-09 15:50:15 -07:00
2025-11-27 22:45:04 -08:00
# RBAC relationships
2026-06-26 18:18:50 +02:00
workspace_memberships = relationship (
" WorkspaceMembership " ,
2025-07-24 14:43:48 -07:00
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2025-11-27 22:45:04 -08:00
created_invites = relationship (
2026-06-26 18:18:50 +02:00
" WorkspaceInvite " ,
2025-11-27 22:45:04 -08:00
back_populates = " created_by " ,
passive_deletes = True ,
)
2025-06-09 15:50:15 -07:00
2026-01-13 00:17:12 -08:00
# Chat threads created by this user
new_chat_threads = relationship (
" NewChatThread " ,
back_populates = " created_by " ,
passive_deletes = True ,
)
2026-02-02 12:32:24 +05:30
# Documents created/uploaded by this user
documents = relationship (
" Document " ,
back_populates = " created_by " ,
passive_deletes = True ,
)
2026-03-27 01:39:15 -07:00
# Folders created by this user
folders = relationship (
" Folder " ,
back_populates = " created_by " ,
passive_deletes = True ,
)
2026-02-05 16:43:48 -08:00
# Image generations created by this user
image_generations = relationship (
" ImageGeneration " ,
back_populates = " created_by " ,
passive_deletes = True ,
)
2026-02-09 18:30:52 +05:30
# Connectors created by this user
search_source_connectors = relationship (
" SearchSourceConnector " ,
back_populates = " user " ,
passive_deletes = True ,
)
2026-06-10 21:47:23 +05:30
connections = relationship (
" Connection " ,
2026-04-07 18:49:04 +02:00
back_populates = " user " ,
passive_deletes = True ,
)
2026-05-27 13:45:32 +02:00
# Automations created by this user
automations = relationship (
" Automation " ,
back_populates = " created_by " ,
passive_deletes = True ,
)
2026-01-26 23:32:30 -08:00
# Incentive tasks completed by this user
incentive_tasks = relationship (
" UserIncentiveTask " ,
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2026-03-31 18:39:45 -07:00
page_purchases = relationship (
" PagePurchase " ,
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2026-06-10 16:49:03 -07:00
credit_purchases = relationship (
" CreditPurchase " ,
2026-04-15 17:02:00 -07:00
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2026-01-26 23:32:30 -08:00
2026-06-10 16:49:03 -07:00
# Unified credit wallet (USD micro-units, 1_000_000 == $1.00).
# Decreases on use (ETL pages + premium model calls), increases on
# purchase / incentive grant / auto-reload. May dip slightly negative
# when an actual cost exceeds its pre-charge estimate; UI clamps at $0.
credit_micros_balance = Column (
2026-04-15 17:02:00 -07:00
BigInteger ,
nullable = False ,
2026-06-10 16:49:03 -07:00
default = config . DEFAULT_CREDIT_MICROS_BALANCE ,
server_default = str ( config . DEFAULT_CREDIT_MICROS_BALANCE ) ,
2026-04-15 17:02:00 -07:00
)
2026-06-10 16:49:03 -07:00
# In-flight reservation holds (released/settled at finalize).
credit_micros_reserved = Column (
2026-04-15 17:02:00 -07:00
BigInteger , nullable = False , default = 0 , server_default = " 0 "
)
2026-06-10 16:49:03 -07:00
# Auto-reload (off-session Stripe top-up), behind AUTO_RELOAD_ENABLED.
# ``stripe_customer_id`` + ``auto_reload_payment_method_id`` are the
# saved-card plumbing; thresholds are micro-USD. ``auto_reload_failed_at``
# is set (and ``auto_reload_enabled`` flipped off) when an off-session
# charge is declined so the UI can prompt the user to fix their card.
stripe_customer_id = Column ( String , nullable = True )
auto_reload_enabled = Column (
Boolean , nullable = False , default = False , server_default = " false "
2026-04-15 17:02:00 -07:00
)
2026-06-10 16:49:03 -07:00
auto_reload_threshold_micros = Column ( BigInteger , nullable = True )
auto_reload_amount_micros = Column ( BigInteger , nullable = True )
auto_reload_payment_method_id = Column ( String , nullable = True )
auto_reload_failed_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
2026-04-15 17:02:00 -07:00
2026-01-14 14:37:16 +02:00
# User profile (can be set manually for non-OAuth users)
display_name = Column ( String , nullable = True )
avatar_url = Column ( String , nullable = True )
2026-03-08 18:24:29 +05:30
last_login = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
2026-04-08 23:21:24 +05:30
memory_md = Column ( Text , nullable = True , server_default = " " )
2026-02-05 16:18:45 +02:00
# Refresh tokens for this user
refresh_tokens = relationship (
" RefreshToken " ,
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2026-06-19 20:26:28 +05:30
personal_access_tokens = relationship (
" PersonalAccessToken " ,
back_populates = " user " ,
cascade = " all, delete-orphan " ,
)
2026-02-05 16:18:45 +02:00
2026-04-28 09:22:19 -07:00
class AgentActionLog ( BaseModel ) :
""" Append-only audit trail of every tool call dispatched by the agent.
One row per ` ` ToolMessage ` ` produced ; written by ` ` ActionLogMiddleware ` `
in its ` ` aafter_tool ` ` hook . Rows are referenced by the
` ` / api / threads / { thread_id } / revert / { action_id } ` ` route to look up an
action ' s stored ``reverse_descriptor`` and replay it.
The table is intentionally narrow : large tool outputs are NOT stored
here . Result text lives in the langgraph checkpoint ; this row only
keeps a short ` ` result_id ` ` ( the LangChain ` ` ToolMessage . id ` ` or a
spilled - content path ) for correlation .
"""
__tablename__ = " agent_action_log "
thread_id = Column (
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
2026-06-26 18:18:50 +02:00
workspace_id = Column (
2026-04-28 09:22:19 -07:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
2026-04-28 09:22:19 -07:00
nullable = False ,
index = True ,
)
2026-04-29 07:20:31 -07:00
# ``turn_id`` historically held the LangChain ``tool_call.id``. It has
# been renamed to ``tool_call_id`` (with a parallel column kept for one
# release for back-compat). The real chat-turn id lives in
# ``chat_turn_id`` and is sourced from ``configurable.turn_id``.
2026-04-28 09:22:19 -07:00
turn_id = Column ( String ( 64 ) , nullable = True , index = True )
2026-04-29 07:20:31 -07:00
tool_call_id = Column ( String ( 64 ) , nullable = True , index = True )
chat_turn_id = Column ( String ( 64 ) , nullable = True , index = True )
2026-04-28 09:22:19 -07:00
message_id = Column ( String ( 128 ) , nullable = True , index = True )
tool_name = Column ( String ( 255 ) , nullable = False , index = True )
args = Column ( JSONB , nullable = True )
result_id = Column ( String ( 255 ) , nullable = True )
reversible = Column (
Boolean , nullable = False , default = False , server_default = text ( " false " )
)
reverse_descriptor = Column ( JSONB , nullable = True )
error = Column ( JSONB , nullable = True )
reverse_of = Column (
Integer ,
ForeignKey ( " agent_action_log.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
created_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
server_default = text ( " (now() AT TIME ZONE ' utc ' ) " ) ,
index = True ,
)
__table_args__ = (
Index ( " ix_agent_action_log_thread_created " , " thread_id " , " created_at " ) ,
2026-04-29 07:20:31 -07:00
# Partial unique index enforces "at most one revert per
# original action". Created in migration 137 with
# ``WHERE reverse_of IS NOT NULL`` so non-revert rows
# (the vast majority) are unaffected and NULLs don't collide.
Index (
" ux_agent_action_log_reverse_of " ,
" reverse_of " ,
unique = True ,
postgresql_where = text ( " reverse_of IS NOT NULL " ) ,
) ,
2026-04-28 09:22:19 -07:00
)
class DocumentRevision ( BaseModel ) :
""" Snapshot of a :class:`Document` row taken before a mutating tool call.
Written by : class : ` KnowledgeBasePersistenceMiddleware ` ( or its safety - net
` commit_staged_filesystem_state ` ) ahead of any NOTE / FILE / EXTENSION
document write . The row is referenced by ` ` / revert / { action_id } ` ` to
restore the original content in place .
"""
__tablename__ = " document_revisions "
2026-04-29 07:20:31 -07:00
# ``ON DELETE SET NULL`` (not CASCADE) so the snapshot survives the
# hard-delete it describes — without that, ``rm`` would wipe the row
# we'd need to undo it. See migration ``134_relax_revision_fks``.
2026-04-28 09:22:19 -07:00
document_id = Column (
Integer ,
2026-04-29 07:20:31 -07:00
ForeignKey ( " documents.id " , ondelete = " SET NULL " ) ,
nullable = True ,
2026-04-28 09:22:19 -07:00
index = True ,
)
2026-06-26 18:18:50 +02:00
workspace_id = Column (
2026-04-28 09:22:19 -07:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
2026-04-28 09:22:19 -07:00
nullable = False ,
index = True ,
)
content_before = Column ( Text , nullable = True )
title_before = Column ( String , nullable = True )
folder_id_before = Column ( Integer , nullable = True )
chunks_before = Column ( JSONB , nullable = True )
metadata_before = Column ( " metadata_before " , JSONB , nullable = True )
created_by_turn_id = Column ( String ( 64 ) , nullable = True , index = True )
agent_action_id = Column (
Integer ,
ForeignKey ( " agent_action_log.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
created_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
server_default = text ( " (now() AT TIME ZONE ' utc ' ) " ) ,
index = True ,
)
class FolderRevision ( BaseModel ) :
""" Snapshot of a :class:`Folder` row taken before a mkdir / move. """
__tablename__ = " folder_revisions "
2026-04-29 07:20:31 -07:00
# ``ON DELETE SET NULL`` (not CASCADE) so the snapshot survives the
# hard-delete it describes — without that, ``rmdir`` would wipe the
# row we'd need to undo it. See migration ``134_relax_revision_fks``.
2026-04-28 09:22:19 -07:00
folder_id = Column (
Integer ,
2026-04-29 07:20:31 -07:00
ForeignKey ( " folders.id " , ondelete = " SET NULL " ) ,
nullable = True ,
2026-04-28 09:22:19 -07:00
index = True ,
)
2026-06-26 18:18:50 +02:00
workspace_id = Column (
2026-04-28 09:22:19 -07:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
2026-04-28 09:22:19 -07:00
nullable = False ,
index = True ,
)
name_before = Column ( String ( 255 ) , nullable = True )
parent_id_before = Column ( Integer , nullable = True )
position_before = Column ( String ( 50 ) , nullable = True )
created_by_turn_id = Column ( String ( 64 ) , nullable = True , index = True )
agent_action_id = Column (
Integer ,
ForeignKey ( " agent_action_log.id " , ondelete = " SET NULL " ) ,
nullable = True ,
index = True ,
)
created_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
server_default = text ( " (now() AT TIME ZONE ' utc ' ) " ) ,
index = True ,
)
class AgentPermissionRule ( BaseModel ) :
""" Persistent permission rule consumed by :class:`PermissionMiddleware`.
2026-06-26 18:18:50 +02:00
Scoped at one of : workspace - wide ( ` ` user_id ` ` and ` ` thread_id ` ` NULL ) ,
2026-04-28 09:22:19 -07:00
user - wide ( ` ` user_id ` ` set , ` ` thread_id ` ` NULL ) , or per - thread
( ` ` thread_id ` ` set ) . Loaded at agent build time and converted to
: class : ` Rule ` instances inside the agent factory .
"""
__tablename__ = " agent_permission_rules "
2026-06-26 18:18:50 +02:00
workspace_id = Column (
2026-04-28 09:22:19 -07:00
Integer ,
2026-06-26 13:27:16 +02:00
ForeignKey ( " workspaces.id " , ondelete = " CASCADE " ) ,
2026-04-28 09:22:19 -07:00
nullable = False ,
index = True ,
)
user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " CASCADE " ) ,
nullable = True ,
index = True ,
)
thread_id = Column (
Integer ,
ForeignKey ( " new_chat_threads.id " , ondelete = " CASCADE " ) ,
nullable = True ,
index = True ,
)
permission = Column ( String ( 255 ) , nullable = False )
pattern = Column ( String ( 255 ) , nullable = False , default = " * " , server_default = " * " )
action = Column ( String ( 16 ) , nullable = False ) # allow / deny / ask
created_at = Column (
TIMESTAMP ( timezone = True ) ,
nullable = False ,
default = lambda : datetime . now ( UTC ) ,
server_default = text ( " (now() AT TIME ZONE ' utc ' ) " ) ,
index = True ,
)
__table_args__ = (
UniqueConstraint (
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
" workspace_id " ,
2026-04-28 09:22:19 -07:00
" user_id " ,
" thread_id " ,
" permission " ,
" pattern " ,
" action " ,
name = " uq_agent_permission_rules_scope " ,
) ,
)
2026-02-05 16:18:45 +02:00
class RefreshToken ( Base , TimestampMixin ) :
"""
Stores refresh tokens for user session management .
2026-02-05 16:49:37 +02:00
Each row represents one device / session .
2026-02-05 16:18:45 +02:00
"""
__tablename__ = " refresh_tokens "
id = Column ( Integer , primary_key = True , autoincrement = True )
user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
user = relationship ( " User " , back_populates = " refresh_tokens " )
2026-06-23 12:49:04 +05:30
token_hash = Column ( String ( 64 ) , unique = True , nullable = False , index = True )
2026-02-05 16:18:45 +02:00
expires_at = Column ( TIMESTAMP ( timezone = True ) , nullable = False , index = True )
2026-06-23 12:49:04 +05:30
revoked_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
absolute_expiry = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
2026-02-05 16:18:45 +02:00
family_id = Column ( UUID ( as_uuid = True ) , nullable = False , index = True )
@property
def is_expired ( self ) - > bool :
return datetime . now ( UTC ) > = self . expires_at
@property
def is_valid ( self ) - > bool :
2026-06-23 12:49:04 +05:30
return not self . is_expired and self . revoked_at is None
2026-02-05 16:18:45 +02:00
2025-03-14 18:53:14 -07:00
2026-06-19 20:26:28 +05:30
class PersonalAccessToken ( BaseModel , TimestampMixin ) :
"""
Stores hashed Personal Access Tokens for programmatic API access .
Plaintext tokens are shown once on creation and are never persisted .
"""
__tablename__ = " personal_access_tokens "
user_id = Column (
UUID ( as_uuid = True ) ,
ForeignKey ( " user.id " , ondelete = " CASCADE " ) ,
nullable = False ,
index = True ,
)
user = relationship ( " User " , back_populates = " personal_access_tokens " )
token_hash = Column ( String ( 64 ) , unique = True , nullable = False , index = True )
token_prefix = Column ( String ( 16 ) , nullable = False )
label = Column ( String , nullable = False )
expires_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True , index = True )
last_used_at = Column ( TIMESTAMP ( timezone = True ) , nullable = True )
@property
def is_expired ( self ) - > bool :
return self . expires_at is not None and datetime . now ( UTC ) > = self . expires_at
@property
def is_valid ( self ) - > bool :
return not self . is_expired
2026-05-27 13:45:32 +02:00
# Register model packages that live outside this file so their classes
# are present in Base.metadata before configure_mappers() resolves any
# string-based relationship() references.
from app . automations . persistence import ( # noqa: E402, F401
Automation ,
AutomationRun ,
AutomationTrigger ,
)
2026-06-12 11:23:50 +02:00
from app . etl_pipeline . cache . persistence . models import CachedParse # noqa: E402, F401
2026-06-02 16:10:44 +02:00
from app . file_storage . persistence import DocumentFile # noqa: E402, F401
2026-06-12 16:48:01 +02:00
from app . indexing_pipeline . cache . persistence . models import ( # noqa: E402, F401
CachedEmbeddingSet ,
)
2026-06-03 18:04:47 +02:00
from app . notifications . persistence import Notification # noqa: E402, F401
2026-06-10 18:44:12 +02:00
from app . podcasts . persistence import ( # noqa: E402, F401
Podcast ,
PodcastStatus ,
)
2026-05-27 13:45:32 +02:00
2026-06-16 16:18:49 -07:00
def _build_connect_args ( ) - > dict :
""" Build driver connect_args, including a protective idle-in-transaction
timeout for asyncpg connections .
A single abandoned ` ` idle in transaction ` ` session can hold table / row locks
indefinitely and wedge writes plus boot - time DDL ( the classic " FastAPI
stuck at application startup " failure). Setting
` ` idle_in_transaction_session_timeout ` ` server - side makes Postgres reap such
sessions automatically . It never affects sessions that are actively running
statements — only ones that opened a transaction and went idle .
"""
connect_args : dict = { }
idle_ms = config . DB_IDLE_IN_TX_TIMEOUT_MS
# ``server_settings`` is asyncpg-specific; only apply it for that driver.
if idle_ms and idle_ms > 0 and DATABASE_URL and " asyncpg " in DATABASE_URL :
connect_args [ " server_settings " ] = {
" idle_in_transaction_session_timeout " : str ( idle_ms )
}
return connect_args
2026-02-28 17:22:34 -08:00
engine = create_async_engine (
DATABASE_URL ,
pool_size = 30 ,
max_overflow = 150 ,
pool_recycle = 1800 ,
pool_pre_ping = True ,
pool_timeout = 30 ,
2026-06-16 16:18:49 -07:00
connect_args = _build_connect_args ( ) ,
2026-02-28 17:22:34 -08:00
)
2025-03-14 18:53:14 -07:00
async_session_maker = async_sessionmaker ( engine , expire_on_commit = False )
2025-07-24 14:43:48 -07:00
2025-03-14 18:53:14 -07:00
2026-02-28 23:59:28 -08:00
@asynccontextmanager
async def shielded_async_session ( ) :
""" Cancellation-safe async session context manager.
Starlette ' s BaseHTTPMiddleware cancels the task via an anyio cancel
scope when a client disconnects . A plain ` ` async with async_session_maker ( ) ` `
has its ` ` __aexit__ ` ` ( which awaits ` ` session . close ( ) ` ` ) cancelled by the
scope , orphaning the underlying database connection .
This wrapper ensures ` ` session . close ( ) ` ` always completes by running it
inside ` ` anyio . CancelScope ( shield = True ) ` ` .
"""
session = async_session_maker ( )
try :
yield session
finally :
with anyio . CancelScope ( shield = True ) :
await session . close ( )
2026-06-16 16:18:49 -07:00
# (index_name, table, CREATE statement). Built with CONCURRENTLY so an index
# build only takes a non-blocking ShareUpdateExclusiveLock — ingestion
# INSERT/UPDATE on documents/chunks keep flowing while the index builds, and a
# slow build can never freeze the FastAPI lifespan or block writers.
_INDEX_DEFINITIONS : list [ tuple [ str , str , str ] ] = [
(
" document_vector_index " ,
" documents " ,
" CREATE INDEX CONCURRENTLY IF NOT EXISTS document_vector_index ON documents USING hnsw (embedding public.vector_cosine_ops) " ,
) ,
(
" document_search_index " ,
" documents " ,
" CREATE INDEX CONCURRENTLY IF NOT EXISTS document_search_index ON documents USING gin (to_tsvector( ' english ' , content)) " ,
) ,
(
" chucks_vector_index " ,
" chunks " ,
" CREATE INDEX CONCURRENTLY IF NOT EXISTS chucks_vector_index ON chunks USING hnsw (embedding public.vector_cosine_ops) " ,
) ,
(
" chucks_search_index " ,
" chunks " ,
" CREATE INDEX CONCURRENTLY IF NOT EXISTS chucks_search_index ON chunks USING gin (to_tsvector( ' english ' , content)) " ,
) ,
# pg_trgm index for efficient ILIKE '%term%' searches on titles — critical
# for the document mention picker (@mentions) to scale.
(
" idx_documents_title_trgm " ,
" documents " ,
" CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_documents_title_trgm ON documents USING gin (title gin_trgm_ops) " ,
) ,
(
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
" idx_documents_workspace_id " ,
2026-06-16 16:18:49 -07:00
" documents " ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
" CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_documents_workspace_id ON documents (workspace_id) " ,
2026-06-16 16:18:49 -07:00
) ,
# Covering index for "recent documents" query — enables index-only scan.
(
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
" idx_documents_workspace_updated " ,
2026-06-16 16:18:49 -07:00
" documents " ,
refactor(db): map search_space_id ORM attrs to physical workspace_id column
Phase 1 (rename DB) commit 1a: shim every search_space_id /
owner_search_space_id column to the renamed physical column
(workspace_id / owner_workspace_id) via Column("workspace_id", ...),
keeping the ORM attribute name unchanged so existing callers are
untouched. Flip all Table-level references that resolve by column key
(inner __table_args__ strings, the ck_connections_scope_owner CHECK
text, and the _INDEX_DEFINITIONS runtime DDL) plus the searchspace-named
constraint/index names to workspace_id. Update the three raw-SQL test
fixtures that referenced the physical column.
Note: SQLAlchemy defaults a column's key to its name, so passing an
explicit "workspace_id" name moves the Table.c key to workspace_id;
Table-level string refs must therefore change in this phase (the
opposite of the original plan's finding 1). The ORM attribute, the
SearchSpace class, relationships, table names, and FK target strings are
intentionally left for later commits/Phase 2.
Verified: unit 2375 passed/1 skip, integration 346 passed (baseline
parity); create_all builds every table with workspace_id.
2026-06-26 12:05:52 +02:00
" CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_documents_workspace_updated ON documents (workspace_id, updated_at DESC NULLS LAST) INCLUDE (id, title, document_type) " ,
2026-06-16 16:18:49 -07:00
) ,
]
async def _drop_invalid_index ( conn , name : str ) - > None :
""" Drop a leftover *invalid* index so it can be rebuilt.
A ` ` CREATE INDEX CONCURRENTLY ` ` that is interrupted ( timeout , crash ,
cancellation ) leaves behind an ` ` indisvalid = false ` ` index . Because the
name now exists , a later ` ` CREATE INDEX CONCURRENTLY IF NOT EXISTS ` ` would
skip it and the broken index would persist forever . Detect and drop it
first .
"""
result = await conn . execute (
text ( " SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass(:n) " ) ,
{ " n " : name } ,
)
row = result . first ( )
if row is not None and row [ 0 ] is False :
logger . warning (
" [startup] dropping invalid leftover index %s before rebuild " , name
2026-01-17 20:45:10 +05:30
)
2026-06-16 16:18:49 -07:00
await conn . execute ( text ( f ' DROP INDEX CONCURRENTLY IF EXISTS " { name } " ' ) )
async def setup_indexes ( ) - > None :
""" Ensure search/vector indexes exist without ever blocking startup.
Each index is created with ` ` CONCURRENTLY ` ` ( so it never takes a blocking
SHARE lock on documents / chunks ) under a short per - session ` ` lock_timeout ` `
( so a contended boot fails fast instead of hanging the lifespan forever ) .
Failures are logged and swallowed per - index — a missing index just gets
retried on the next boot rather than crash - looping the API .
"""
lock_timeout_ms = int ( config . DB_DDL_LOCK_TIMEOUT_MS )
# AUTOCOMMIT is mandatory: CREATE INDEX CONCURRENTLY cannot run inside a
# transaction block.
async with engine . connect ( ) as base_conn :
conn = await base_conn . execution_options ( isolation_level = " AUTOCOMMIT " )
await conn . execute ( text ( f " SET lock_timeout = { lock_timeout_ms } " ) )
for name , table , ddl in _INDEX_DEFINITIONS :
try :
await _drop_invalid_index ( conn , name )
await conn . execute ( text ( ddl ) )
except Exception as exc :
# Non-fatal by design: a missing index is retried next boot.
logger . warning (
" [startup] index %s on %s not ready ( %s : %s ); "
" will retry on next boot " ,
name ,
table ,
exc . __class__ . __name__ ,
exc ,
)
2025-07-24 14:43:48 -07:00
2025-03-14 18:53:14 -07:00
async def create_db_and_tables ( ) :
2026-06-16 16:18:49 -07:00
if not config . DB_BOOTSTRAP_ON_STARTUP :
logger . info (
" [startup] DB bootstrap skipped (DB_BOOTSTRAP_ON_STARTUP=FALSE); "
" schema/indexes are expected to be managed by migrations "
)
return
lock_timeout_ms = int ( config . DB_DDL_LOCK_TIMEOUT_MS )
2025-03-14 18:53:14 -07:00
async with engine . begin ( ) as conn :
2026-06-16 16:18:49 -07:00
# Fail fast instead of hanging forever if another session holds a
# conflicting lock on a table we need to touch.
await conn . execute ( text ( f " SET LOCAL lock_timeout = { lock_timeout_ms } " ) )
2025-07-24 14:43:48 -07:00
await conn . execute ( text ( " CREATE EXTENSION IF NOT EXISTS vector " ) )
2026-01-17 20:45:10 +05:30
await conn . execute ( text ( " CREATE EXTENSION IF NOT EXISTS pg_trgm " ) )
2025-03-14 18:53:14 -07:00
await conn . run_sync ( Base . metadata . create_all )
await setup_indexes ( )
async def get_async_session ( ) - > AsyncGenerator [ AsyncSession , None ] :
async with async_session_maker ( ) as session :
yield session
2025-05-21 20:56:23 -07:00
if config . AUTH_TYPE == " GOOGLE " :
2025-07-24 14:43:48 -07:00
2025-05-21 20:56:23 -07:00
async def get_user_db ( session : AsyncSession = Depends ( get_async_session ) ) :
yield SQLAlchemyUserDatabase ( session , User , OAuthAccount )
2025-07-24 11:33:38 +02:00
2025-05-21 20:56:23 -07:00
else :
2025-07-24 14:43:48 -07:00
2025-05-21 20:56:23 -07:00
async def get_user_db ( session : AsyncSession = Depends ( get_async_session ) ) :
yield SQLAlchemyUserDatabase ( session , User )
2025-07-24 14:43:48 -07:00
2025-11-27 22:45:04 -08:00
def has_permission ( user_permissions : list [ str ] , required_permission : str ) - > bool :
"""
Check if the user has the required permission .
Supports wildcard ( * ) for full access .
Args :
user_permissions : List of permission strings the user has
required_permission : The permission string to check for
Returns :
True if user has the permission , False otherwise
"""
if not user_permissions :
return False
# Full access wildcard grants all permissions
if Permission . FULL_ACCESS . value in user_permissions :
return True
return required_permission in user_permissions
def has_any_permission (
user_permissions : list [ str ] , required_permissions : list [ str ]
) - > bool :
"""
Check if the user has any of the required permissions .
Args :
user_permissions : List of permission strings the user has
required_permissions : List of permission strings to check for ( any match )
Returns :
True if user has at least one of the permissions , False otherwise
"""
if not user_permissions :
return False
if Permission . FULL_ACCESS . value in user_permissions :
return True
return any ( perm in user_permissions for perm in required_permissions )
def has_all_permissions (
user_permissions : list [ str ] , required_permissions : list [ str ]
) - > bool :
"""
Check if the user has all of the required permissions .
Args :
user_permissions : List of permission strings the user has
required_permissions : List of permission strings to check for ( all must match )
Returns :
True if user has all of the permissions , False otherwise
"""
if not user_permissions :
return False
if Permission . FULL_ACCESS . value in user_permissions :
return True
return all ( perm in user_permissions for perm in required_permissions )
def get_default_roles_config ( ) - > list [ dict ] :
"""
Get the configuration for default system roles .
2026-06-26 18:18:50 +02:00
These roles are created automatically when a workspace is created .
2025-11-27 22:45:04 -08:00
2026-01-20 02:59:32 -08:00
Only 3 roles are supported :
2026-06-26 18:18:50 +02:00
- Owner : Full access to everything ( assigned to workspace creator )
2026-01-20 02:59:32 -08:00
- Editor : Can create / update content but cannot delete , manage roles , or change settings
- Viewer : Read - only access to resources ( can add comments )
2025-11-27 22:45:04 -08:00
Returns :
List of role configurations with name , description , permissions , and flags
"""
return [
{
" name " : " Owner " ,
2026-06-26 18:18:50 +02:00
" description " : " Full access to all workspace resources and settings " ,
2025-11-27 22:45:04 -08:00
" permissions " : DEFAULT_ROLE_PERMISSIONS [ " Owner " ] ,
" is_default " : False ,
" is_system_role " : True ,
} ,
{
" name " : " Editor " ,
2026-01-20 02:59:32 -08:00
" description " : " Can create and update content (no delete, role management, or settings access) " ,
2025-11-27 22:45:04 -08:00
" permissions " : DEFAULT_ROLE_PERMISSIONS [ " Editor " ] ,
" is_default " : True , # Default role for new members via invite
" is_system_role " : True ,
} ,
{
" name " : " Viewer " ,
2026-06-26 18:18:50 +02:00
" description " : " Read-only access to workspace resources " ,
2025-11-27 22:45:04 -08:00
" permissions " : DEFAULT_ROLE_PERMISSIONS [ " Viewer " ] ,
" is_default " : False ,
" is_system_role " : True ,
} ,
]