Give every action a branch and a depth

Phase 14 SP1. The tree goes into the schema and nothing reads it yet: a
`branches` table, `branch_id`/`depth` on actions and memories, a head pointer
on adventures, migrations 46-52, and a server-side backfill that re-reads every
existing adventure as a tree with one branch. `depth` holds the number `index`
already held, gaps included, so no story changes — a linear story *is* a tree
with one branch, which is what makes the SP0 baseline passing unmodified the
pass condition rather than a hope.

The writer had to come with it. No migration will ever visit a row written
after it ran, so columns backfilled today and populated next subphase would
leave a hole exactly the width of one deploy, and from SP2 on a row without a
branch is a row no read can see. `app/tree.py` owns that: one module, because a
node written without a branch fails by disappearing rather than by raising.

Three things the schema itself insisted on:

- `adventures.head_branch_id` is a plain integer, not a foreign key. Pointing
  both ways makes the two tables a cycle create_all cannot order, and its
  escape hatch needs an ALTER SQLite does not have. It is a cache, and a head
  naming a branch that is gone recovers onto the root.
- `lineage` is NOT NULL, so the backfill inserts `'[]'` and fills it in a
  second pass guarded on `json_array_length(lineage) = 0` — not `= '[]'`,
  because Postgres `json` has no equality operator.
- SQLite will not drop a column a foreign key names, which is how two existing
  tests broke: they simulated an old database by rewinding the stamp while
  leaving the new columns in place. Every ADD COLUMN migration is now
  idempotent, and `tests/test_tree_migration.py` builds a genuine schema 45 by
  rebuilding three tables from frozen DDL so the real ALTERs run.

297 tests green, 14 of them new. `branches` costs 0.1 kB of a 733.5 kB turn;
page load and index are byte-identical to the recorded figures.

The deploy that ships this needs one `VACUUM FULL actions;` on the direct
endpoint afterwards — it rewrites every row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
parththakkar106
2026-08-18 19:14:07 +05:30
committed by Parth
co-authored by Claude Opus 5
parent 5c1bcf7305
commit d3756abdaa
13 changed files with 1026 additions and 38 deletions
+44
View File
@@ -231,6 +231,50 @@ mapped. Bootstrap run twice is a no-op. `tests/test_egress.py` ceilings unmoved
integers a row). **Post-deploy `VACUUM FULL actions;` is mandatory** — this rewrites
every row, which is the 144 MB lesson at the top of `STATUS.md`.
**Done, 2026-08-17** (branch `sp1-tree-schema`). **297 tests green**, the 283 from SP0
plus 14 in `test_tree_migration.py`. The baseline contract passes **unmodified**, which
was the pass condition. Five things the plan did not anticipate, all of them found by
building it:
- **The writes could not wait for SP2.** The file table above lists only `models.py` and
`migrations.py`, but a migration never visits a row written *after* it runs — so
shipping the columns without a writer would leave every turn played between the two
deploys with no branch, and from SP2 on a row with no branch is a row no read can see.
`app/tree.py` is that writer: `root_branch` / `head_branch` (get-or-create),
`place_action`, `place_memory`, `refresh_head`. One module for the same reason SP2 gets
one — a node written without a branch fails by *disappearing*, not by raising. Wired
into create/turn/import/undo/delete/memory, plus `seed_demo.py` and
`tools.stress_session` (a fixture built by `create_all` is stamped LATEST, so no
migration ever runs against it).
- **`adventures.head_branch_id` cannot be a foreign key.** `branches.adventure_id`
already points the other way, and the pair is then a cycle `create_all` refuses to
order; the fix for that is `use_alter`, which SQLite has no ALTER for. It is a plain
integer, documented as a cache, and `head_branch` recovers onto the root if it ever
names a branch that is gone.
- **`lineage` is NOT NULL, because `branches` comes from `create_all`.** The backfill
cannot insert a row and fill the lineage afterwards via a NULL marker, so it inserts
`'[]'` and guards step two on `json_array_length(lineage) = 0` — not `= '[]'`, because
Postgres `json` has no equality operator.
- **SQLite cannot drop a column a foreign key names.** So a current-schema database
cannot be rewound past `branch_id` at all, which broke the two existing tests that
simulate an old database by rewinding only the *stamp*. Fixed properly:
`migrations._column_already_there` makes every `ADD COLUMN` idempotent (the
`IF NOT EXISTS` the module docstring asks for and SQLite has no syntax for), and
`tests/schema_rewind.py` holds the inverse of the migrations that *can* be undone.
The SP1 fixture therefore builds a **genuine schema 45** by dropping the three tables
and recreating them from frozen pre-tree DDL, so the real ALTERs run — including the
one that adds a foreign key.
- **Deleting a branch takes its nodes with it**, and deleting an adventure takes its
branch — both verified, both at the database level via `ON DELETE CASCADE` on the two
`branch_id` columns. SP7's delete-a-branch needs no code of its own for the nodes.
Measured: `branches` costs **0.1 kB of a 733.5 kB turn (0 %)**, and the page load
(62.6 kB) and index (1.8 kB) shapes are byte-identical to the figures in `STATUS.md`.
One cost that is *not* free: the new table and its index add ~47 ms to every
`create_all`/`drop_all` cycle on SQLite, and the suite does one per test — 20 s → 38 s.
Test-only (DDL fsync), so no model change; if it ever matters, the fix is the test
harness, not the schema.
### SP2 — The branch clause *(reads move to lineage; still one branch)*
One module owns the clause; every action read goes through it. A forgotten clause shows