textmachine/backend/internal/store/migrate.go

133 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(&current); 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
}