package store import ( "context" "fmt" ) // migrations are applied in order; schema_version records the highest applied // version so re-runs are no-ops. Every step is also idempotent (CREATE TABLE // IF NOT EXISTS) so a half-applied database still converges. Append-only: // never edit an earlier migration (дисциплина vojo). var migrations = []string{ // v1: the Phase-0 operational schema. ` -- Snapshot контекста джобы (Р6, implementation-notes §3.2): материализованная -- запись; snapshot_id входит в request-hash, так что волатильность контекста -- заморожена на джобу. payload — JSON (версии промптов per-role, модели, -- версия чанкера, style sheet, резюме на момент старта). CREATE TABLE IF NOT EXISTS snapshots ( snapshot_id TEXT PRIMARY KEY, brief_hash TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Durable jobs: джоба = глава×стадия (Р6). Гранулярность СОХРАННОСТИ — чанк -- (checkpoints), джоба лишь группирует их и несёт snapshot_id. CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY, book_id TEXT NOT NULL, chapter INTEGER NOT NULL, stage TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|failed snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (book_id, chapter, stage) ); -- Чанк-чекпоинты: сырой ответ LLM персистится атомарно вместе с settle -- (одна транзакция — SettleWithCheckpoint), ключ — request_hash от -- детерминированного рендера (§3.1). kill -9 теряет максимум один -- in-flight вызов. Найденный чекпоинт на resume = вызов не повторяется и -- не оплачивается второй раз. CREATE TABLE IF NOT EXISTS checkpoints ( request_hash TEXT PRIMARY KEY, job_id INTEGER NOT NULL REFERENCES jobs(id), chunk_idx INTEGER NOT NULL, stage TEXT NOT NULL, role TEXT NOT NULL, model_requested TEXT NOT NULL, model_actual TEXT NOT NULL, response_text TEXT NOT NULL, usage_json TEXT NOT NULL, cost_usd REAL NOT NULL, finish_reason TEXT NOT NULL DEFAULT '', provider_request_id TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS checkpoints_job_idx ON checkpoints (job_id, chunk_idx); -- Дневной/книжный ledger с резервированием (порт семантики vojo spend): -- потолки считают committed + reserved на допуске, поэтому burst -- конкурентных вызовов не проскакивает потолок (TOCTOU-дисциплина). CREATE TABLE IF NOT EXISTS spend ( book_id TEXT NOT NULL, date TEXT NOT NULL, -- UTC YYYY-MM-DD committed_usd REAL NOT NULL DEFAULT 0, reserved_usd REAL NOT NULL DEFAULT 0, PRIMARY KEY (book_id, date) ); -- request_log: одна строка на LLM-вызов (Р7: per-книга/глава/чанк/стадия/ -- роль/модель, $, latency, cache-поля). Телеметрия, не источник денег — -- деньги живут в spend/checkpoints. CREATE TABLE IF NOT EXISTS request_log ( id INTEGER PRIMARY KEY, ts TEXT NOT NULL DEFAULT (datetime('now')), trace_id TEXT NOT NULL DEFAULT '', book_id TEXT NOT NULL, chapter INTEGER NOT NULL DEFAULT 0, chunk_idx INTEGER NOT NULL DEFAULT 0, stage TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT '', model_requested TEXT NOT NULL DEFAULT '', model_actual TEXT NOT NULL DEFAULT '', request_hash TEXT NOT NULL DEFAULT '', prompt_tokens INTEGER NOT NULL DEFAULT 0, cached_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, completion_tokens INTEGER NOT NULL DEFAULT 0, reasoning_tokens INTEGER NOT NULL DEFAULT 0, cost_usd REAL NOT NULL DEFAULT 0, latency_ms INTEGER NOT NULL DEFAULT 0, finish_reason TEXT NOT NULL DEFAULT '', tm_hit INTEGER NOT NULL DEFAULT 0, -- ответ взят из чекпоинта, вызова не было degraded TEXT NOT NULL DEFAULT '', err TEXT NOT NULL DEFAULT '', ok INTEGER NOT NULL DEFAULT 1 ); CREATE INDEX IF NOT EXISTS request_log_ts_idx ON request_log (ts); `, } // migrate runs all pending migrations on the write pool, one transaction per // step, recording each in schema_version. func (s *Store) migrate(ctx context.Context) error { if _, err := s.w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil { return fmt.Errorf("store: schema_version: %w", err) } var current int if err := s.w.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(¤t); err != nil { return fmt.Errorf("store: read version: %w", err) } for v := current; v < len(migrations); v++ { tx, err := s.w.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("store: begin migration %d: %w", v+1, err) } if _, err := tx.ExecContext(ctx, migrations[v]); err != nil { _ = tx.Rollback() return fmt.Errorf("store: apply migration %d: %w", v+1, err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (?)`, v+1); err != nil { _ = tx.Rollback() return fmt.Errorf("store: record migration %d: %w", v+1, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("store: commit migration %d: %w", v+1, err) } } return nil }