Add a useDebouncedSave hook and delete Settings.stream

Two items from Stage 2 of `plan/17-refactor.md`.

`frontend/src/hooks/useDebouncedSave.js` replaces three copies of the same
debounce. `PlotPanel` and `ScenarioEditor` held identical per-key timer maps.
`ScriptEditor` held a single shared timer, so editing two fields inside the
same 600 ms window canceled the first save. The hook gives every key its own
timer, which fixes that.

`Settings.stream` was dead state. Nothing read it and every turn streams. This
removes the column, both schema fields, and adds migration 65 to drop it. It is
item S1 in `docs/self-review.md`.

Migration 65 needs a new guard. `_column_already_gone` is the counterpart to
`_column_already_there`: `create_all` builds the current schema, which is
already missing every dropped column, so a fixture that stamps an old version
and replays would fail on a column that is not there.

Verified: 549 backend tests pass, lint and build are clean, and migration 65
runs both ways, once against a database that still has the column and once
against one that does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
This commit is contained in:
parththakkar106
2026-08-29 02:12:32 +05:30
co-authored by Claude Opus 5
parent 2c57b1ceab
commit e0bf2b61d9
8 changed files with 63 additions and 28 deletions
+25 -1
View File
@@ -297,6 +297,10 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# under.
(63, "ALTER TABLE actions ADD COLUMN parent_id INTEGER REFERENCES actions(id) ON DELETE SET NULL"),
(64, "CREATE INDEX IF NOT EXISTS ix_actions_parent ON actions (parent_id)"),
# Phase 17: `Settings.stream` was dead state. Nothing ever read it, and every
# turn streams. This is item S1 in `docs/self-review.md`. The table holds one
# row per user, so the rewrite is small and needs no VACUUM FULL.
(65, "ALTER TABLE settings DROP COLUMN stream"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
@@ -427,6 +431,25 @@ def _for_dialect(sql: str | dict[str, str], dialect: str) -> str:
# Matches the ADD COLUMN migrations in this file. Every one is written above,
# so this pattern parses only SQL this file controls.
_ADD_COLUMN = re.compile(r"^\s*ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+\"?(\w+)\"?", re.I)
_DROP_COLUMN = re.compile(r"^\s*ALTER\s+TABLE\s+(\w+)\s+DROP\s+COLUMN\s+\"?(\w+)\"?", re.I)
def _column_already_gone(conn, sql: str) -> bool:
"""Returns `True` when `sql` drops a column the table no longer has.
This is the counterpart to `_column_already_there`, for the same reason.
`create_all` builds the current schema, which is already missing every
column a migration drops. Replaying from an older stamp against a database
built that way would fail on a column that is not there.
"""
match = _DROP_COLUMN.match(sql)
if match is None:
return False
table, column = match.group(1), match.group(2)
inspector = inspect(conn)
if table not in inspector.get_table_names():
return False
return column not in {col["name"] for col in inspector.get_columns(table)}
def _column_already_there(conn, sql: str) -> bool:
@@ -946,7 +969,8 @@ def bootstrap(engine: Engine) -> None:
statement = _for_dialect(sql, conn.dialect.name)
# Skip the DDL when it has already run. The data pass below it
# still runs.
if not _column_already_there(conn, statement):
if not (_column_already_there(conn, statement)
or _column_already_gone(conn, statement)):
conn.execute(text(statement))
if version == WORLD_DELTA_VERSION:
_backfill_world_delta(conn)