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:
committed by
Parth
co-authored by
Claude Opus 5
parent
5c1bcf7305
commit
d3756abdaa
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user