Drop the eight legacy columns SP8 left behind

Migrations 66 to 73 drop `actions.index`, `variants`, `variant_index`,
`variant_count`, `state_before`, and `world_state_before`, plus
`adventures.memory_cursor` and `summary_cursor`. `index` is a keyword in
SQLite, so migration 71 quotes it.

Nothing outside the migrations read these. `models.py`, `schemas.py`, and
`ACTION_LIST_COLUMNS` lose the same eight fields, `Adventure.actions` orders
by `id`, and `attempts.renumber`, `context.history.max_action_index`, and
`nodes.next_index` are deleted.

Two changes keep the migration replayable on a `create_all` database:

- `_split_variants_into_siblings` wrote through the live ORM table, so it
  stopped compiling once migration 66 removed five of its columns. It now
  writes through `_ACTIONS_AT_60`, a frozen `Table` with its own `MetaData`.
- Five data passes read columns these migrations drop. Each now calls
  `_has_columns` and returns early when the columns are absent.

`bootstrap` takes a `through` version so a migration test can stop at the
schema it asserts on.

555 tests pass, up from 549. Eight of the new cases assert each column is
gone after a real schema-45 database migrates all the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198qDK3gmgSo7EtQ4GTPqqK
This commit is contained in:
parththakkar106
2026-08-29 17:15:49 +05:30
committed by Parth
co-authored by Claude Opus 5
parent e0bf2b61d9
commit f1bebe18d0
41 changed files with 477 additions and 378 deletions
-1
View File
@@ -206,7 +206,6 @@ def create_adventure(
if scenario.prompt.strip():
opening = models.Action(
adventure_id=adventure.id,
index=0,
type="start",
text=fill_placeholders(scenario.prompt, values),
)
+3 -10
View File
@@ -10,22 +10,15 @@ from sqlalchemy.orm import Session, undefer
from ... import attempts, memorybank, models, tree
from ...context import cursors
from ...context import history as context_history
from ...context import lineage
def next_index(adventure: models.Adventure) -> int:
return context_history.max_action_index(adventure) + 1
def next_depth(adventure: models.Adventure) -> int:
"""Returns the depth for the next node played onto this story, one past the tip.
This is not `next_index`, which returned the same number until SP5. `index`
has to stay unique across the whole adventure, because it is the v1 bundle's
key. On a story forked at depth 6 after twenty turns, `next_index` gives the
next node depth 21 and leaves a fourteen-deep gap in the path. A depth is a
position along this one story, and the branch is what makes it unambiguous.
A depth is a position along one story, and the branch is what makes it
unambiguous. Two branches each hold a node at depth 4, and they are
alternatives rather than duplicates.
"""
return adventure.head_depth + 1
+6 -14
View File
@@ -24,13 +24,10 @@ from ...context import lineage
# row.
ACTION_LIST_COLUMNS = (
models.Action.adventure_id,
models.Action.index,
models.Action.type,
models.Action.text,
models.Action.reasoning,
models.Action.world_delta,
models.Action.variant_count,
models.Action.variant_index,
# SP9: the pager's key. If `parent_id` were deferred, every row on the page
# would cost a lazy load, which is the cost `load_only` is here to prevent.
# `branch_id` is listed for the same reason. The pager reads it to tell a
@@ -124,33 +121,28 @@ def annotate_takes(
This runs one query for the whole page rather than one per row. The pager
needs the shape of each turn's attempt group, and calling `attempts.group`
per action costs one query per message on screen. `variant_count` was cached
to avoid that cost, which is why SP8 could not drop it.
per action costs one query per message on screen.
This function reads the siblings rather than counting them. A group holds
only a few attempts, the page is bounded, and a count still needs a second
query for the ordinal. It fetches only the id and the ordering keys, so it
stays cheap even when the text is large.
query for the ordinal. It fetches only the id and the parent, so it stays
cheap even when the text is large.
"""
parents = {a.parent_id for a in actions if a.parent_id is not None}
if parents:
rows = (
db.query(
models.Action.id,
models.Action.parent_id,
models.Action.variant_index,
)
db.query(models.Action.id, models.Action.parent_id)
.filter(
models.Action.adventure_id == adventure_id,
models.Action.parent_id.in_(parents),
)
.order_by(models.Action.variant_index, models.Action.id)
.order_by(models.Action.id)
.all()
)
else:
rows = []
siblings: dict[int, list[int]] = {}
for row_id, parent_id, _ in rows:
for row_id, parent_id in rows:
siblings.setdefault(parent_id, []).append(row_id)
for action in actions:
ids = siblings.get(action.parent_id) if action.parent_id else None
+6 -10
View File
@@ -23,7 +23,7 @@ from ...sse import SSE_HEADERS, sse, turn_error
from ..settings import get_settings
from .deps import CurrentUser, current_adventure, router
from .nodes import _move_to_after, next_depth, next_index
from .nodes import _move_to_after, next_depth
from .paging import annotate_takes
@@ -275,11 +275,6 @@ async def _generate_turn(
reasoning = "".join(reasoning_chunks).strip() or None
ai_action = models.Action(
adventure_id=adventure.id,
# A sibling shares the turn's legacy index for the same reason it
# shares its depth: it is the same turn. Two rows then hold one index,
# which is safe, because `max_action_index` takes a maximum rather than
# a count, and nothing else reads the column.
index=retry_of.index if retry_of is not None else next_index(adventure),
depth=ai_depth,
type="ai",
text=text,
@@ -298,8 +293,10 @@ async def _generate_turn(
# a turn landed on top of it. See `memorybank`.
memorybank.forget_node(db, adventure, retry_of)
cursors.rewind_all(adventure, retry_of.branch_id, ai_depth - 1)
# Flush so the new attempt has an id. The session does not autoflush,
# and attempts page in id order, so a read taken before this point puts
# the newest attempt nowhere.
db.flush()
attempts.renumber(attempts.group(db, ai_action))
else:
tree.place_action(db, adventure, ai_action)
db.add(ai_action)
@@ -369,7 +366,6 @@ async def run_player_turn(
return
player_action = models.Action(
adventure_id=adventure.id,
index=next_index(adventure),
depth=next_depth(adventure),
type=payload.type,
text=modified,
@@ -384,8 +380,8 @@ async def run_player_turn(
db.refresh(player_action)
# The new action was added through its foreign key, so the loaded
# `adventure.actions` collection is stale. Without this expire,
# `build_context` and `next_index` for the AI action do not see the
# player action that was just saved.
# `build_context` for the AI action does not see the player action
# that was just saved.
db.expire(adventure, ["actions"])
yield sse({"type": "player", "action": action_json(player_action, db)})
if stop: