Rename task_* tables to quest_* with legacy-dev migration path (D-007)

Per design/IMPLEMENTATION-ORDER.md 1.1 and decision D-007: the domain
model takes the quest vocabulary pre-release. SCHEMA_V1 now creates
quest_definitions/quest_instances directly; DAO classes, records and
error messages follow (QuestDefinitionsDao, QuestInstancesDao). The
migration runner gains migration 2, an async-callable step that detects
surviving task_* tables on dev machines and ALTER TABLE RENAMES them
inside the migration transaction, with FK clauses rewritten by SQLite.
Migration 1's content rewrite is legal because nothing was ever
shipped; dev databases stamped version 1 ride migration 2 up to 2.
This commit is contained in:
Joshua King
2026-09-15 09:35:21 -04:00
parent 3ecca2d212
commit c988f6508b
11 changed files with 546 additions and 222 deletions
+25 -25
View File
@@ -1,4 +1,4 @@
"""Tests for dao_rules.py: typed DAO for schedule_rules and task_definitions."""
"""Tests for dao_rules.py: typed DAO for schedule_rules and quest_definitions."""
from __future__ import annotations
import asyncio
@@ -10,8 +10,8 @@ from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_rules import (
ScheduleRuleRecord,
ScheduleRulesDao,
TaskDefinitionRecord,
TaskDefinitionsDao,
QuestDefinitionRecord,
QuestDefinitionsDao,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
@@ -37,7 +37,7 @@ async def _prepare(path) -> tuple:
await database.open(path)
await apply_migrations(database)
rules = ScheduleRulesDao(database)
definitions = TaskDefinitionsDao(database)
definitions = QuestDefinitionsDao(database)
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
return database, rules, definitions, children, child
@@ -322,7 +322,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
await apply_migrations(database)
children = ChildrenDao(database)
rules = ScheduleRulesDao(database)
definitions = TaskDefinitionsDao(database)
definitions = QuestDefinitionsDao(database)
child = await children.create("Ada", NOW)
rule_a = await rules.create("daily", "2026-09-14")
@@ -335,7 +335,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
import custom_components.nestquest.dao_rules as dao_rules
original_validate = (
dao_rules.TaskDefinitionsDao._validate_assignable
dao_rules.QuestDefinitionsDao._validate_assignable
)
release = asyncio.Event()
started = asyncio.Event()
@@ -353,7 +353,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
started.set()
await release.wait()
dao_rules.TaskDefinitionsDao._validate_assignable = (
dao_rules.QuestDefinitionsDao._validate_assignable = (
_pausing_validate
)
try:
@@ -373,7 +373,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
delete_task, create_task, return_exceptions=True
)
finally:
dao_rules.TaskDefinitionsDao._validate_assignable = (
dao_rules.QuestDefinitionsDao._validate_assignable = (
original_validate
)
else:
@@ -405,7 +405,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
assert rule_row is None
else:
# Create won: the delete was rejected, both rows exist.
assert isinstance(creation, TaskDefinitionRecord), (
assert isinstance(creation, QuestDefinitionRecord), (
f"create must succeed, got {creation!r}"
)
assert rule_row is not None
@@ -425,7 +425,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
# ---------------------------------------------------------------------------
# task_definitions: create / get
# quest_definitions: create / get
# ---------------------------------------------------------------------------
@@ -441,7 +441,7 @@ def test_definition_create_returns_typed_record(tmp_path) -> None:
icon="mdi:tooth",
due_time="08:00",
)
assert isinstance(definition, TaskDefinitionRecord)
assert isinstance(definition, QuestDefinitionRecord)
assert definition.title == "Brush teeth"
assert definition.child_id == child.id
assert definition.schedule_rule_id == rule.id
@@ -497,7 +497,7 @@ def test_definition_create_unknown_rule_raises_value_error(
# ---------------------------------------------------------------------------
# task_definitions: list by child / list active
# quest_definitions: list by child / list active
# ---------------------------------------------------------------------------
@@ -539,7 +539,7 @@ def test_definition_list_active_excludes_inactive(tmp_path) -> None:
# ---------------------------------------------------------------------------
# task_definitions: update / set_active / set_assignee
# quest_definitions: update / set_active / set_assignee
# ---------------------------------------------------------------------------
@@ -673,7 +673,7 @@ def test_definition_no_delete_method_exists(tmp_path) -> None:
methods = {
name
for name, _ in inspect.getmembers(
TaskDefinitionsDao, inspect.isfunction
QuestDefinitionsDao, inspect.isfunction
)
}
assert "delete" not in methods
@@ -748,13 +748,13 @@ def test_definition_set_assignee_leaves_history_rows_alone(
# Simulate an already-generated instance (materialization owns
# this table, but its row shape proves reassignment isolation).
await database.execute(
"INSERT INTO task_instances (definition_id, child_id, "
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, generated_at) VALUES (?, ?, ?, ?)",
(definition.id, child.id, "2026-09-15", NOW),
)
await definitions.set_assignee(definition.id, other.id)
row = await database.fetch_one(
"SELECT child_id FROM task_instances WHERE definition_id = ?",
"SELECT child_id FROM quest_instances WHERE definition_id = ?",
(definition.id,),
)
assert row == (child.id,)
@@ -769,7 +769,7 @@ def test_definition_set_assignee_leaves_history_rows_alone(
def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
"""Guardrail: schedule_rules/task_definitions SQL may appear only
"""Guardrail: schedule_rules/quest_definitions SQL may appear only
in the DAO modules (and the schema DDL declarations). RECURSIVE
scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/JOIN
pattern, with exact repo-relative exclusions justified by role:
@@ -801,7 +801,7 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
}
sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
r"(schedule_rules|task_definitions)\b",
r"(schedule_rules|quest_definitions)\b",
re.IGNORECASE,
)
offenders: list[str] = []
@@ -813,16 +813,16 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
if sql_pattern.search(py.read_text()):
offenders.append(relative)
assert offenders == [], (
f"SQL touching schedule_rules/task_definitions leaked into: "
f"SQL touching schedule_rules/quest_definitions leaked into: "
f"{offenders}"
)
def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None:
"""Compensating self-scan for the test_dao_instances.py exemption:
that file may query task_instances/completion_events raw (its own
that file may query quest_instances/completion_events raw (its own
guard's scope) but must go through the DAO for schedule_rules and
task_definitions, except its single sanctioned definition-create
quest_definitions, except its single sanctioned definition-create
helper usage and guard spans.
"""
import ast
@@ -853,12 +853,12 @@ def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None:
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|task_definitions)\b",
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions)\b",
re.IGNORECASE,
)
assert sql_pattern.search(remaining) is None, (
"test_dao_instances.py must go through the DAO, not raw SQL, "
"for schedule_rules/task_definitions"
"for schedule_rules/quest_definitions"
)
@@ -866,7 +866,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None:
"""This test module must exercise the DAO, not raw SQL, for these
tables. Exceptions: the guard functions' own spans (their regexes
mention the names), and the reassignment-isolation test, which
legitimately touches task_instances — a table this DAO does not
legitimately touches quest_instances — a table this DAO does not
own — to prove the guardrail that reassignment leaves it alone.
"""
import ast
@@ -898,7 +898,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None:
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|task_definitions)\b",
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions)\b",
re.IGNORECASE,
)
assert sql_pattern.search(remaining) is None, (