Spaces:
Build error
Build error
| # Database Architecture | |
| This document describes the database architecture and file metadata management system in the PDF-TEI Editor. | |
| ## Overview | |
| The application uses **SQLite** for file metadata storage with a document-centric design. The database stores metadata about PDF and TEI files, their relationships, and sync tracking information. The filesystem contains the actual file content, while the database provides fast querying and relationship management. | |
| ## Key Design Principles | |
| ### 1. Reconstructable Database | |
| The database does not contain information that cannot be reconstructed from the filesystem (TEI/PDF content). In case of corruption or being outdated after WebDAV sync, it can be rebuilt from the files. | |
| ### 2. Document-Centric Model | |
| - **PDF files** store document metadata (collections, metadata, etc.) | |
| - **TEI files** inherit metadata from their associated PDF via `doc_id` | |
| - All files sharing a `doc_id` represent artifacts of the same document | |
| ### 3. Soft Deletes | |
| Files are marked as deleted (`deleted = 1`) rather than removed from the database. This: | |
| - Enables sync tracking (know what was deleted since last sync) | |
| - Preserves history for debugging | |
| - Allows undelete operations | |
| ### 4. Sync-Ready Schema | |
| While sync implementation is complete, the schema was designed from the start to support efficient synchronization: | |
| - `local_modified_at` - Change tracking for delta sync | |
| - `sync_status` - Sync state machine | |
| - `sync_hash` - Conflict detection | |
| - `deleted` - Soft delete marker (replaces `.deleted` files) | |
| ## Database Location | |
| - **Development**: `data/metadata.db` | |
| - **Production**: `data/metadata.db` (configurable via settings) | |
| - **Tests**: Temporary directory for isolation | |
| ## Configuration Management | |
| The application uses a two-tier configuration system: | |
| ### Default Configuration | |
| Location: `config/` directory (version-controlled) | |
| - `config.json` - Application configuration defaults | |
| - `users.json` - Default user accounts | |
| - `groups.json` - Default user groups | |
| - `collections.json` - Default collections | |
| - `roles.json` - Role definitions | |
| ### Runtime Configuration | |
| Location: `data/db/` directory (gitignored) | |
| - `config.json` - Runtime configuration (copied from `config/` on startup) | |
| - `users.json` - User accounts (copied from `config/` on startup) | |
| - `groups.json` - User groups (copied from `config/` on startup) | |
| - `collections.json` - Collections (copied from `config/` on startup) | |
| - `roles.json` - Roles (copied from `config/` on startup) | |
| ### Initialization Pattern | |
| On server startup ([fastapi_app/lib/core/db_init.py](../../fastapi_app/lib/core/db_init.py)): | |
| 1. Check if `data/db/*.json` files exist | |
| 2. If missing, copy from `config/*.json` | |
| 3. Merge missing keys into `data/db/config.json` (preserves existing values) | |
| 4. SQLite databases created on first access (on-demand) | |
| This ensures: | |
| - **Clean branches** - No database file conflicts in git | |
| - **Easy reset** - Delete `data/db/`, restart server | |
| - **Default recovery** - Missing files auto-restore | |
| - **Safe updates** - Preserves user customizations | |
| ## Database Schema | |
| ### Files Table | |
| The `files` table stores all file metadata: | |
| ```sql | |
| CREATE TABLE files ( | |
| -- Primary key | |
| id TEXT PRIMARY KEY, -- SHA-256 content hash (64 chars) | |
| -- Stable ID for API responses | |
| stable_id TEXT, -- Short stable identifier (6-12 chars) | |
| -- File identity | |
| file_type TEXT NOT NULL, -- 'pdf' | 'tei-xml' | |
| doc_id TEXT NOT NULL, -- Document identifier (groups related files) | |
| -- Document metadata (stored only on PDF files) | |
| doc_collections TEXT, -- JSON array: ["collection1", "collection2"] | |
| doc_metadata TEXT, -- JSON object: document-level metadata | |
| -- File-specific metadata | |
| file_metadata TEXT, -- JSON object: file-specific metadata | |
| variant_id TEXT, -- Variant identifier (e.g., "translation-fr") | |
| is_gold_standard INTEGER DEFAULT 0, -- Boolean: 1 for gold standard TEI files | |
| -- Filesystem location | |
| storage_path TEXT NOT NULL, -- Relative path in data directory | |
| -- Sync tracking | |
| deleted INTEGER DEFAULT 0, -- Soft delete: 1 = deleted | |
| local_modified_at TEXT, -- ISO timestamp of last local change | |
| sync_status TEXT, -- Sync state: 'synced'|'pending'|'conflict' | |
| sync_hash TEXT, -- Hash for conflict detection | |
| -- Audit timestamps | |
| created_at TEXT NOT NULL, -- ISO timestamp | |
| updated_at TEXT NOT NULL -- ISO timestamp | |
| ) | |
| ``` | |
| **Indexes:** | |
| ```sql | |
| CREATE INDEX idx_files_doc_id ON files(doc_id); | |
| CREATE INDEX idx_files_file_type ON files(file_type); | |
| CREATE INDEX idx_files_deleted ON files(deleted); | |
| CREATE INDEX idx_files_sync_status ON files(sync_status); | |
| CREATE INDEX idx_files_stable_id ON files(stable_id); | |
| ``` | |
| ### Sync Metadata Table | |
| Stores WebDAV sync state: | |
| ```sql | |
| CREATE TABLE sync_metadata ( | |
| key TEXT PRIMARY KEY, | |
| value TEXT NOT NULL, | |
| updated_at TEXT NOT NULL | |
| ) | |
| ``` | |
| Used for tracking: | |
| - Last sync timestamp | |
| - Sync server URL | |
| - Sync credentials (encrypted) | |
| ### Storage References Table | |
| Tracks filesystem references for safe cleanup: | |
| ```sql | |
| CREATE TABLE storage_references ( | |
| hash_prefix TEXT, -- First 2 chars of hash (sharding) | |
| content_hash TEXT, -- Full SHA-256 hash | |
| reference_count INTEGER, -- Number of files referencing this hash | |
| PRIMARY KEY (hash_prefix, content_hash) | |
| ) | |
| ``` | |
| Enables safe deletion: | |
| - Only delete filesystem files when `reference_count = 0` | |
| - Multiple database records can reference same file content | |
| - Git-style hash-sharded storage (`data/files/ab/cd/abcd...`) | |
| ## Database Components | |
| ### DatabaseManager | |
| Location: [fastapi_app/lib/core/database.py](../../fastapi_app/lib/core/database.py) | |
| Provides connection management and transactions: | |
| ```python | |
| class DatabaseManager: | |
| def __init__(self, db_path: Path, logger=None): | |
| """Initialize database manager and ensure schema exists.""" | |
| @contextmanager | |
| def get_connection(self) -> sqlite3.Connection: | |
| """Context manager for database connections.""" | |
| @contextmanager | |
| def transaction(self) -> sqlite3.Connection: | |
| """Context manager for transactions with auto-commit/rollback.""" | |
| ``` | |
| **Features:** | |
| - Thread-safe connection management | |
| - Automatic schema initialization | |
| - Row factory for dict-like access | |
| - Foreign keys enabled | |
| - Write-Ahead Logging (WAL) mode for better performance | |
| **Usage:** | |
| ```python | |
| # Simple query | |
| with db_manager.get_connection() as conn: | |
| cursor = conn.cursor() | |
| cursor.execute("SELECT * FROM files WHERE deleted = 0") | |
| files = cursor.fetchall() | |
| # Transaction | |
| with db_manager.transaction() as conn: | |
| cursor = conn.cursor() | |
| cursor.execute("INSERT INTO files ...") | |
| cursor.execute("UPDATE files ...") | |
| # Auto-commit on exit, rollback on exception | |
| ``` | |
| ### FileRepository | |
| Location: [fastapi_app/lib/repository/file_repository.py](../../fastapi_app/lib/repository/file_repository.py) | |
| Provides high-level CRUD operations with Pydantic models: | |
| ```python | |
| class FileRepository: | |
| def __init__(self, db_manager: DatabaseManager, logger=None): | |
| """Initialize repository with storage reference counting.""" | |
| ``` | |
| #### Core CRUD Operations | |
| All queries **filter `deleted = 0` by default** unless explicitly requesting deleted files. | |
| ```python | |
| # Create | |
| def insert_file(self, file_data: FileCreate) -> FileMetadata: | |
| """Insert new file record. Returns created FileMetadata.""" | |
| # Read | |
| def get_file_by_id(self, file_id: str) -> Optional[FileMetadata]: | |
| """Get file by full SHA-256 hash (64 chars).""" | |
| def get_file_by_stable_id(self, stable_id: str) -> Optional[FileMetadata]: | |
| """Get file by short stable ID (6-12 chars).""" | |
| def get_file_by_id_or_stable_id(self, file_id: str) -> Optional[FileMetadata]: | |
| """Get file by either stable_id or full hash.""" | |
| # Update | |
| def update_file(self, file_id: str, updates: FileUpdate) -> FileMetadata: | |
| """Update file record. Returns updated FileMetadata.""" | |
| # Delete (soft) | |
| def delete_file(self, file_id: str) -> None: | |
| """Mark file as deleted (sets deleted = 1).""" | |
| ``` | |
| #### Document-Centric Queries | |
| ```python | |
| def get_files_by_doc_id(self, doc_id: str) -> List[FileMetadata]: | |
| """Get all files for a document (excludes deleted).""" | |
| def get_pdf_for_document(self, doc_id: str) -> Optional[FileMetadata]: | |
| """Get the PDF file for a document.""" | |
| def get_latest_tei_version(self, doc_id: str) -> Optional[FileMetadata]: | |
| """Get most recent TEI version (excludes gold standard).""" | |
| def get_gold_standard(self, doc_id: str) -> Optional[FileMetadata]: | |
| """Get gold standard TEI file for document.""" | |
| def get_all_versions(self, doc_id: str) -> List[FileMetadata]: | |
| """Get all TEI versions (excludes gold standard and deleted).""" | |
| ``` | |
| #### Metadata Inheritance | |
| TEI files inherit `doc_collections` and `doc_metadata` from their PDF: | |
| ```python | |
| def get_file_with_doc_metadata( | |
| self, | |
| file_id: str | |
| ) -> Optional[FileWithDocMetadata]: | |
| """ | |
| Get file with inherited document metadata. | |
| For TEI files, returns both: | |
| - doc_collections/doc_metadata (NULL for TEI) | |
| - inherited_doc_collections/inherited_doc_metadata (from PDF via JOIN) | |
| """ | |
| ``` | |
| **SQL Implementation:** | |
| ```sql | |
| SELECT | |
| f.*, | |
| pdf.doc_collections as inherited_doc_collections, | |
| pdf.doc_metadata as inherited_doc_metadata | |
| FROM files f | |
| LEFT JOIN files pdf ON f.doc_id = pdf.doc_id AND pdf.file_type = 'pdf' | |
| WHERE f.id = ? AND f.deleted = 0 | |
| ``` | |
| #### List and Filter | |
| ```python | |
| def list_files( | |
| self, | |
| collection: Optional[str] = None, | |
| variant: Optional[str] = None, | |
| file_type: Optional[str] = None, | |
| include_deleted: bool = False | |
| ) -> List[FileMetadata]: | |
| """ | |
| List files with optional filters. | |
| Args: | |
| collection: Filter by collection ID | |
| variant: Filter by variant_id | |
| file_type: Filter by file_type ('pdf' | 'tei-xml') | |
| include_deleted: Include soft-deleted files | |
| """ | |
| ``` | |
| #### Sync Support | |
| ```python | |
| def get_deleted_files(self) -> List[FileMetadata]: | |
| """Get files marked as deleted (for sync reconciliation).""" | |
| def mark_deleted(self, file_id: str) -> None: | |
| """Soft delete with sync tracking.""" | |
| def update_sync_status(self, file_id: str, updates: SyncUpdate) -> None: | |
| """Update sync tracking fields.""" | |
| def get_sync_metadata(self, key: str) -> Optional[str]: | |
| """Get sync metadata value.""" | |
| def set_sync_metadata(self, key: str, value: str) -> None: | |
| """Set sync metadata value.""" | |
| ``` | |
| ### StorageReferenceManager | |
| Location: [fastapi_app/lib/storage/storage_references.py](../../fastapi_app/lib/storage/storage_references.py) | |
| Manages reference counting for safe filesystem cleanup: | |
| ```python | |
| class StorageReferenceManager: | |
| def increment_reference(self, content_hash: str) -> None: | |
| """Increment reference count when file is added.""" | |
| def decrement_reference(self, content_hash: str) -> bool: | |
| """ | |
| Decrement reference count when file is deleted. | |
| Returns True if safe to delete file from filesystem. | |
| """ | |
| ``` | |
| **Usage:** | |
| ```python | |
| # When creating file | |
| file_repo.insert_file(file_data) | |
| ref_manager.increment_reference(file_data.id) | |
| # When deleting file | |
| file_repo.delete_file(file_id) | |
| can_delete = ref_manager.decrement_reference(file_id) | |
| if can_delete: | |
| # Safe to delete from filesystem | |
| os.remove(storage_path) | |
| ``` | |
| ## Pydantic Models | |
| Location: [fastapi_app/lib/models/models.py](../../fastapi_app/lib/models/models.py) | |
| Type-safe models for database operations: | |
| ```python | |
| class FileMetadata(BaseModel): | |
| """Complete file record from database.""" | |
| id: str | |
| stable_id: Optional[str] | |
| file_type: Literal['pdf', 'tei-xml'] | |
| doc_id: str | |
| doc_collections: List[str] | |
| doc_metadata: dict | |
| file_metadata: dict | |
| variant_id: Optional[str] | |
| is_gold_standard: bool | |
| storage_path: str | |
| deleted: bool | |
| local_modified_at: Optional[datetime] | |
| sync_status: Optional[str] | |
| sync_hash: Optional[str] | |
| created_at: datetime | |
| updated_at: datetime | |
| class FileCreate(BaseModel): | |
| """Model for creating new file records.""" | |
| id: str # SHA-256 hash | |
| stable_id: Optional[str] | |
| file_type: Literal['pdf', 'tei-xml'] | |
| doc_id: str | |
| # ... (subset of FileMetadata) | |
| class FileUpdate(BaseModel): | |
| """Model for updating file records (all fields optional).""" | |
| doc_collections: Optional[List[str]] | |
| doc_metadata: Optional[dict] | |
| file_metadata: Optional[dict] | |
| # ... | |
| class FileWithDocMetadata(FileMetadata): | |
| """File with inherited document metadata from PDF.""" | |
| inherited_doc_collections: List[str] | |
| inherited_doc_metadata: dict | |
| ``` | |
| ## File Storage Layout | |
| Files are stored using Git-style hash sharding: | |
| ``` | |
| data/ | |
| ├── files/ | |
| │ ├── ab/ | |
| │ │ ├── cd/ | |
| │ │ │ └── abcd1234...5678 # Full SHA-256 hash | |
| │ │ └── ef/ | |
| │ │ └── abef9876...4321 | |
| │ └── 12/ | |
| │ └── 34/ | |
| │ └── 1234abcd...ef56 | |
| └── metadata.db | |
| ``` | |
| **Benefits:** | |
| - Prevents directory with too many files (filesystem limits) | |
| - Fast lookups using hash prefix | |
| - Content-addressable storage (deduplication) | |
| ## Database Initialization | |
| ### Application Startup | |
| Location: [fastapi_app/main.py](../../fastapi_app/main.py) | |
| ```python | |
| from .lib.db_init import ensure_db_initialized | |
| # Initialize configuration and database | |
| ensure_db_initialized() | |
| ``` | |
| ### For Tests | |
| Tests use isolated temporary directories: | |
| ```python | |
| import tempfile | |
| from pathlib import Path | |
| from fastapi_app.lib.core.db_init import initialize_db_from_config | |
| # Create temporary database | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| db_dir = Path(tmpdir) / "db" | |
| config_dir = Path("config") | |
| initialize_db_from_config(config_dir, db_dir) | |
| # Run tests with isolated database | |
| db_manager = DatabaseManager(db_dir / "metadata.db") | |
| ``` | |
| ## Query Patterns | |
| ### Document Grouping | |
| Get all files for a document with inherited metadata: | |
| ```python | |
| files = file_repo.get_files_by_doc_id("doc123") | |
| pdf = file_repo.get_pdf_for_document("doc123") | |
| gold = file_repo.get_gold_standard("doc123") | |
| versions = file_repo.get_all_versions("doc123") | |
| ``` | |
| ### Collection Filtering | |
| Find all documents in a collection: | |
| ```python | |
| files = file_repo.list_files(collection="manuscripts") | |
| # Documents appear if ANY collection matches | |
| # doc_collections = ["manuscripts", "letters"] matches collection="manuscripts" | |
| ``` | |
| ### Metadata Inheritance Example | |
| ```python | |
| # PDF file stores metadata | |
| pdf = FileMetadata( | |
| id="abc123...", | |
| file_type="pdf", | |
| doc_id="doc1", | |
| doc_collections=["manuscripts"], | |
| doc_metadata={"title": "Medieval Text"} | |
| ) | |
| # TEI file inherits metadata | |
| tei = file_repo.get_file_with_doc_metadata("def456...") | |
| # tei.doc_collections = None (TEI doesn't store) | |
| # tei.inherited_doc_collections = ["manuscripts"] (from PDF) | |
| # tei.inherited_doc_metadata = {"title": "Medieval Text"} | |
| ``` | |
| ## Performance Considerations | |
| ### Indexes | |
| Critical indexes for common queries: | |
| - `idx_files_doc_id` - Document grouping | |
| - `idx_files_file_type` - File type filtering | |
| - `idx_files_deleted` - Exclude deleted files | |
| - `idx_files_stable_id` - API lookups | |
| ### WAL Mode | |
| Write-Ahead Logging enables: | |
| - Concurrent readers with single writer | |
| - Better performance for write-heavy workloads | |
| - Crash recovery | |
| ### Connection Pooling | |
| Single DatabaseManager instance per application: | |
| - **Connection Pooling**: Implemented using `queue.Queue` to reuse connections and reduce file open/close overhead. | |
| - **Singleton Pattern**: `_DatabaseManagerSingleton` ensures one pool per database file. | |
| - **Concurrency**: Optimized for high concurrency with WAL mode and explicit transaction management. | |
| - **Thread-safe**: Context managers handle connection checkout/return and transaction boundaries. | |
| ## Migration and Maintenance | |
| ### Database Rebuild | |
| If database becomes corrupted or outdated: | |
| ```bash | |
| # Remove database | |
| rm data/metadata.db | |
| # Restart server (auto-recreates from filesystem) | |
| npm run start:dev | |
| ``` | |
| ### Schema Updates | |
| Schema changes require: | |
| 1. Update `fastapi_app/lib/core/db_schema.py` | |
| 2. Add migration logic in `initialize_database()` | |
| 3. Test with clean database and existing database | |
| ### Backup | |
| ```bash | |
| # Backup database | |
| cp data/metadata.db data/metadata.db.backup | |
| # Backup configuration | |
| cp -r data/db/ data/db.backup/ | |
| ``` | |
| ## Testing | |
| ### Unit Tests | |
| Location: `tests/unit/fastapi/` | |
| ```bash | |
| # Database initialization tests | |
| uv run python -m pytest tests/unit/fastapi/test_db_init.py -v | |
| # File repository tests | |
| uv run python -m pytest tests/unit/fastapi/test_file_repository.py -v | |
| ``` | |
| ### Test Isolation | |
| Tests use temporary directories: | |
| ```python | |
| def test_file_operations(): | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| db_path = Path(tmpdir) / "test.db" | |
| db_manager = DatabaseManager(db_path) | |
| file_repo = FileRepository(db_manager) | |
| # Test operations in isolation | |
| # ... | |
| ``` | |
| ## Related Documentation | |
| - [Architecture Overview](architecture.md) - Complete system architecture | |
| - [Configuration Management](configuration.md) - Config files and CLI | |
| - [Access Control](access-control.md) - RBAC and collection-based access | |
| - [Collections](collections.md) - Collection management system | |
| - [Testing](testing.md) - Testing infrastructure | |