Files
claudia-docs-api/app/database.py
Motoko 7f3e8a8f53 Phase 1 MVP - Complete implementation
- Auth: register, login, JWT with refresh tokens, blocklist
- Projects/Folders/Documents CRUD with soft deletes
- Tags CRUD and assignment
- FTS5 search with highlights and tag filtering
- ADR-001, ADR-002, ADR-003 compliant
- Security fixes applied (JWT_SECRET_KEY, exception handler, cookie secure)
- 25 tests passing
2026-03-30 15:17:27 +00:00

248 lines
8.8 KiB
Python

import asyncio
import hashlib
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from sqlalchemy import create_engine, event, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
# Async engine for aiosqlite
DATABASE_URL = settings.resolved_database_url
# Sync engine for migrations and initial setup
SYNC_DATABASE_URL = DATABASE_URL.replace("sqlite+aiosqlite:///", "sqlite:///")
class Base(DeclarativeBase):
pass
# Async session factory
async_engine = create_async_engine(DATABASE_URL, echo=False)
AsyncSessionLocal = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
# Sync engine for migrations
sync_engine = create_engine(SYNC_DATABASE_URL, echo=False)
SyncSessionLocal = sessionmaker(sync_engine)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_db_simple():
"""Bare async generator for FastAPI dependency injection."""
async with AsyncSessionLocal() as session:
yield session
async def init_db():
"""Initialize database with all tables, views, FTS5, and triggers."""
# Create data directory
Path("./data").mkdir(exist_ok=True)
async with async_engine.begin() as conn:
# Create all tables via SQL (not ORM) to handle SQLite-specific features
await conn.run_sync(_create_schema)
def _create_schema(sync_conn):
"""Create all tables, views, FTS5 tables, and triggers synchronously."""
# Agents table
sync_conn.execute(text("""
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'agent' CHECK (role IN ('agent', 'admin')),
is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TIMESTAMP NULL,
deleted_by TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
)
"""))
# Projects table
sync_conn.execute(text("""
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
agent_id TEXT NOT NULL REFERENCES agents(id),
is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TIMESTAMP NULL,
deleted_by TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
)
"""))
# Folders table
sync_conn.execute(text("""
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
path TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TIMESTAMP NULL,
deleted_by TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
)
"""))
# Documents table
sync_conn.execute(text("""
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
path TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TIMESTAMP NULL,
deleted_by TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
)
"""))
# Tags table
sync_conn.execute(text("""
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
color TEXT NOT NULL DEFAULT '#6366f1',
is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TIMESTAMP NULL,
deleted_by TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
)
"""))
# Document tags junction
sync_conn.execute(text("""
CREATE TABLE IF NOT EXISTS document_tags (
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (document_id, tag_id)
)
"""))
# Refresh tokens table
sync_conn.execute(text("""
CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES agents(id),
token_hash TEXT NOT NULL UNIQUE,
token_family_id TEXT NOT NULL,
token_version INTEGER NOT NULL,
user_agent TEXT,
ip_address TEXT,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NULL,
is_global_logout INTEGER NOT NULL DEFAULT 0
)
"""))
# JWT blocklist table
sync_conn.execute(text("""
CREATE TABLE IF NOT EXISTS jwt_blocklist (
token_id TEXT PRIMARY KEY,
revoked_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
expires_at TIMESTAMP NOT NULL
)
"""))
# Indexes
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_projects_agent ON projects(agent_id)"))
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_folders_project ON folders(project_id)"))
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id)"))
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_documents_project ON documents(project_id)"))
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_documents_folder ON documents(folder_id)"))
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_document_tags_doc ON document_tags(document_id)"))
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_document_tags_tag ON document_tags(tag_id)"))
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash)"))
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_family ON refresh_tokens(user_id, token_family_id)"))
# --- FTS5 virtual table ---
sync_conn.execute(text("""
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
document_id,
title,
content,
path,
tokenize='unicode61 remove_diacritics 1'
)
"""))
# --- Active views for soft deletes ---
sync_conn.execute(text("""
CREATE VIEW IF NOT EXISTS active_agents AS
SELECT * FROM agents WHERE is_deleted = 0
"""))
sync_conn.execute(text("""
CREATE VIEW IF NOT EXISTS active_projects AS
SELECT * FROM projects WHERE is_deleted = 0
"""))
sync_conn.execute(text("""
CREATE VIEW IF NOT EXISTS active_folders AS
SELECT * FROM folders WHERE is_deleted = 0
"""))
sync_conn.execute(text("""
CREATE VIEW IF NOT EXISTS active_documents AS
SELECT * FROM documents WHERE is_deleted = 0
"""))
sync_conn.execute(text("""
CREATE VIEW IF NOT EXISTS active_tags AS
SELECT * FROM tags WHERE is_deleted = 0
"""))
# --- FTS5 Sync Triggers ---
# Insert trigger
sync_conn.execute(text("""
CREATE TRIGGER IF NOT EXISTS documents_fts_ai AFTER INSERT ON documents BEGIN
INSERT INTO documents_fts(document_id, title, content, path)
VALUES (new.id, new.title, new.content, new.path);
END
"""))
# Update trigger (delete old + insert new)
sync_conn.execute(text("""
CREATE TRIGGER IF NOT EXISTS documents_fts_au AFTER UPDATE ON documents BEGIN
DELETE FROM documents_fts WHERE document_id = old.id;
INSERT INTO documents_fts(document_id, title, content, path)
VALUES (new.id, new.title, new.content, new.path);
END
"""))
# Soft-delete trigger (when is_deleted becomes TRUE)
sync_conn.execute(text("""
CREATE TRIGGER IF NOT EXISTS documents_fts_ad AFTER UPDATE ON documents
WHEN new.is_deleted = 1 AND old.is_deleted = 0
BEGIN
DELETE FROM documents_fts WHERE document_id = old.id;
END
"""))
sync_conn.commit()