- 0007_cloud_analysis_cache.py: new Alembic migration adding 4 Phase 14 tables
- cloud_byte_cache_entries: durable MinIO cache with version_key uniqueness,
pin_count/active_job_count guards, LRU eviction indexes
- cloud_analysis_jobs: batch job rows (scope_kind, status, aggregate counters,
provider_bytes_estimate, failure_behavior)
- cloud_analysis_job_items: per-item idempotency rows (status vocabulary,
controlled error fields, cache_entry_id, retry_count, version_key fingerprint)
- user_analysis_settings: per-user preferences (progress_detail, failure_behavior,
cloud_cache_limit_bytes)
- Reversible downgrade drops Phase 14 objects only
- db/models.py: CloudByteCacheEntry, CloudAnalysisJob, CloudAnalysisJobItem,
UserAnalysisSettings ORM models mirroring migration
760 lines
30 KiB
Python
760 lines
30 KiB
Python
"""
|
|
Full v1 SQLAlchemy 2.0 ORM schema for DocuVault.
|
|
|
|
All 11 tables declared here: users, quotas, refresh_tokens, folders, documents,
|
|
topics, document_topics, shares, audit_log, cloud_connections, groups.
|
|
|
|
Key decisions:
|
|
D-01: Full v1 skeleton in Phase 1 migration
|
|
D-02: groups table stub (v2 feature, seeded for schema completeness per PROJECT.md)
|
|
D-03: documents.user_id is nullable in Phase 1 (no auth yet); Phase 2 adds NOT NULL
|
|
|
|
AuditLog note: The metadata column is declared as `metadata_` (ORM attribute name)
|
|
with `name="metadata"` (DB column name). This is required because `metadata` is a
|
|
reserved attribute on SQLAlchemy's DeclarativeBase and would cause silent conflicts
|
|
if used as an attribute name directly.
|
|
|
|
Python compat note: `Optional[X]` is used instead of `X | None` union syntax
|
|
because the host environment may be Python < 3.10. Both are equivalent.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
BigInteger,
|
|
ForeignKey,
|
|
Index,
|
|
String,
|
|
Text,
|
|
TIMESTAMP,
|
|
UniqueConstraint,
|
|
Integer,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import UUID, INET, JSONB
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
from sqlalchemy.sql import func
|
|
|
|
|
|
class Base(DeclarativeBase):
|
|
pass
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
handle: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
|
totp_secret: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
role: Mapped[str] = mapped_column(String, nullable=False, default="user")
|
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
password_must_change: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
|
ai_provider: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
ai_model: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
default_storage_backend: Mapped[str] = mapped_column(
|
|
String, nullable=False, default="minio"
|
|
)
|
|
pdf_open_mode: Mapped[str] = mapped_column(
|
|
String, nullable=False, server_default="in_app"
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
|
|
class Quota(Base):
|
|
__tablename__ = "quotas"
|
|
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
# 100 MB default free-tier quota (STORE-01); admin can override limit_bytes per user
|
|
limit_bytes: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=104857600
|
|
)
|
|
used_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
|
|
|
|
class RefreshToken(Base):
|
|
__tablename__ = "refresh_tokens"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
expires_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False
|
|
)
|
|
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (Index("ix_refresh_tokens_user_revoked", "user_id", "revoked"),)
|
|
|
|
|
|
class BackupCode(Base):
|
|
"""Single-use backup codes for TOTP recovery (AUTH-02).
|
|
|
|
code_hash stores the Argon2 hash of the original code (never plaintext).
|
|
used_at is None when the code is unused; set to now() on first use.
|
|
Verification iterates ALL codes to prevent timing-based enumeration (SEC-06).
|
|
"""
|
|
|
|
__tablename__ = "backup_codes"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
code_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
|
used_at: Mapped[Optional[datetime]] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=True
|
|
)
|
|
|
|
__table_args__ = (Index("ix_backup_codes_user_id", "user_id"),)
|
|
|
|
|
|
class Folder(Base):
|
|
__tablename__ = "folders"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("folders.id", ondelete="CASCADE"),
|
|
nullable=True,
|
|
)
|
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("user_id", "parent_id", "name", name="uq_folders_user_parent_name"),
|
|
)
|
|
|
|
|
|
class Document(Base):
|
|
__tablename__ = "documents"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
# D-03: user_id is NULLABLE in Phase 1 — no auth system yet.
|
|
# Phase 2 migration adds NOT NULL constraint after users/auth are live.
|
|
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=True,
|
|
)
|
|
folder_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("folders.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
# original human-readable filename — stored in DB only, never in the MinIO key
|
|
filename: Mapped[str] = mapped_column(Text, nullable=False)
|
|
# MinIO object key: {user_id}/{document_id}/{uuid4()}{ext}
|
|
object_key: Mapped[str] = mapped_column(Text, nullable=False)
|
|
content_type: Mapped[str] = mapped_column(Text, nullable=False)
|
|
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
storage_backend: Mapped[str] = mapped_column(String, nullable=False, default="minio")
|
|
extracted_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_documents_user_folder", "user_id", "folder_id"),
|
|
Index("ix_documents_user_created", "user_id", "created_at"),
|
|
)
|
|
|
|
|
|
class Topic(Base):
|
|
__tablename__ = "topics"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=True,
|
|
)
|
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
|
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6366f1")
|
|
|
|
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_topics_user_name"),)
|
|
|
|
|
|
class DocumentTopic(Base):
|
|
__tablename__ = "document_topics"
|
|
|
|
document_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("documents.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
topic_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("topics.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
|
|
|
|
class Share(Base):
|
|
__tablename__ = "shares"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
document_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("documents.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
recipient_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
permission: Mapped[str] = mapped_column(String, nullable=False, default="view")
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("document_id", "recipient_id", name="uq_shares_document_recipient"),
|
|
Index("ix_shares_recipient", "recipient_id"),
|
|
)
|
|
|
|
|
|
class AuditLog(Base):
|
|
__tablename__ = "audit_log"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
event_type: Mapped[str] = mapped_column(Text, nullable=False)
|
|
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True), nullable=True
|
|
)
|
|
ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True)
|
|
# ORM attribute is `metadata_` to avoid collision with DeclarativeBase.metadata.
|
|
# The DB column is named "metadata" via the mapped_column name= kwarg.
|
|
metadata_: Mapped[Optional[dict]] = mapped_column("metadata", JSONB, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_audit_user_created", "user_id", "created_at"),
|
|
Index("ix_audit_event_created", "event_type", "created_at"),
|
|
)
|
|
|
|
|
|
class CloudConnection(Base):
|
|
__tablename__ = "cloud_connections"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
provider: Mapped[str] = mapped_column(String, nullable=False)
|
|
display_name: Mapped[str] = mapped_column(Text, nullable=False)
|
|
display_name_override: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
credentials_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
|
status: Mapped[str] = mapped_column(String, nullable=False, default="ACTIVE")
|
|
connected_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (Index("ix_cloud_connections_user", "user_id"),)
|
|
|
|
|
|
class CloudItem(Base):
|
|
"""Durable per-item cloud metadata indexed by (connection_id, provider_item_id).
|
|
|
|
Phase 12: metadata browsing only — no MinIO object_key field.
|
|
Phase 14: extracted_text, analysis_status, semantic_index_status, semantic_index_data
|
|
are reserved for byte analysis and semantic search.
|
|
|
|
D-18: provider_size never flows to quotas.used_bytes.
|
|
"""
|
|
|
|
__tablename__ = "cloud_items"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
connection_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("cloud_connections.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
provider_item_id: Mapped[str] = mapped_column(Text, nullable=False)
|
|
parent_ref: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
path_snapshot: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
|
kind: Mapped[str] = mapped_column(String(8), nullable=False) # "file" | "folder"
|
|
content_type: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
# Provider-reported size — never used to update quotas.used_bytes (D-18)
|
|
provider_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
|
etag: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
version: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
modified_at: Mapped[Optional[datetime]] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=True
|
|
)
|
|
last_seen_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
deleted_at: Mapped[Optional[datetime]] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=True
|
|
)
|
|
# Phase 14 reserved fields
|
|
extracted_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
analysis_status: Mapped[str] = mapped_column(
|
|
String(16), nullable=False, default="pending"
|
|
)
|
|
semantic_index_status: Mapped[str] = mapped_column(
|
|
String(16), nullable=False, default="none"
|
|
)
|
|
semantic_index_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"connection_id",
|
|
"provider_item_id",
|
|
name="uq_cloud_items_connection_provider_item",
|
|
),
|
|
Index("ix_cloud_items_user_id", "user_id"),
|
|
Index("ix_cloud_items_connection_id", "connection_id"),
|
|
Index("ix_cloud_items_connection_parent", "connection_id", "parent_ref"),
|
|
)
|
|
|
|
|
|
class CloudItemTopic(Base):
|
|
"""Association between CloudItem and Topic — no Document row required."""
|
|
|
|
__tablename__ = "cloud_item_topics"
|
|
|
|
cloud_item_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("cloud_items.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
topic_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("topics.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
|
|
|
|
class CloudFolderState(Base):
|
|
"""Per-connection folder freshness row for cached-first navigation.
|
|
|
|
parent_ref = '' represents the connection root (allows unique constraint).
|
|
refresh_state: "refreshing" | "fresh" | "warning"
|
|
"""
|
|
|
|
__tablename__ = "cloud_folder_states"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
connection_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("cloud_connections.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
# '' for root, provider ref for non-root
|
|
parent_ref: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
refresh_state: Mapped[str] = mapped_column(
|
|
String(16), nullable=False, default="fresh"
|
|
)
|
|
last_refreshed_at: Mapped[Optional[datetime]] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=True
|
|
)
|
|
error_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
|
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"connection_id",
|
|
"parent_ref",
|
|
name="uq_cloud_folder_states_connection_parent",
|
|
),
|
|
Index("ix_cloud_folder_states_connection", "connection_id"),
|
|
)
|
|
|
|
|
|
class CloudByteCacheEntry(Base):
|
|
"""Durable per-item provider byte cache entry backed by MinIO.
|
|
|
|
Tracks retained cloud bytes for active open, preview, or analysis work.
|
|
size_bytes counts against quota.used_bytes while retained; decremented on eviction.
|
|
|
|
Eviction rules (D-14, D-16):
|
|
- Only entries with pin_count == 0 and active_job_count == 0 may be evicted.
|
|
- Eviction is per-user LRU ordered by last_accessed_at ascending.
|
|
- object_key is a UUID-based private MinIO key — never exposed in API responses (T-14-02).
|
|
|
|
Phase 14: cache lifecycle (create -> pin -> release -> evict) managed by
|
|
services/cloud_cache.py.
|
|
"""
|
|
|
|
__tablename__ = "cloud_byte_cache_entries"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
connection_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("cloud_connections.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
cloud_item_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("cloud_items.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
# Snapshot of provider_item_id — for audit/debug without joins
|
|
provider_item_id: Mapped[str] = mapped_column(Text, nullable=False)
|
|
# Derived from version / etag / metadata fingerprint (content hash optional, post-hydration only)
|
|
version_key: Mapped[str] = mapped_column(Text, nullable=False)
|
|
# Private MinIO object key — UUID-based, never exposed in API responses
|
|
object_key: Mapped[str] = mapped_column(Text, nullable=False)
|
|
content_type: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
# Actual byte count stored in MinIO — debited from quota.used_bytes
|
|
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
# Optional content hash — computed ONLY after bytes already hydrated, never as a pre-download requirement
|
|
content_hash: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
# pin_count > 0 = active preview/download lease; must not be evicted
|
|
pin_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
# active_job_count > 0 = Celery analysis task in flight; must not be evicted
|
|
active_job_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
last_accessed_at: Mapped[Optional[datetime]] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=True
|
|
)
|
|
evicted_at: Mapped[Optional[datetime]] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=True
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"user_id",
|
|
"connection_id",
|
|
"cloud_item_id",
|
|
"version_key",
|
|
name="uq_cache_entry_user_connection_item_version",
|
|
),
|
|
Index("ix_cache_entries_user_evicted_accessed", "user_id", "evicted_at", "last_accessed_at"),
|
|
Index("ix_cache_entries_user_active", "user_id", "pin_count", "active_job_count", "last_accessed_at"),
|
|
)
|
|
|
|
|
|
class CloudAnalysisJob(Base):
|
|
"""User-visible analysis batch row.
|
|
|
|
Represents a single enqueue action for file, selection, folder, or connection scope.
|
|
Aggregate counters are updated by Celery workers as items are processed.
|
|
|
|
status: "queued" | "running" | "paused" | "completed" | "cancelled" | "failed"
|
|
scope_kind: "file" | "selection" | "folder" | "connection"
|
|
failure_behavior: "pause_batch" (default) | "continue_item" (D-11)
|
|
"""
|
|
|
|
__tablename__ = "cloud_analysis_jobs"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
connection_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("cloud_connections.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
scope_kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
scope_ref: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
recursive: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="queued")
|
|
# Aggregate item counters
|
|
total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
queued_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
downloading_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
extracting_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
classifying_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
indexed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
already_current_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
skipped_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
cancelled_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
failed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
unsupported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
# Provider-reported byte total — for estimates only, never used for quota
|
|
provider_bytes_estimate: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
failure_behavior: Mapped[str] = mapped_column(String(16), nullable=False, default="pause_batch")
|
|
started_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True), nullable=True)
|
|
finished_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True), nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_analysis_jobs_user_id", "user_id"),
|
|
Index("ix_analysis_jobs_user_status", "user_id", "status"),
|
|
)
|
|
|
|
|
|
class CloudAnalysisJobItem(Base):
|
|
"""Per-item row for idempotent processing within a CloudAnalysisJob.
|
|
|
|
status vocabulary (PATTERNS.md):
|
|
queued | downloading | extracting | classifying | indexed |
|
|
already_current | cancelled | failed | unsupported | stale
|
|
|
|
error_code and error_message contain controlled strings only — never raw
|
|
provider exception text or stack traces.
|
|
|
|
cache_entry_id is null until bytes are hydrated. It links to the
|
|
CloudByteCacheEntry used during processing and is cleared when the cache
|
|
entry is evicted.
|
|
"""
|
|
|
|
__tablename__ = "cloud_analysis_job_items"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
job_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("cloud_analysis_jobs.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
connection_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("cloud_connections.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
cloud_item_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("cloud_items.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
provider_item_id: Mapped[str] = mapped_column(Text, nullable=False)
|
|
# version_key at enqueue time — re-resolved and compared during processing
|
|
version_key: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
# Metadata fingerprint: "provider_item_id:size:modified_at:content_type"
|
|
fingerprint: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="queued")
|
|
# Controlled error strings — never raw provider error text
|
|
error_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
|
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
cache_entry_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("cloud_byte_cache_entries.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
started_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True), nullable=True)
|
|
finished_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True), nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("job_id", "cloud_item_id", name="uq_job_items_job_cloud_item"),
|
|
Index("ix_job_items_user_item_version", "user_id", "cloud_item_id", "version_key"),
|
|
Index("ix_job_items_job_id", "job_id"),
|
|
)
|
|
|
|
|
|
class UserAnalysisSettings(Base):
|
|
"""Per-user analysis preferences — one row per user, created on demand.
|
|
|
|
Distinct from system_settings (AI provider config) and CloudFolderState.
|
|
This is the single settings authority for user analysis preferences.
|
|
|
|
progress_detail: "simple" (default) | "detailed"
|
|
analysis_failure_behavior: "pause_batch" (default) | "continue_item" (D-11)
|
|
cloud_cache_limit_bytes: user preference capped by tier/admin maximum in the service layer
|
|
"""
|
|
|
|
__tablename__ = "user_analysis_settings"
|
|
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
analysis_progress_detail: Mapped[str] = mapped_column(
|
|
String(16), nullable=False, default="simple"
|
|
)
|
|
analysis_failure_behavior: Mapped[str] = mapped_column(
|
|
String(16), nullable=False, default="pause_batch"
|
|
)
|
|
# Default: 512 MB
|
|
cloud_cache_limit_bytes: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, default=512 * 1024 * 1024
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
|
|
class Group(Base):
|
|
"""v2 stub — empty table, seeded for schema completeness (PROJECT.md D-02).
|
|
|
|
Groups are a v2 feature; the table is created in Phase 1 so the schema is
|
|
complete and future migrations don't need to alter the dependency ordering.
|
|
No rows will be inserted until Phase 2 or later.
|
|
"""
|
|
|
|
__tablename__ = "groups"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
name: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
|
|
class SystemSettings(Base):
|
|
"""AI provider configuration table — one row per provider.
|
|
|
|
Stores the active AI provider's API key (Fernet-encrypted), base URL,
|
|
model name, and context window size. Only one row has is_active=TRUE
|
|
at any given time; the admin panel flips all rows atomically.
|
|
|
|
Encryption (D-05):
|
|
api_key_enc is encrypted with HKDF/Fernet using info=b"ai-provider-settings"
|
|
for domain separation from cloud credentials (info=b"cloud-credentials").
|
|
Master key is settings.cloud_creds_key (env var CLOUD_CREDS_KEY).
|
|
See services/ai_config.py for the encryption helpers.
|
|
|
|
Design reference: 07-RESEARCH.md Pattern 1.
|
|
Migration: 0005_system_settings.py.
|
|
"""
|
|
|
|
__tablename__ = "system_settings"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
provider_id: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
|
api_key_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
base_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
model_name: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
context_chars: Mapped[int] = mapped_column(Integer, nullable=False, default=8000)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("provider_id", name="uq_system_settings_provider_id"),
|
|
)
|