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
+28 -28
View File
@@ -1,4 +1,4 @@
"""Typed DAO layer for task_instances and the append-only completion_events.
"""Typed DAO layer for quest_instances and the append-only completion_events.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
@@ -14,7 +14,7 @@ separate reversal event, and instance state derives from the latest
event. A package-wide guard test (bottom of the test module) keeps
mutation SQL for this table out of every other module.
Upsert semantics: ``TaskInstancesDao.upsert`` is idempotent on
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
(definition_id, due_date) — re-running materialization for a date
whose instance already exists updates nothing that matters
(child_id/due_time are refreshed only in the degenerate re-generation
@@ -82,8 +82,8 @@ def _next_day_start(date_str: str) -> str:
@dataclass(frozen=True)
class TaskInstanceRecord:
"""One row of ``task_instances``."""
class QuestInstanceRecord:
"""One row of ``quest_instances``."""
id: int
definition_id: int
@@ -121,8 +121,8 @@ EVENT_COMPLETED = "completed"
EVENT_UNCOMPLETED = "uncompleted"
def _instance_from_row(row: tuple) -> TaskInstanceRecord:
return TaskInstanceRecord(
def _instance_from_row(row: tuple) -> QuestInstanceRecord:
return QuestInstanceRecord(
id=row[0],
definition_id=row[1],
child_id=row[2],
@@ -145,8 +145,8 @@ def _event_from_row(row: tuple) -> CompletionEventRecord:
)
class TaskInstancesDao:
"""Typed async access to the ``task_instances`` table."""
class QuestInstancesDao:
"""Typed async access to the ``quest_instances`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
@@ -159,7 +159,7 @@ class TaskInstancesDao:
generated_at: str,
*,
due_time: str | None = None,
) -> TaskInstanceRecord:
) -> QuestInstanceRecord:
"""Create the dated instance for (definition_id, due_date) idempotently.
Running materialization twice for the same definition/date
@@ -207,16 +207,16 @@ class TaskInstancesDao:
f"{today!r}"
)
definition = await self._database.fetch_one(
"SELECT child_id FROM task_definitions WHERE id = ?",
"SELECT child_id FROM quest_definitions WHERE id = ?",
(definition_id,),
)
if definition is None:
raise ValueError(
f"task definition {definition_id} does not exist"
f"quest definition {definition_id} does not exist"
)
if definition[0] != child_id:
raise ValueError(
f"task definition {definition_id} is assigned to "
f"quest definition {definition_id} is assigned to "
f"child {definition[0]}, not {child_id}; instance "
"must carry the definition's assignee"
)
@@ -227,7 +227,7 @@ class TaskInstancesDao:
raise ValueError(f"child {child_id} does not exist")
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM task_instances "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND due_date = ?)",
(definition_id, due_date),
)
@@ -238,7 +238,7 @@ class TaskInstancesDao:
"event and is immutable; it cannot be regenerated"
)
await self._database.execute(
"INSERT INTO task_instances (definition_id, child_id, "
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, due_date) DO UPDATE SET "
@@ -259,30 +259,30 @@ class TaskInstancesDao:
async def get(
self, definition_id: int, due_date: str
) -> TaskInstanceRecord | None:
) -> QuestInstanceRecord | None:
"""Return the instance for (definition_id, due_date), or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances "
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE definition_id = ? AND due_date = ?",
(definition_id, due_date),
)
return _instance_from_row(row) if row is not None else None
async def get_by_id(self, instance_id: int) -> TaskInstanceRecord | None:
async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None:
"""Return the instance with ``instance_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances WHERE id = ?",
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?",
(instance_id,),
)
return _instance_from_row(row) if row is not None else None
async def list_by_child_and_date(
self, child_id: int, due_date: str
) -> list[TaskInstanceRecord]:
) -> list[QuestInstanceRecord]:
"""Return the child's instances due exactly on ``due_date``."""
_validate_date(due_date, "due_date")
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances "
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date = ? ORDER BY id",
(child_id, due_date),
)
@@ -293,7 +293,7 @@ class TaskInstancesDao:
child_id: int,
range_start: str,
range_end: str,
) -> list[TaskInstanceRecord]:
) -> list[QuestInstanceRecord]:
"""Return the child's instances with due_date in the closed range."""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
@@ -303,7 +303,7 @@ class TaskInstancesDao:
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM task_instances "
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
"ORDER BY due_date, id",
(child_id, range_start, range_end),
@@ -338,10 +338,10 @@ class TaskInstancesDao:
today = datetime.date.today().isoformat()
effective_cutoff = max(cutoff_date, today)
result = await self._database.execute(
"DELETE FROM task_instances WHERE definition_id = ? "
"DELETE FROM quest_instances WHERE definition_id = ? "
"AND due_date >= ? AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = task_instances.id"
" WHERE instance_id = quest_instances.id"
")",
(definition_id, effective_cutoff),
)
@@ -418,16 +418,16 @@ class CompletionEventsDao:
async with _connection_lock(self._database):
async with self._database.transaction():
instance = await self._database.fetch_one(
"SELECT child_id FROM task_instances WHERE id = ?",
"SELECT child_id FROM quest_instances WHERE id = ?",
(instance_id,),
)
if instance is None:
raise ValueError(
f"task instance {instance_id} does not exist"
f"quest instance {instance_id} does not exist"
)
if instance[0] != child_id:
raise ValueError(
f"task instance {instance_id} belongs to child "
f"quest instance {instance_id} belongs to child "
f"{instance[0]}, not {child_id}"
)
child = await self._database.fetch_one(
@@ -493,7 +493,7 @@ class CompletionEventsDao:
)
if due_date_scope:
predicate = (
"SELECT id FROM task_instances WHERE child_id = ? "
"SELECT id FROM quest_instances WHERE child_id = ? "
"AND due_date >= ? AND due_date <= ?"
)
parameters: list[object] = [child_id, range_start, range_end]
+23 -23
View File
@@ -1,6 +1,6 @@
"""Typed DAO layer for the schedule_rules and task_definitions tables.
"""Typed DAO layer for the schedule_rules and quest_definitions tables.
All SQL for ``schedule_rules`` and ``task_definitions`` lives in this
All SQL for ``schedule_rules`` and ``quest_definitions`` lives in this
module per the feature guardrails: callers get typed dataclasses back
and never see raw rows or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
@@ -9,16 +9,16 @@ executor and the event loop never blocks.
Guardrail mapping:
- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still
referenced by any task definition is rejected (the done-condition),
referenced by any quest definition is rejected (the done-condition),
not cascaded.
- ``task_definitions`` has no delete path at all: definitions are
- ``quest_definitions`` has no delete path at all: definitions are
deactivated, never hard-deleted, and history must survive.
- ``set_assignee`` changes future instances only — the DAO writes the
definition row and nothing else; reassignment never rewrites
existing instances or completion history (Feature 06 guardrail).
- Validation of rule well-formedness and child activeness lives partly
in the schema CHECKs (rule-type coherence) and partly in
:meth:`TaskDefinitionsDao.create` (active child, rule existence);
:meth:`QuestDefinitionsDao.create` (active child, rule existence);
no permission checks here, that is the Feature 09 gate's job.
Concurrency: ``delete_rule_if_unreferenced`` performs its reference
@@ -81,8 +81,8 @@ class ScheduleRuleRecord:
@dataclass(frozen=True)
class TaskDefinitionRecord:
"""One row of ``task_definitions``."""
class QuestDefinitionRecord:
"""One row of ``quest_definitions``."""
id: int
title: str
@@ -119,8 +119,8 @@ def _rule_from_row(row: tuple) -> ScheduleRuleRecord:
)
def _definition_from_row(row: tuple) -> TaskDefinitionRecord:
return TaskDefinitionRecord(
def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
return QuestDefinitionRecord(
id=row[0],
title=row[1],
description=row[2],
@@ -258,7 +258,7 @@ class ScheduleRulesDao:
async with _connection_lock(self._database):
async with self._database.transaction():
row = await self._database.fetch_one(
"SELECT 1 FROM task_definitions "
"SELECT 1 FROM quest_definitions "
"WHERE schedule_rule_id = ? LIMIT 1",
(rule_id,),
)
@@ -271,8 +271,8 @@ class ScheduleRulesDao:
return result.rowcount > 0
class TaskDefinitionsDao:
"""Typed async access to the ``task_definitions`` table.
class QuestDefinitionsDao:
"""Typed async access to the ``quest_definitions`` table.
No delete method exists: definitions are deactivated via
:meth:`set_active`, never hard-deleted, so completion history keeps
@@ -293,7 +293,7 @@ class TaskDefinitionsDao:
icon: str | None = None,
due_time: str | None = None,
is_active: bool = True,
) -> TaskDefinitionRecord:
) -> QuestDefinitionRecord:
"""Insert one definition and return the record as stored.
Validates the assignment target as part of the same serialized
@@ -312,7 +312,7 @@ class TaskDefinitionsDao:
child_id, schedule_rule_id
)
result = await self._database.execute(
"INSERT INTO task_definitions (title, description, "
"INSERT INTO quest_definitions (title, description, "
"icon, child_id, schedule_rule_id, due_time, "
"is_active, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
@@ -349,28 +349,28 @@ class TaskDefinitionsDao:
f"schedule rule {schedule_rule_id} does not exist"
)
async def get(self, definition_id: int) -> TaskDefinitionRecord | None:
async def get(self, definition_id: int) -> QuestDefinitionRecord | None:
"""Return the definition with ``definition_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
"WHERE id = ?",
(definition_id,),
)
return _definition_from_row(row) if row is not None else None
async def list_by_child(self, child_id: int) -> list[TaskDefinitionRecord]:
async def list_by_child(self, child_id: int) -> list[QuestDefinitionRecord]:
"""Return all definitions assigned to ``child_id``, newest first."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
"WHERE child_id = ? ORDER BY id DESC",
(child_id,),
)
return [_definition_from_row(row) for row in rows]
async def list_active(self) -> list[TaskDefinitionRecord]:
async def list_active(self) -> list[QuestDefinitionRecord]:
"""Return all active definitions, oldest first (stable order)."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
"WHERE is_active = 1 ORDER BY id"
)
return [_definition_from_row(row) for row in rows]
@@ -409,7 +409,7 @@ class TaskDefinitionsDao:
return 0
parameters.append(definition_id)
result = await self._database.execute(
f"UPDATE task_definitions SET {', '.join(assignments)} "
f"UPDATE quest_definitions SET {', '.join(assignments)} "
"WHERE id = ?",
tuple(parameters),
)
@@ -422,7 +422,7 @@ class TaskDefinitionsDao:
existing instances and completion history untouched.
"""
result = await self._database.execute(
"UPDATE task_definitions SET is_active = ? WHERE id = ?",
"UPDATE quest_definitions SET is_active = ? WHERE id = ?",
(int(is_active), definition_id),
)
return result.rowcount
@@ -442,7 +442,7 @@ class TaskDefinitionsDao:
async with self._database.transaction():
await self._validate_assignable_child(child_id)
result = await self._database.execute(
"UPDATE task_definitions SET child_id = ? "
"UPDATE quest_definitions SET child_id = ? "
"WHERE id = ?",
(child_id, definition_id),
)
+84 -13
View File
@@ -14,6 +14,22 @@ Version 0 means "the file exists but carries no NestQuest schema yet"
the runner against version 0 applies migration 1, which applies the
full v1 DDL from :mod:`.schema` and stamps version 1.
Migration 1 was rewritten pre-release (D-007): nothing was ever
shipped, so the v1 DDL now creates the ``quest_*`` tables directly
instead of the legacy ``task_*`` spellings, and no compatibility shim
exists. Dev machines that already hold a version-1 database with
``task_*`` tables are handled by migration 2, which detects the legacy
table names and ``ALTER TABLE ... RENAME TO``s them to their current
names before anything else assumes the new model. The rename step runs
inside the same transaction as its version stamp, so a crash mid-rename
rolls both back together.
Migration entries are either an ordered sequence of SQL statements or
an async callable taking the open database. Callables exist for steps
whose SQL depends on the database's current state (the legacy rename
only fires when a legacy table is actually present) and run under the
same per-migration transaction as statement lists.
Each migration's statements and its version stamp run inside ONE
transaction, and the stamp is written as DELETE + INSERT rather than
UPDATE because a version-0 database has no version row to update yet.
@@ -43,13 +59,18 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import Sequence
from collections.abc import Awaitable, Callable, Sequence
from typing import Union
from .db import NestQuestDatabase
from .schema import SCHEMA_V1_STATEMENTS
LOGGER = logging.getLogger(__name__)
#: One migration entry: either an ordered sequence of SQL statements or
#: an async callable run under the entry's migration transaction.
MigrationStep = Union[Sequence[str], Callable[[NestQuestDatabase], Awaitable[None]]]
#: Metadata table holding the applied schema version. Prefixed with the
#: integration domain so a config directory shared with other tools can
#: never collide with a foreign ``schema_version`` table.
@@ -73,10 +94,48 @@ VERSION_TABLE_DDL = f"""
#: version ``n-1`` to version ``n``.
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
#: Legacy (pre-D-007) table name -> current table name. Order matters:
#: the parent table renames first so the child's foreign key reference
#: is rewritten while its parent's new name is already in place.
LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
("task_definitions", "quest_definitions"),
("task_instances", "quest_instances"),
)
async def _rename_legacy_task_tables(database: NestQuestDatabase) -> None:
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
Dev-machines-only path (D-007): a database stamped version 1 by the
pre-rename runner holds ``task_definitions``/``task_instances``.
Each legacy name still present is renamed; fresh databases find
nothing and this step is a no-op. Modern SQLite rewrites foreign
key clauses in other tables to follow the rename, so the renamed
schema is indistinguishable from a freshly created one apart from
internal autoindex names. Runs inside the migration transaction,
so the renames and the version stamp commit or roll back together.
"""
for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES:
row = await database.fetch_one(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name = ?",
(legacy_name,),
)
if row is not None:
await database.execute(
f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"'
)
LOGGER.info(
"Renamed legacy table %s to %s", legacy_name, current_name
)
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead.
MIGRATIONS: Sequence[Sequence[str]] = [
#: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.)
MIGRATIONS: Sequence[MigrationStep] = [
MIGRATION_1_V1_DDL,
_rename_legacy_task_tables,
]
@@ -140,7 +199,7 @@ def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
async def apply_migrations(
database: NestQuestDatabase,
migrations: Sequence[Sequence[str]] = MIGRATIONS,
migrations: Sequence[MigrationStep] = MIGRATIONS,
) -> int:
"""Bring the database schema up to the latest version.
@@ -208,8 +267,14 @@ async def apply_migrations(
f"{latest}); downgrades are not supported"
)
if in_tx_version < target:
for sql in migrations[target - 1]:
await database.execute(sql)
step = migrations[target - 1]
if callable(step):
await step(database)
applied_statements = 0
else:
for sql in step:
await database.execute(sql)
applied_statements = len(step)
# INSERT keyed on the singleton row (id = 1): a
# fresh file has no row yet, and migration 1 is the
# only migration a version-0 database can run, so
@@ -225,12 +290,18 @@ async def apply_migrations(
)
applied = True
if applied:
LOGGER.info(
"NestQuest schema migrated to version %d "
"(%d statement%s)",
target,
len(migrations[target - 1]),
"s" if len(migrations[target - 1]) != 1 else "",
)
if applied_statements == 0:
LOGGER.info(
"NestQuest schema migrated to version %d",
target,
)
else:
LOGGER.info(
"NestQuest schema migrated to version %d "
"(%d statement%s)",
target,
applied_statements,
"s" if applied_statements != 1 else "",
)
return latest
+11 -11
View File
@@ -86,7 +86,7 @@ date range; a single-day override stores the same date in both columns.
``is_present`` 0/1 marks the child absent/present for the whole range;
``note`` is optional free text.
Task instances: ``task_instances`` deliberately carries NO completion
Quest instances: ``quest_instances`` deliberately carries NO completion
status column. An instance's current state derives from the latest row
in ``completion_events`` (Feature 08), so materialization (Feature 07)
can insert instances without knowing anything about completion and the
@@ -103,7 +103,7 @@ stamps one coherent time).
Uniqueness: the (definition_id, due_date) pair is declared as a
table-level ``UNIQUE`` constraint rather than a separate ``CREATE
UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique
index (``sqlite_autoindex_task_instances_1``) — the required "unique
index (``sqlite_autoindex_quest_instances_1``) — the required "unique
index prevents duplicate instances" — and the constraint form cannot be
silently dropped the way a standalone index can, so the duplicate door
stays shut. Idempotent materialization keys on exactly this pair. No
@@ -128,7 +128,7 @@ representable — missed is a derived state announced on the HA event bus
(Feature 10), never an appended event.
``child_id`` is deliberately denormalized (it is also derivable via
``task_instances``): history reporting (Feature 13) filters and rates
``quest_instances``): history reporting (Feature 13) filters and rates
per child directly against the log, and the log should stay
self-describing without joins.
@@ -219,9 +219,9 @@ SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
""",
]
SCHEMA_V1_TASK_DEFINITIONS_DDL: list[str] = [
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS task_definitions (
CREATE TABLE IF NOT EXISTS quest_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
@@ -271,11 +271,11 @@ SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
""",
]
SCHEMA_V1_TASK_INSTANCES_DDL: list[str] = [
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS task_instances (
CREATE TABLE IF NOT EXISTS quest_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES task_definitions(id),
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
due_date TEXT NOT NULL,
due_time TEXT,
@@ -289,7 +289,7 @@ SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS completion_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES task_instances(id),
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
@@ -315,10 +315,10 @@ SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_TASK_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_TASK_INSTANCES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
]