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
+45
-3
@@ -78,8 +78,16 @@ needed; nothing requires reading a row of anyone's story.
|
||||
|
||||
## Pick up here
|
||||
|
||||
**`plan/14-phase-story-tree.md` — the tree itself.** `plan/13` is finished. Its design
|
||||
is settled in `plan/14` and nothing about it has been built.
|
||||
**`plan/14-phase-story-tree.md`, SP2 — the branch clause.** SP0 (the regression contract
|
||||
and the `--rich` fixture) and SP1 (schema, migration, and the writer that keeps new rows
|
||||
on the tree) are done and green; nothing is deployed yet. SP2 is where the reads move
|
||||
onto `(branch_id, depth)`, and where the highest-risk line in the whole phase lives:
|
||||
`history._from_memory()` slices `adventure.actions`, which under a tree is *every
|
||||
branch's* actions rather than the path. Make that shortcut branch-aware or delete it.
|
||||
|
||||
**The schema is live in code but not on production.** When SP1 ships, the deploy needs
|
||||
one `VACUUM FULL actions;` on the direct (non-`-pooler`) endpoint afterwards — it rewrites
|
||||
every row. See the 144 MB lesson at the top of this file.
|
||||
|
||||
Two things from the egress work are worth carrying into it:
|
||||
|
||||
@@ -101,6 +109,40 @@ drive it before rewriting it.
|
||||
|
||||
---
|
||||
|
||||
## What happened on 2026-08-17, part four — the tree, SP0 and SP1
|
||||
|
||||
No behaviour change, and none intended: a linear story is a tree with one branch, so
|
||||
every adventure reads exactly as it did. **297 tests green** (259 before the phase
|
||||
started, 283 with SP0's contract, 297 with SP1's migration tests).
|
||||
|
||||
**SP0** built `tests/test_story_tree_baseline.py` — 24 tests driving the product over
|
||||
HTTP, asserting only on API responses — and `tools.stress_session --rich`, a correctness
|
||||
fixture beside the scale one. Both on branch `phase-14-story-tree`.
|
||||
|
||||
**SP1** put the tree in the schema, on branch `sp1-tree-schema`: a `branches` table,
|
||||
`branch_id`/`depth` on actions and memories, `head_branch_id`/`head_depth` on adventures,
|
||||
migrations 46–52 with a server-side backfill, and `app/tree.py` for the write side.
|
||||
Nothing reads any of it yet. Four things worth not rediscovering:
|
||||
|
||||
- **A schema needs its writer in the same subphase.** No migration will ever visit a row
|
||||
written after it ran, so columns backfilled today and populated-on-write next week leave
|
||||
a hole exactly the width of one deploy. `app/tree.py` stamps every new node, including
|
||||
the ones `seed_demo.py` and the stress fixture write — a fixture built by `create_all`
|
||||
is stamped LATEST and no migration ever touches it.
|
||||
- **SQLite will not drop a column a foreign key names.** Two tests simulated an old
|
||||
database by rewinding the *stamp* while `create_all` left the new columns in place; that
|
||||
works until the next `ADD COLUMN` lands, and then it fails on a duplicate column. Every
|
||||
`ADD COLUMN` migration is now idempotent (`migrations._column_already_there`), which is
|
||||
the `IF NOT EXISTS` SQLite has no syntax for. A true pre-migration fixture has to drop
|
||||
and rebuild the tables from frozen DDL, which is what `test_tree_migration.py` does.
|
||||
- **Two mutually-referencing tables cannot both carry the foreign key.** `create_all`
|
||||
refuses to order the cycle, and its escape hatch (`use_alter`) needs an ALTER SQLite
|
||||
does not have. `adventures.head_branch_id` is a plain integer and a documented cache.
|
||||
- **The suite went 20 s → 38 s, and it is not the app.** One new table plus one index adds
|
||||
~47 ms to a `create_all`/`drop_all` pair on SQLite (DDL fsync), and nearly every test
|
||||
does one. Measured, not guessed. Egress is unmoved: `branches` is 0.1 kB of a 733.5 kB
|
||||
turn, and the page-load and index shapes are byte-identical to the numbers above.
|
||||
|
||||
## What happened on 2026-08-17, part three
|
||||
|
||||
No behaviour change. A way to get a long adventure in front of a browser, because the
|
||||
@@ -330,7 +372,7 @@ the SQLite dev parity this codebase protects on purpose).
|
||||
|
||||
```
|
||||
cd backend
|
||||
.venv/Scripts/python.exe -m pytest tests/ # 259 tests
|
||||
.venv/Scripts/python.exe -m pytest tests/ # 297 tests (~38s)
|
||||
.venv/Scripts/python.exe -m tools.stress_session # egress report (SQLite)
|
||||
|
||||
# Same harness against a real Postgres. The target must be a THROWAWAY database
|
||||
|
||||
Reference in New Issue
Block a user