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
+9 -27
View File
@@ -14,8 +14,8 @@ Version 2 carries the tree. It holds three things version 1 could not, and each
one is required:
* The branches, because a forked adventure is two stories and a flat list holds
one. A version 1 export interleaved them by `index`, which read as a garbled
story rather than as lost data.
one. A version 1 export interleaved them by turn number, which read as a
garbled story rather than as lost data.
* `live`, because a coordinate can hold several attempts at one turn and exactly
one of them is the story.
* Both after-snapshots, because they are what a branch switch and an undo
@@ -26,22 +26,14 @@ one is required:
A bundle carries what was chosen, never what is derived. The head branch, the
fork points, the live flags, and the anchors are decisions somebody made, so
they are in the file. `lineage`, the head depth, `index`, and the variant
ordinals are all computed from those, and the import recomputes them:
they are in the file. `lineage` and the head depth are computed from those, and
the import recomputes them:
* `lineage` is a cache of `parent` plus `fork_depth`. Shipping it as well would
put a second source of truth for one fact into a file anyone can hand-edit,
and the two could then disagree without any read reporting it.
* The head depth is the tip of the head branch, which is a fact about the nodes
that arrived with it.
* `index` is the legacy column SP8 drops. Its one remaining job is to give the
next row a number nothing else holds, which is a fact about the adventure
rather than about a path, so `depth` cannot serve: two branches each have a
node at depth 4. The import allocates one index per turn instead, which keeps
`max_action_index` correct and keeps siblings sharing an index the way SP4
leaves them.
* `attempts.renumber` maintains the variant ordinals, and it is the only place
allowed to.
Every hand-editable coordinate is therefore checked before a row is written, in
`plan`, rather than repaired afterwards. An import that fails partway leaves an
@@ -98,8 +90,7 @@ def export(db: Session, adventure: models.Adventure) -> dict:
undefer(models.Action.world_state_after),
)
.order_by(
models.Action.branch_id, models.Action.depth,
models.Action.variant_index, models.Action.id,
models.Action.branch_id, models.Action.depth, models.Action.id,
)
.all()
)
@@ -168,8 +159,7 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
node = {
"branch": _local(action.branch_id, local),
# A pre-tree row's depth is the number `index` already holds.
"depth": action.depth if action.depth is not None else action.index,
"depth": action.depth,
"live": bool(action.live),
"type": action.type,
"text": action.text,
@@ -496,24 +486,17 @@ def _write_nodes(
) -> None:
"""Writes the nodes, grouped into the turns they are attempts at.
Two values are allocated here rather than read from the file. `index` is
issued once per turn, in the order the bundle lists them, so siblings share
one index and no two coordinates do. `max_action_index` needs that to keep
issuing numbers nothing holds. Exactly one attempt in each group is also made
live, because a file can name none or several, and a turn with no live node
disappears from the story.
One value is decided here rather than read from the file. Exactly one
attempt in each group is made live, because a file can name none or several,
and a turn with no live node disappears from the story.
"""
groups: dict[tuple[int, int], list[models.Action]] = {}
indices: dict[tuple[int, int], int] = {}
for spec in specs:
key = (spec["branch"], spec["depth"])
if key not in indices:
indices[key] = len(indices)
action = models.Action(
adventure_id=adventure.id,
branch_id=ids[spec["branch"]],
depth=spec["depth"],
index=indices[key],
type=spec["type"],
text=spec["text"],
reasoning=spec["reasoning"],
@@ -531,7 +514,6 @@ def _write_nodes(
live = next((row for row in rows if row.live), rows[0])
for row in rows:
row.live = row is live
attempts.renumber(rows)
def _write_memories(