Ctrl K
ComunidadGuías…

docs/adr/0005-sqlite-schema-and-migrations.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# ADR-0005: SQLite Schema and Migration Strategy

## Metadata

| Field | Value |
|-------|-------|
| **Status** | Accepted (retrospective) |
| **Date** | 2026-06-08 |
| **Authors** | AlessioBrillo |
| **Deciders** | AlessioBrillo |
| **Supersedes** | N/A |
| **Relates to** | ADR-0001, ADR-0007, ADR-0011 |
| **Project** | DOMINUS |

## Context

DOMINUS persists all data in SQLite. The schema must support six core tables:

- `candidates`: domains entering the pipeline, with status tracking through
  each stage.
- `scoring_runs`: scoring engine output per candidate per run, with a
  snapshot of the weights used.
- `portfolio_entries`: owned domains with acquisition cost, renewal date,
  current score, and drop verdict.
- `trademark_results`: USPTO/EUIPO API responses per search term, with
  TTL-based expiry for cache invalidation.
- `outcomes`: realised events (sold, dropped, expired, renewed) for the
  backtest feedback loop.
- `pipeline_runs`: durable history of every pipeline execution for audit
  and debugging.

A migration system is required to evolve the schema over time without data
loss. The migration strategy must be simple enough for a single-developer
project (no Flyway, no Knex migrations) but robust enough to apply
idempotently and document schema changes in version control.

SQLite-specific constraints driven by the single-user architecture also need
to be documented: WAL mode for concurrent read/write access, foreign key
enforcement via `PRAGMA`, and the absence of ALTER COLUMN support.

## Decision Drivers

1. **Simplicity** — the migration system must be a single file, not a library.
   Better-sqlite3's synchronous API makes this straightforward.
2. **Idempotence** — running migrations against an up-to-date database must
   be a no-op. This allows `npm start` to always run migrations without
   checking a version file.
3. **Version control** — every schema change is a numbered SQL DDL file
   committed with the code. The migration name matches the feature branch
   naming convention.
4. **Backward compatibility** — old code must not break against a newer
   schema (additive migrations only, no destructive DDL).
5. **SQLite-native** — the migration system must not hide SQLite behind an
   ORM. Raw SQL DDL in `.ts` files, executed by a `better-sqlite3`
   prepared statement.

## Considered Options

### Option A: Numbered DDL Constants + Idempotent Migration Runner (CHOSEN)

Each migration is a TypeScript file defining a DDL constant and an optional
seed statement. The migration runner compares the applied migrations against
the filesystem and applies only new ones.

Each DDL uses `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`
for idempotence. The runner tracks applied migrations in a
`schema_migrations` table.

**Advantages:**
- Ultra-simple: ~30 lines of TypeScript for the runner, plus one DDL file per
  migration. No dependencies beyond better-sqlite3.
- Idempotent by construction: `IF NOT EXISTS` means re-running all migrations
  is a no-op.
- Migration numbering is sequential and explicit: each migration file is
  `0001-candidates.ts`, `0002-scoring-runs.ts`, etc. The order is determined
  by sorting filenames, not a version table.
- Single-developer workflow: write the DDL, add the filename to the migration
  list, commit. No migration CLI, no up/down commands.

**Disadvantages:**
- No rollback: all migrations are additive (`CREATE TABLE`, `CREATE INDEX`).
  A destructive migration (DROP COLUMN, DELETE) would require a manual SQL
  script (and should never happen in normal operation).
- The `schema_migrations` table is not itself protected by transactions in
  early implementations — a crash between migration 4 and 5 could leave the
  database in a partial state. (Mitigated in the current runner by wrapping
  all migrations in a single transaction.)

**Cost Implications:** Zero monetary cost. ~4 hours to implement.

**Risk Assessment:** Very low. The system is trivial and has worked without
incident across 8 migrations.

---

### Option B: Knex.js Migration Framework

Use the Knex query builder's migration system, which supports `up()` and
`down()` functions, a migration CLI, and a lock table for concurrent
migration (irrelevant for single-user).

**Advantages:**
- Industry-standard migration system with rollback support.
- Mature and well-documented.
- Locking prevents concurrent migrations (not needed here).

**Disadvantages:**
- Adds a heavy dependency (Knex + its SQLite dialect) to a project that uses
  raw SQL for all other database access.
- Adds a migration CLI tool — the operator must learn Knex commands.
- Rollback is rarely used in practice for single-developer projects:
  destructive schema changes are typically handled by taking a backup and
  writing a new migration.
- Knex abstracts SQL syntax, which makes debugging harder — the operator
  must understand both Knex API and SQLite SQL.

**Cost Implications:** Zero monetary cost. ~12 hours to set up and 30KB added
dependency size. Introduces a SQL abstraction layer that contradicts the
design decision to use raw SQL.

**Risk Assessment:** Low, but introduces unnecessary abstraction and dependency
weight for no benefit at single-user scale.

---

### Option C: JSON File with Schema Version

Store a schema version number in a JSON file in `data/` and apply migrations
conditionally based on the version.

**Advantages:**
- No `schema_migrations` table in the database.
- Version is human-readable in a JSON file.

**Disadvantages:**
- The version file can get out of sync with the database (e.g., if the
  database is replaced but the version file persists).
- Two state locations (database + filesystem) doubles the surface for
  inconsistency.
- JSON file writes are not atomic on all filesystems — a crash during
  migration could corrupt the version file.
- No advantage over a `schema_migrations` table for a single-user tool.

**Cost Implications:** Zero monetary cost. Less robust than Option A.

**Risk Assessment:** Medium. The split-state problem (version file + database)
makes this strictly worse than a database-backed migration tracker.

---

## Decision

**Chosen option: Option A — Numbered DDL Constants + Idempotent Migration Runner**

The rationale is driven by the decision drivers:

1. **Simplicity**: The migration runner is 30 lines of TypeScript. Each
   migration is a DDL string in a dedicated file. No CLI, no framework, no
   lock table.

2. **Idempotence**: `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT
   EXISTS` make every migration idempotent by construction. The
   `schema_migrations` table prevents the runner from even attempting to
   execute already-applied migrations.

3. **Version control**: Migrations are numbered sequentially (`0001-`, `0002-`,
   etc.) and committed with the code. The schema is fully reconstructed by
   running all migrations against an empty database.

4. **Backward compatibility**: All 8 migrations are additive. No DROP, no
   ALTER COLUMN. Old code against a newer schema still works because new
   columns have defaults and new tables are never referenced by old code.

5. **SQLite-native**: Raw SQL DDL strings executed by `better-sqlite3.prepare()`.
   No ORM, no query builder, no abstraction layer.

The schema design decisions follow naturally:

- **WAL mode** (`PRAGMA journal_mode=WAL`): allows concurrent reads during
  writes in single-user mode. The `PipelineRunService` transaction writes to
  `candidates` and `scoring_runs` while the REST API may be reading
  `portfolio_entries` — WAL mode prevents lock contention.
- **Foreign keys** (`PRAGMA foreign_keys=ON`): enforced at the connection
  level, not as a schema DDL. This is safer for migration (a migration that
  creates a circular FK would fail immediately) and avoids the parsing issues
  that SQLite has with inline FK constraints in `CREATE TABLE`.
- **Text dates**: All timestamps are ISO-8601 text (`TEXT NOT NULL`). SQLite
  has no native datetime type; storing ISO-8601 text is human-readable,
  sortable, and compatible with `new Date(isoString)` and `strftime()`
  queries.

## Consequences

### Positive
- Migration runner is ~30 lines, understood at a glance.
- All 8 migrations are committed in version control with the schema they
  introduce. The full history is readable in `src/db/migrations/`.
- Idempotent: `runMigrations(db)` is safe to call on every `npm start`.
- No external dependency beyond better-sqlite3.

### Negative
- No rollback support. Destructive schema changes require a manual SQL script
  and are not represented as migrations. (Mitigation: destructive changes are
  rare and always preceded by a backup.)
- Migration files are raw SQL — no TypeScript type checking on DDL. A typo in
  a column name is caught only when the migration runs.
- The `schema_migrations` table stores only the migration name and timestamp.
  It does not store the DDL that was applied — reconstituting the schema
  requires reading all migration files.

### Compliance and Security Implications
- All SQL queries use parameterised statements (better-sqlite3 API enforces
  this). No SQL injection vector.
- The database file is stored at `DATABASE_PATH` (default `./data/dominus.db`),
  which is in the gitignored `data/` directory. The database is never
  committed to version control.
- WAL mode files (`.db-wal`, `.db-shm`) are gitignored and cleaned up by
  SQLite on clean shutdown.

### Migration and Monitoring Plan
- **Migration**: Eight migrations already applied. The `runMigrations()` call
  in `src/index.ts` runs on every `npm start` and is idempotent.
- **Adding a migration**: (1) Create `src/db/migrations/NNNN-description.ts`
  with a DDL constant, (2) add it to the migration list in
  `src/db/migrator.ts`, (3) run `npm run typecheck` to verify nothing broke,
  (4) commit.
- **Rollback**: There is no automated rollback. Manual rollback: restore from
  backup, or write a reverse migration SQL script. Backup before any
  migration that changes existing columns.

### Validation
- The migration runner is validated by tests in each repository file: each
  test creates an in-memory database, runs migrations, and asserts table
  structure via test queries.
- The `PipelineRunService` test validates that `pipeline_runs` rows are
  written correctly and respect the schema (ADR-0011).
- Production validation: `npm start` on a fresh install creates the database
  and applies all 8 migrations. `npm start` on an existing database is a
  no-op.

---

*This ADR was created following the MADR 4.0.0 standard. All DOMINUS ADRs
should be consistent with the product vision previously documented in
`dominus-product-vision.md` (v0.2), now superseded by this ADR series.
Template: `docs/adr/template.md`.*