From 5379911f3a81a970d81d392044e7af0274e13618 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Thu, 16 Jul 2026 18:31:57 +0100 Subject: [PATCH] fix(projects): create element_id indexes after migration, not in SCHEMA (boot-brick) The canvas and task stores put their element_id index in SCHEMA: CREATE INDEX ... ON project_canvas_elements(project_id, element_id) CREATE INDEX ... ON project_tasks(project_id, element_id) BaseStore runs SCHEMA (executescript) BEFORE _post_init, so on an existing pre-element_id database the index creation raised 'no such column: element_id' before the _post_init ALTER could add the column, crashing controller boot after an upgrade. Reproduced live: the Pi bricked on startup right after pulling this code (fresh installs were fine because SCHEMA creates the table WITH element_id, so CI never exercised the migration path). Move both indexes out of SCHEMA into _post_init, created after the ALTER. Same class of bug and fix as the registry active-handle index (#1841). Regression test seeds a pre-element_id DB and asserts both stores boot, migrate the column, and build the index. --- .../test_element_id_boot_migration.py | 70 +++++++++++++++++++ tinyagentos/projects/canvas/store.py | 14 +++- tinyagentos/projects/task_store.py | 9 ++- 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 tests/projects/test_element_id_boot_migration.py diff --git a/tests/projects/test_element_id_boot_migration.py b/tests/projects/test_element_id_boot_migration.py new file mode 100644 index 000000000..a2710cd41 --- /dev/null +++ b/tests/projects/test_element_id_boot_migration.py @@ -0,0 +1,70 @@ +"""Regression: the canvas + task stores must boot on an existing DB that +predates the element_id column. + +The element_id index used to live in each store's SCHEMA, but BaseStore runs +SCHEMA (executescript) before _post_init. On a real pre-element_id database the +`CREATE INDEX ... (project_id, element_id)` crashed with +`no such column: element_id` before the migration could add the column, which +bricked controller boot after an upgrade (seen live on the Pi). The index is +now created in _post_init after the ALTER, so a legacy DB migrates cleanly. +""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +from tinyagentos.projects.canvas.store import ProjectCanvasStore +from tinyagentos.projects.task_store import ProjectTaskStore + + +def _seed_pre_element_id_db(db: Path) -> None: + """Create the canvas + task tables WITHOUT the element_id column, as an + install created before the projects-elements work would have them.""" + c = sqlite3.connect(str(db)) + c.executescript( + """ + CREATE TABLE project_canvas_elements ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, kind TEXT, + author_kind TEXT, author_id TEXT, x REAL, y REAL, w REAL, h REAL, + rotation REAL DEFAULT 0, z_index INTEGER DEFAULT 0, payload TEXT, + created_at REAL, updated_at REAL, deleted_at REAL); + CREATE TABLE project_tasks ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_task_id TEXT, + title TEXT, body TEXT DEFAULT '', status TEXT DEFAULT 'open', + priority INTEGER DEFAULT 0, labels TEXT DEFAULT '[]', assignee_id TEXT, + created_by TEXT, created_at REAL, updated_at REAL); + """ + ) + c.commit() + c.close() + + +@pytest.mark.asyncio +async def test_stores_boot_on_pre_element_id_db(tmp_path): + db = tmp_path / "projects.db" + _seed_pre_element_id_db(db) + + # init() must NOT raise "no such column: element_id". + canvas = ProjectCanvasStore(db) + await canvas.init() + tasks = ProjectTaskStore(db) + await tasks.init() + try: + c = sqlite3.connect(str(db)) + indexes = {r[0] for r in c.execute( + "SELECT name FROM sqlite_master WHERE type='index'")} + canvas_cols = {r[1] for r in c.execute( + "PRAGMA table_info(project_canvas_elements)")} + task_cols = {r[1] for r in c.execute( + "PRAGMA table_info(project_tasks)")} + c.close() + + assert "element_id" in canvas_cols + assert "element_id" in task_cols + assert "idx_canvas_element" in indexes + assert "idx_tasks_element" in indexes + finally: + await canvas.close() + await tasks.close() diff --git a/tinyagentos/projects/canvas/store.py b/tinyagentos/projects/canvas/store.py index c627574f7..c4c5bab74 100644 --- a/tinyagentos/projects/canvas/store.py +++ b/tinyagentos/projects/canvas/store.py @@ -32,8 +32,16 @@ ); CREATE INDEX IF NOT EXISTS idx_canvas_project ON project_canvas_elements(project_id, deleted_at); CREATE INDEX IF NOT EXISTS idx_canvas_updated ON project_canvas_elements(project_id, updated_at); -CREATE INDEX IF NOT EXISTS idx_canvas_element ON project_canvas_elements(project_id, element_id); """ +# The element_id index references a column added by _post_init on the migration +# path, so it CANNOT live in SCHEMA: BaseStore runs SCHEMA (executescript) +# before _post_init, and on an existing pre-element_id table the index creation +# crashes ("no such column: element_id") before the migration can add it, +# bricking controller boot. Created in _post_init after the ALTER instead. +_CANVAS_ELEMENT_INDEX = ( + "CREATE INDEX IF NOT EXISTS idx_canvas_element " + "ON project_canvas_elements(project_id, element_id)" +) _CANVAS_JSON_FIELDS = ("payload",) # Ideas-board kinds (text, mermaid, flowchart, mindmap_edge) carry their @@ -84,6 +92,10 @@ async def _post_init(self) -> None: await self._db.commit() except Exception: pass + # Created here (not in SCHEMA) so the column exists first on the + # migration path. Idempotent (IF NOT EXISTS). + await self._db.execute(_CANVAS_ELEMENT_INDEX) + await self._db.commit() async def _publish(self, project_id: str, kind: str, payload: dict) -> None: if self._broker is not None: diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py index bfc0ffe50..3b570f7ec 100644 --- a/tinyagentos/projects/task_store.py +++ b/tinyagentos/projects/task_store.py @@ -43,7 +43,6 @@ ); CREATE INDEX IF NOT EXISTS idx_tasks_project ON project_tasks(project_id, status); CREATE INDEX IF NOT EXISTS idx_tasks_parent ON project_tasks(parent_task_id); -CREATE INDEX IF NOT EXISTS idx_tasks_element ON project_tasks(project_id, element_id); CREATE TABLE IF NOT EXISTS task_relationships ( id TEXT PRIMARY KEY, @@ -164,6 +163,14 @@ async def _post_init(self) -> None: await self._db.commit() except Exception: pass + # Created here (not in SCHEMA) so element_id exists first on the + # migration path; SCHEMA runs before _post_init and would otherwise + # crash "no such column: element_id" on a pre-element_id table. + await self._db.execute( + "CREATE INDEX IF NOT EXISTS idx_tasks_element " + "ON project_tasks(project_id, element_id)" + ) + await self._db.commit() async def create_task( self,