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
+15 -37
View File
@@ -2,7 +2,7 @@
A retry used to rewrite the AI action in place and append the discarded attempt
to a JSON list on the same row. Seven separate bugs came from that arrangement.
The row's `text` duplicated one entry of a repeating group, `variant_count`
The row's `text` duplicated one entry of a repeating group, a second column
duplicated its length, and every reader that touched the story during a retry
had to be told to ignore the row.
@@ -25,10 +25,11 @@ that maintains either one:
avoid. The prompt therefore moves with the live flag, and a superseded sibling
keeps only its own slices.
Ordering inside a group comes from `variant_index`, which is an explicit ordinal
rather than `created_at`. Two attempts made in the same second still have to page
in the order they were made, and the migration that split the old JSON lists had
to state the order rather than reconstruct it.
Ordering inside a group comes from `id`, not from `created_at`. Two attempts made
in the same second still have to page in the order they were made, and `id`
increases with every insert. SP8 dropped `variant_index`, an explicit ordinal
that carried the same order, once a run of the suite confirmed that the two
agreed in every group.
"""
import copy
@@ -82,7 +83,7 @@ def group(db: Session, action: models.Action) -> list[models.Action]:
models.Action.depth == action.depth,
models.Action.parent_id.is_(None),
)
.order_by(models.Action.variant_index, models.Action.id)
.order_by(models.Action.id)
.all()
)
return (
@@ -91,7 +92,7 @@ def group(db: Session, action: models.Action) -> list[models.Action]:
models.Action.adventure_id == action.adventure_id,
models.Action.parent_id == action.parent_id,
)
.order_by(models.Action.variant_index, models.Action.id)
.order_by(models.Action.id)
.all()
)
@@ -190,8 +191,8 @@ def add_attempt(
"""Places `replacement` next to `previous` as the newer attempt at that turn.
The placement is done here rather than through `tree.place_action`, which
would read the depth from the legacy `index` and move the head. A sibling is
not a new turn. It is another attempt at the turn the head is already on.
moves the head. A sibling is not a new turn. It is another attempt at the
turn the head is already on.
"""
replacement.branch_id = previous.branch_id
replacement.depth = previous.depth
@@ -202,18 +203,10 @@ def add_attempt(
# away from this turn.
replacement.parent_id = previous.parent_id
replacement.live = True
# Use the end of the group rather than one past `previous`. The two match
# only when `previous` is the newest attempt. Switch a three-attempt turn
# back to attempt 1 and retry, and `previous.variant_index + 1` collides with
# attempt 2. `renumber` then breaks the tie by id and places the new attempt
# between attempts 2 and 3, so the pager walks the attempts in an order they
# were not made in. `group` returns oldest first, and `replacement` is not in
# it yet.
siblings = group(db, previous)
replacement.variant_index = 1 + max(
(s.variant_index for s in siblings if s.variant_index is not None),
default=previous.variant_index or 0,
)
# The replacement takes its place at the end of the group, because `group`
# orders by `id` and this row has no id yet. Switching a three-attempt turn
# back to attempt 1 and retrying therefore still pages 1, 2, 3, 4, which is
# the order the attempts were made in.
previous.live = False
# The replacement was assembled with a fresh snapshot, so the prompt for
# this turn is now the one it carries. The superseded attempt keeps only the
@@ -226,8 +219,7 @@ def make_live(
) -> list[models.Action]:
"""Makes `node` the attempt the story tells, and restores its outcome.
Returns the group, renumbered, so that a caller reporting on it does not read
it twice.
Returns the group, so that a caller reporting on it does not read it twice.
"""
rows = group(db, node)
previous = live_in(rows)
@@ -236,23 +228,9 @@ def make_live(
for row in rows:
row.live = row is node
restore_state(adventure, node)
renumber(rows)
return rows
def renumber(rows: list[models.Action]) -> None:
"""Refreshes the group-shape cache that the page response reads.
`variant_count` is 0 rather than 1 for a turn nobody retried, because the
pager asks whether there is anything to page through, and for a single
attempt the answer is no.
"""
count = len(rows) if len(rows) > 1 else 0
for i, row in enumerate(rows):
row.variant_index = i
row.variant_count = count
# ------------------------------------------------- the prompt, stored once
def keep_own_slices(node: models.Action) -> None: