Files
interactive-story/backend/app/migrations.py
T
parththakkar106andClaude Opus 5 f1bd099ec8 Cut database egress 189x by deferring the prompt snapshot
The free-tier 5 GB/month network transfer allowance ran out, which blocks
connections outright. The database is only ~55 MB, so 5 GB meant the whole
thing was being pulled roughly 90 times over.

Cause: actions is 39 MB of that 55 MB -- 541 rows at ~74 KB each, almost
entirely context_snapshot, which stores the whole assembled prompt for a
turn. Every adventure load and every turn fetched all of it in order to
read two small things out of it: the world-change chips under an AI
message (Action.world_changes) and the emit block re-attached when
replaying history to the model (_history_text). The Insights viewer is
the only consumer that wants the whole snapshot, and it asks for one
action at a time.

Lifts that slice into its own small actions.world_delta column
(migration 36) and marks context_snapshot, state_before and
world_state_before deferred, so they load only when something touches
the attribute -- Insights, undo and retry, all single-action paths.
The backfill runs server-side, dialect-specific (json_extract on SQLite,
#> on Postgres), because pulling 39 MB of snapshots into Python to
rewrite a slice of each would defeat the purpose.

Measured at production shape (541 actions, 72 KB snapshots), one
adventure load goes from 38.46 MB to 0.20 MB. The traffic that consumed
5 GB would now be about 27 MB.

Deliberately not included: limiting the history query to recent actions,
and removing the redundant db.refresh(adventure) calls. Both were sized
against the old numbers; against a 0.20 MB load they would take ~27 MB a
month down to ~10 MB, which is not worth the complexity.

tests/test_egress.py hooks before_cursor_execute and asserts the emitted
SQL never names the deferred columns during a bulk load, so this cannot
regress silently. 123 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
2026-08-03 20:35:54 +05:30

216 lines
11 KiB
Python

"""Lightweight versioned schema migrations.
How it works:
- A fresh database is created by `Base.metadata.create_all()` (always current)
and stamped with LATEST_VERSION.
- An existing database runs every migration whose version is greater than its
stored version, in order, then is stamped.
The version lives in SQLite's PRAGMA user_version, or a one-row
`schema_version` table on Postgres (no PRAGMA there).
To change the schema: update models.py (keeps fresh DBs current) AND append a
(version, sql) pair here (upgrades existing DBs). Keep migrations idempotent
where cheap (IF NOT EXISTS etc.). Migrations up to 23 predate Postgres support
and use SQLite-only syntax — that's fine because every Postgres database
starts fresh (created by create_all, stamped LATEST, never replays them), but
migrations added from Phase 9 on must run on both dialects.
"""
from sqlalchemy import inspect, text
from sqlalchemy.engine import Engine
from .database import Base
# (version, SQL to run when upgrading past it) — append only, never reorder.
MIGRATIONS: list[tuple[int, str]] = [
# Phase 6: auto-summarization + memory bank (the `memories` table itself is
# created by create_all, which runs for existing DBs too).
(2, "ALTER TABLE adventures ADD COLUMN auto_summarize BOOLEAN NOT NULL DEFAULT 0"),
(3, "ALTER TABLE adventures ADD COLUMN memory_bank_enabled BOOLEAN NOT NULL DEFAULT 0"),
(4, "ALTER TABLE adventures ADD COLUMN memory_cursor INTEGER NOT NULL DEFAULT 0"),
(5, "ALTER TABLE adventures ADD COLUMN summary_cursor INTEGER NOT NULL DEFAULT 0"),
(6, "ALTER TABLE settings ADD COLUMN summary_model VARCHAR(200) NOT NULL DEFAULT ''"),
(7, "ALTER TABLE settings ADD COLUMN embedding_model VARCHAR(200) NOT NULL DEFAULT ''"),
(8, "ALTER TABLE settings ADD COLUMN memory_bank_capacity INTEGER NOT NULL DEFAULT 200"),
(9, "ALTER TABLE settings ADD COLUMN memory_top_k INTEGER NOT NULL DEFAULT 5"),
# Repair duplicate action indexes (player + AI actions of one turn used to
# get the same index): renumber 0..n-1 per adventure, preserving order.
# UPDATE..FROM: ranks are computed as a snapshot before any row is
# rewritten (a correlated subquery would see partially-updated rows and
# could produce duplicates again).
(10, """
UPDATE actions SET "index" = ranked.new_index
FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY adventure_id ORDER BY "index", id
) - 1 AS new_index
FROM actions
) AS ranked
WHERE ranked.id = actions.id
"""),
# Reasoning-model support: separate thinking budget + stored reasoning text.
(11, "ALTER TABLE settings ADD COLUMN reasoning_max_tokens INTEGER NOT NULL DEFAULT 0"),
(12, "ALTER TABLE actions ADD COLUMN reasoning TEXT"),
# Phase 8: optional accounts. The `users` table itself comes from
# create_all; these adopt all pre-existing rows under a "local user"
# (id=1) so a single-user install keeps working unchanged.
(13, """
INSERT INTO users (id, email, password_hash, is_guest, created_at,
demo_turns_used, demo_turns_date)
SELECT 1, NULL, NULL, 0, CURRENT_TIMESTAMP, 0, ''
WHERE NOT EXISTS (SELECT 1 FROM users)
"""),
(14, "ALTER TABLE scenarios ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE"),
(15, "UPDATE scenarios SET user_id = 1"),
(16, "ALTER TABLE scenarios ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT 0"),
(17, "ALTER TABLE scripts ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE"),
(18, "UPDATE scripts SET user_id = 1"),
(19, "ALTER TABLE adventures ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE"),
(20, "UPDATE adventures SET user_id = 1"),
(21, "ALTER TABLE settings ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE"),
(22, "UPDATE settings SET user_id = 1"),
(23, "CREATE UNIQUE INDEX IF NOT EXISTS ix_settings_user_id ON settings (user_id)"),
# Link each adventure-script copy back to its library Script so it can be
# re-synced on demand. NULL for copies made before this column existed.
(24, "ALTER TABLE adventure_scripts ADD COLUMN source_script_id INTEGER "
"REFERENCES scripts(id) ON DELETE SET NULL"),
# Per-action snapshot of the shared script_state as it was before that
# action's hooks ran, enabling undo/retry to roll state back. JSON is valid
# on both SQLite and Postgres.
(25, "ALTER TABLE actions ADD COLUMN state_before JSON"),
# Phase 12: RPG world state. `stat_schema` defines the stats/bands/rules and
# milestones for a scenario; `world_state` holds an adventure's live values;
# `world_state_before` snapshots it per action for undo/retry (mirrors
# state_before). JSON is valid on both SQLite and Postgres.
(26, "ALTER TABLE scenarios ADD COLUMN stat_schema JSON"),
(27, "ALTER TABLE adventures ADD COLUMN world_state JSON"),
(28, "ALTER TABLE actions ADD COLUMN world_state_before JSON"),
# Raise the default context budget 4096 -> 16384 (Phase 12 injects a stat
# guide + world state each turn). Only bumps rows still on the old default,
# so anyone who picked a custom value keeps it.
(29, "UPDATE settings SET context_token_budget = 16384 WHERE context_token_budget = 4096"),
# Scenario cover art — an external URL or an inline base64 data URI. TEXT
# (not VARCHAR) because a downscaled data URI runs tens of kilobytes.
(30, "ALTER TABLE scenarios ADD COLUMN image TEXT NOT NULL DEFAULT ''"),
# Emoji/glyph fallback used when `image` is empty.
(31, "ALTER TABLE scenarios ADD COLUMN icon VARCHAR(16) NOT NULL DEFAULT ''"),
# The ${Placeholder} answers given when the adventure was started. Kept so
# "Update from scenario" can re-fill re-copied text; NULL for adventures
# created before this column, which re-prompt for them on first refresh.
(32, "ALTER TABLE adventures ADD COLUMN placeholders JSON"),
# Which piece of the scenario a copied story card came from ("card:<id>" or
# "npc:<key>"), so a refresh can update/remove exactly the scenario-derived
# cards and leave player-authored ones alone. NULL = player-authored, or a
# copy predating this column (matched by name once, then adopted).
(33, "ALTER TABLE story_cards ADD COLUMN source_ref VARCHAR(64)"),
# Retry history: every attempt made for an AI turn, oldest first, so retry
# can append instead of deleting. NULL = never retried (the row is its own
# only version), which is also the correct reading for every action that
# predates this column.
(34, "ALTER TABLE actions ADD COLUMN variants JSON"),
(35, "ALTER TABLE actions ADD COLUMN variant_index INTEGER NOT NULL DEFAULT 0"),
# Egress: context_snapshot holds the whole assembled prompt (~74 KB/row) and
# was being loaded in bulk for two tiny things — the world-change chips and
# the emit block replayed into history. Lift just that slice into its own
# column so the snapshot can be deferred. Backfilled by _backfill_world_delta.
(36, "ALTER TABLE actions ADD COLUMN world_delta JSON"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
# Migrations that need a data pass after their DDL, keyed by version.
WORLD_DELTA_VERSION = 36
def _backfill_world_delta(conn) -> None:
"""Populate actions.world_delta from the existing context_snapshot.
Runs entirely server-side: the snapshots are the reason this change exists,
so pulling ~40 MB of them into Python to rewrite a slice would defeat the
point. Dialect-specific because SQLite and Postgres spell JSON access
differently, and both have to work (SQLite locally and in tests).
"""
if conn.dialect.name == "sqlite":
sql = """
UPDATE actions SET world_delta = json_object(
'delta', json_extract(context_snapshot, '$.world_state.delta'),
'applied', json_extract(context_snapshot, '$.world_state.report.applied')
)
WHERE world_delta IS NULL
AND context_snapshot IS NOT NULL
AND json_extract(context_snapshot, '$.world_state') IS NOT NULL
"""
else:
sql = """
UPDATE actions SET world_delta = jsonb_build_object(
'delta', context_snapshot::jsonb #> '{world_state,delta}',
'applied', context_snapshot::jsonb #> '{world_state,report,applied}'
)
WHERE world_delta IS NULL
AND context_snapshot IS NOT NULL
AND jsonb_exists(context_snapshot::jsonb, 'world_state')
"""
conn.execute(text(sql))
def _get_version(conn) -> int:
if conn.dialect.name == "sqlite":
return conn.execute(text("PRAGMA user_version")).scalar() or 1
conn.execute(text(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"
))
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
# A non-fresh database with no stamp can only have been created by an
# earlier create_all of this same codebase — i.e. already at LATEST.
return version if version is not None else LATEST_VERSION
def _set_version(conn, version: int) -> None:
if conn.dialect.name == "sqlite":
conn.execute(text(f"PRAGMA user_version = {version}"))
return
conn.execute(text(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"
))
if conn.execute(text("SELECT version FROM schema_version")).scalar() is None:
conn.execute(
text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": version}
)
else:
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
def bootstrap(engine: Engine) -> None:
fresh = not inspect(engine).get_table_names()
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
if fresh:
_set_version(conn, LATEST_VERSION)
return
current = _get_version(conn)
for version, sql in MIGRATIONS:
if version > current:
conn.execute(text(sql))
if version == WORLD_DELTA_VERSION:
_backfill_world_delta(conn)
current = version
_set_version(conn, current)
_encrypt_plaintext_api_keys(conn)
def _encrypt_plaintext_api_keys(conn) -> None:
"""Phase 8 data migration (can't be plain SQL): API keys saved before
encryption-at-rest existed are stored bare; wrap them in Fernet. Runs on
every start but matches nothing once all rows carry the enc: prefix."""
from . import security # deferred: security derives its key from DB_PATH setup
rows = conn.execute(text(
"SELECT id, api_key FROM settings WHERE api_key != '' AND api_key NOT LIKE 'enc:%'"
)).all()
for row_id, plain in rows:
conn.execute(
text("UPDATE settings SET api_key = :key WHERE id = :id"),
{"key": security.encrypt_secret(plain), "id": row_id},
)