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,
attempt INTEGER NOT NULL DEFAULT 0, -- регенерации Фазы 1: attempt входит в request-hash
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);
`,
// v2: per-chunk×stage disposition (Веха 2, D2). Резолв ПОВЕРХ append-only
// checkpoints (keyed by request_hash-с-attempt) — не колонка на checkpoints:
// на resume строка пересобирается из чекпоинтов, поэтому её потеря на kill -9
// самолечится (classify по тексту чекпоинта, без ре-биллинга). jobs.status
// контентом НЕ расширяется — failed остаётся инфра-сбоем; плохой чанк живёт
// здесь как disposition, а цикл продолжается.
`
CREATE TABLE IF NOT EXISTS chunk_status (
book_id TEXT NOT NULL,
chapter INTEGER NOT NULL,
chunk_idx INTEGER NOT NULL,
stage TEXT NOT NULL,
snapshot_id TEXT NOT NULL, -- под каким снапшотом резолвнут (stale после --resnapshot игнорируется)
content_hash TEXT NOT NULL DEFAULT '',-- сигнатура отрендеренных msgs (исходник НЕ в снапшоте): правка src делает позиционную строку недействительной, а не подаёт устаревший перевод
disposition TEXT NOT NULL, -- ok | flagged | skipped
flag_reason TEXT NOT NULL DEFAULT '', -- '' когда ok; Go-константа FlagReason
attempts INTEGER NOT NULL DEFAULT 0,
final_hash TEXT NOT NULL DEFAULT '', -- request_hash авторитетного чекпоинта (ok-путь подаёт его текст дальше)
cost_usd REAL NOT NULL DEFAULT 0, -- сумма по ВСЕМ попыткам (F3-честно: ретраи учтены)
detail TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (book_id, chapter, chunk_idx, stage)
);
CREATE INDEX IF NOT EXISTS chunk_status_book_idx ON chunk_status (book_id, disposition);
`,
// v3: escalation flag on checkpoints (Веха 2.5, D12 single-hop). The fallback
// draft (a deterministic content-failure routed to another model ONCE) is tagged
// so the book's escalation.budget_usd can be enforced by summing ONLY escalation
// spend, independent of the primary translation cost. On the checkpoint (written
// in the same tx as the settle), NOT request_log, so the sum is durable across
// resume / kill -9 (telemetry is fire-and-forget; money-path budgeting is not).
`ALTER TABLE checkpoints ADD COLUMN escalation INTEGER NOT NULL DEFAULT 0;`,
// v4: ruby/furigana readings captured on ingest (шаг 3a, 04-unhappy §4 / D9). A
// ja epub's base carries the AUTHOR's reading of a
// name/term; epub-v1 drops inline markup (02-mvp:25), which would silently kill
// this layer, so ingest EXTRACTS the readings into this table instead. It is NOT
// injected into any prompt (that is memory v2 + determinism-load-bearing) — this
// step only CAPTURES + PERSISTS; memory v2 (шаг 4) consumes it into a glossary
// name-lock (base→dst via reading, first_chapter→since_ch — D7 schema). PK
// (book_id, base, reading): the same name may carry several readings across a
// book (variant yomi), each a distinct row. first_chapter = the MIN chapter the
// pair appears in (a glossary "since"); occurrences = the pair's full-book count
// (a memory-v2 confidence signal). The persist is idempotent: ingest aggregates
// each (base,reading) once over the WHOLE book, so re-ingesting the same source
// re-derives identical rows and BOTH first_chapter and occurrences are REPLACED
// with those authoritative full-book values (never an increment or a MIN-pin that
// would drift on resume or stale-early after a source edit).
`
CREATE TABLE IF NOT EXISTS ruby_readings (
book_id TEXT NOT NULL,
base TEXT NOT NULL, -- the ruby BODY: the kanji/base surface form
reading TEXT NOT NULL, -- the