Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions tests/projects/test_element_id_boot_migration.py
Original file line number Diff line number Diff line change
@@ -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()
14 changes: 13 additions & 1 deletion tinyagentos/projects/canvas/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion tinyagentos/projects/task_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading