Multi-assignee definitions via quest_definition_assignees (D-008)
Per IMPLEMENTATION-ORDER.md 1.3: a quest_definition_assignees table (definition_id, child_id) with a composite primary key and FKs to quest_definitions and children replaces the single child_id column on quest_definitions. - schema.py: definitions DDL loses child_id; new assignees table joins SCHEMA_V1_STATEMENTS; the definitions table body is extracted to QUEST_DEFINITIONS_TABLE_SQL so the rebuild migration cannot drift from the canonical shape. - migrations.py: callable migrations now own their transaction and stamp via stamp_schema_version(database, version), because the rebuild must bracket itself with PRAGMA foreign_keys OFF/ON. New migration 3 creates the assignees table and, when a pre-D-008 quest_definitions still carries child_id, rebuilds the table via SQLite's canonical procedure (create, copy, move assignees, drop, rename), verifies PRAGMA foreign_key_check inside the transaction, and stamps. Migration 2 converted to the new callable contract. - dao_rules.py: QuestDefinitionsDao.create drops the child_id argument and applies assignee_child_ids atomically; set_assignee is replaced by add_assignee (idempotent, active-child validated under the connection lock), remove_assignee and list_assignees; list_by_child reads through the assignees table. - dao_instances.py: upsert validates the child against quest_definition_assignees instead of the definitions column. - tests: schema shape tests for the assignees table (columns, PK, FKs, duplicate/unknown constraints); DAO tests rewritten for the new assignment API; migration tests for the v2 -> v3 rebuild (rows and instances survive, child_id gone, foreign_key_check clean).
This commit is contained in:
@@ -19,7 +19,9 @@ Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
|
||||
whose instance already exists updates nothing that matters
|
||||
(child_id/due_time are refreshed only in the degenerate re-generation
|
||||
case where they changed) and never duplicates a row. The schema's
|
||||
UNIQUE(definition_id, due_date) is the last line of defense.
|
||||
UNIQUE(definition_id, due_date) is the last line of defense. The
|
||||
upsert refuses a child that is not an assignee of the definition
|
||||
(D-008 multi-assignee model).
|
||||
|
||||
``delete_future_uncompleted`` implements the Feature 06/07 rule that
|
||||
reassignment and schedule edits regenerate future instances: it
|
||||
@@ -207,18 +209,23 @@ class QuestInstancesDao:
|
||||
f"{today!r}"
|
||||
)
|
||||
definition = await self._database.fetch_one(
|
||||
"SELECT child_id FROM quest_definitions WHERE id = ?",
|
||||
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} does not exist"
|
||||
)
|
||||
if definition[0] != child_id:
|
||||
assignee = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ? AND child_id = ?",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
if assignee is None:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} is assigned to "
|
||||
f"child {definition[0]}, not {child_id}; instance "
|
||||
"must carry the definition's assignee"
|
||||
f"quest definition {definition_id} is not "
|
||||
f"assigned to child {child_id}; instance must "
|
||||
"carry an assigned child"
|
||||
)
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Typed DAO layer for the schedule_rules and quest_definitions tables.
|
||||
"""Typed DAO layer for the schedule_rules, quest_definitions and
|
||||
quest_definition_assignees tables.
|
||||
|
||||
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
|
||||
All SQL for ``schedule_rules``, ``quest_definitions`` and
|
||||
``quest_definition_assignees`` 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
|
||||
executor and the event loop never blocks.
|
||||
|
||||
@@ -13,25 +15,29 @@ Guardrail mapping:
|
||||
not cascaded.
|
||||
- ``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
|
||||
- Assignment is multi-assignee (D-008): :meth:`add_assignee` and
|
||||
:meth:`remove_assignee` change future instances only — the DAO writes
|
||||
the assignee table and nothing else; assignment edits never rewrite
|
||||
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:`QuestDefinitionsDao.create` (active child, rule existence);
|
||||
no permission checks here, that is the Feature 09 gate's job.
|
||||
:meth:`QuestDefinitionsDao.create` (rule existence) and
|
||||
:meth:`QuestDefinitionsDao.add_assignee` (active child); no
|
||||
permission checks here, that is the Feature 09 gate's job.
|
||||
|
||||
Concurrency: ``delete_rule_if_unreferenced`` performs its reference
|
||||
check and DELETE inside one transaction under the connection-scoped
|
||||
lock shared with :mod:`.dao_children`, so a definition referencing the
|
||||
rule cannot appear between check and delete on this connection.
|
||||
Concurrency: ``delete_rule_if_unreferenced`` and the assignee
|
||||
mutations perform their checks and writes inside one transaction under
|
||||
the connection-scoped lock shared with :mod:`.dao_children`, so a
|
||||
definition referencing the rule (or an assignee appearing) cannot slip
|
||||
in between check and write on this connection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .dao_children import ChildRecord, _child_from_row, _connection_lock
|
||||
from .dao_children import _CHILD_COLUMNS
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||
@@ -82,13 +88,12 @@ class ScheduleRuleRecord:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestDefinitionRecord:
|
||||
"""One row of ``quest_definitions``."""
|
||||
"""One row of ``quest_definitions`` (without its assignees)."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
description: str | None
|
||||
icon: str | None
|
||||
child_id: int
|
||||
schedule_rule_id: int
|
||||
due_time: str | None
|
||||
is_active: bool
|
||||
@@ -100,7 +105,7 @@ _RULE_COLUMNS = (
|
||||
"month, start_date, end_date"
|
||||
)
|
||||
_DEFINITION_COLUMNS = (
|
||||
"id, title, description, icon, child_id, schedule_rule_id, due_time, "
|
||||
"id, title, description, icon, schedule_rule_id, due_time, "
|
||||
"is_active, created_at"
|
||||
)
|
||||
|
||||
@@ -125,11 +130,10 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
|
||||
title=row[1],
|
||||
description=row[2],
|
||||
icon=row[3],
|
||||
child_id=row[4],
|
||||
schedule_rule_id=row[5],
|
||||
due_time=row[6],
|
||||
is_active=bool(row[7]),
|
||||
created_at=row[8],
|
||||
schedule_rule_id=row[4],
|
||||
due_time=row[5],
|
||||
is_active=bool(row[6]),
|
||||
created_at=row[7],
|
||||
)
|
||||
|
||||
|
||||
@@ -272,11 +276,14 @@ class ScheduleRulesDao:
|
||||
|
||||
|
||||
class QuestDefinitionsDao:
|
||||
"""Typed async access to the ``quest_definitions`` table.
|
||||
"""Typed async access to ``quest_definitions`` and its assignees.
|
||||
|
||||
No delete method exists: definitions are deactivated via
|
||||
:meth:`set_active`, never hard-deleted, so completion history keeps
|
||||
its references (feature guardrail).
|
||||
its references (feature guardrail). Assignment is multi-assignee
|
||||
(D-008): the definition row carries no child; assignees live in
|
||||
``quest_definition_assignees`` and are managed explicitly so the
|
||||
operations stay separately loggable and permission-gateable.
|
||||
"""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
@@ -285,7 +292,6 @@ class QuestDefinitionsDao:
|
||||
async def create(
|
||||
self,
|
||||
title: str,
|
||||
child_id: int,
|
||||
schedule_rule_id: int,
|
||||
created_at: str,
|
||||
*,
|
||||
@@ -293,53 +299,55 @@ class QuestDefinitionsDao:
|
||||
icon: str | None = None,
|
||||
due_time: str | None = None,
|
||||
is_active: bool = True,
|
||||
assignee_child_ids: list[int] | None = None,
|
||||
) -> QuestDefinitionRecord:
|
||||
"""Insert one definition and return the record as stored.
|
||||
|
||||
Validates the assignment target as part of the same serialized
|
||||
transaction as the insert: the child must exist and be active,
|
||||
and the rule must exist, AT INSERT TIME — a child deactivated
|
||||
or a rule deleted concurrently cannot slip between the checks
|
||||
and the INSERT, which would otherwise create an invalid
|
||||
assignment or surface as a raw FK IntegrityError instead of
|
||||
the documented ValueError. An INACTIVE child would pass the
|
||||
schema foreign keys, which is why the activeness check lives
|
||||
here.
|
||||
Validates the schedule rule as part of the same serialized
|
||||
transaction as the insert: the rule must exist AT INSERT TIME —
|
||||
a rule deleted concurrently cannot slip between the check and
|
||||
the INSERT. ``assignee_child_ids`` (each child must exist and
|
||||
be active) is applied in the same transaction, so a definition
|
||||
can never be observed with a stale or missing assignee set; an
|
||||
empty list or None creates an unassigned definition, which
|
||||
callers may fill via :meth:`add_assignee`.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
await self._validate_assignable(
|
||||
child_id, schedule_rule_id
|
||||
)
|
||||
await self._validate_rule_exists(schedule_rule_id)
|
||||
for child_id in assignee_child_ids or []:
|
||||
await self._validate_assignable_child(child_id)
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO quest_definitions (title, description, "
|
||||
"icon, child_id, schedule_rule_id, due_time, "
|
||||
"is_active, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"icon, schedule_rule_id, due_time, is_active, "
|
||||
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
child_id,
|
||||
schedule_rule_id,
|
||||
due_time,
|
||||
int(is_active),
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
definition = await self.get(result.lastrowid)
|
||||
definition_id = result.lastrowid
|
||||
for child_id in assignee_child_ids or []:
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_definition_assignees "
|
||||
"(definition_id, child_id) VALUES (?, ?)",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
definition = await self.get(definition_id)
|
||||
assert definition is not None
|
||||
return definition
|
||||
|
||||
async def _validate_assignable(
|
||||
self, child_id: int, schedule_rule_id: int
|
||||
) -> None:
|
||||
"""Raise ValueError unless child (active) and rule both exist.
|
||||
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
|
||||
"""Raise ValueError unless the rule exists.
|
||||
|
||||
MUST be called inside the connection lock so the verdict cannot
|
||||
go stale before the caller's INSERT commits.
|
||||
"""
|
||||
await self._validate_assignable_child(child_id)
|
||||
rule = await self._database.fetch_one(
|
||||
"SELECT 1 FROM schedule_rules WHERE id = ?",
|
||||
(schedule_rule_id,),
|
||||
@@ -359,10 +367,17 @@ class QuestDefinitionsDao:
|
||||
return _definition_from_row(row) if row is not None else None
|
||||
|
||||
async def list_by_child(self, child_id: int) -> list[QuestDefinitionRecord]:
|
||||
"""Return all definitions assigned to ``child_id``, newest first."""
|
||||
"""Return all definitions assigned to ``child_id``, newest first.
|
||||
|
||||
Assignment is read through ``quest_definition_assignees``
|
||||
(D-008): a definition appears once per child regardless of how
|
||||
many assignees share it.
|
||||
"""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
|
||||
"WHERE child_id = ? ORDER BY id DESC",
|
||||
"WHERE id IN (SELECT definition_id "
|
||||
"FROM quest_definition_assignees WHERE child_id = ?) "
|
||||
"ORDER BY id DESC",
|
||||
(child_id,),
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
@@ -391,8 +406,9 @@ class QuestDefinitionsDao:
|
||||
SQL NULL, so callers can remove optional metadata
|
||||
(description, icon, due_time). Assignment and activation are
|
||||
deliberately NOT settable here: they have their own explicit
|
||||
operations (:meth:`set_assignee`, :meth:`set_active`) so they
|
||||
stay separately loggable and permission-gateable.
|
||||
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
|
||||
:meth:`set_active`) so they stay separately loggable and
|
||||
permission-gateable.
|
||||
"""
|
||||
assignments: list[str] = []
|
||||
parameters: list[object] = []
|
||||
@@ -427,32 +443,75 @@ class QuestDefinitionsDao:
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def set_assignee(
|
||||
async def add_assignee(
|
||||
self, definition_id: int, child_id: int
|
||||
) -> int:
|
||||
"""Reassign the definition to ``child_id``; rows updated (0 if absent).
|
||||
) -> None:
|
||||
"""Assign ``child_id`` to the definition (idempotent).
|
||||
|
||||
Validates the new assignee inside the same serialized
|
||||
transaction as the UPDATE (existing and active AT ASSIGNMENT
|
||||
TIME), so a child deactivated concurrently cannot become the
|
||||
assignee. Changes future instances only: already-generated
|
||||
instances and completion history are never rewritten here.
|
||||
Validates the assignee inside the same serialized transaction
|
||||
as the INSERT (existing and active AT ASSIGNMENT TIME), so a
|
||||
child deactivated concurrently cannot become an assignee.
|
||||
Assigning an already-assigned child is a no-op, mirroring the
|
||||
composite primary key's storage-level guarantee. Changes
|
||||
future instances only: already-generated instances and
|
||||
completion history are never rewritten here.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
await self._validate_assignable_child(child_id)
|
||||
result = await self._database.execute(
|
||||
"UPDATE quest_definitions SET child_id = ? "
|
||||
"WHERE id = ?",
|
||||
(child_id, definition_id),
|
||||
definition = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
return result.rowcount
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} does not exist"
|
||||
)
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_definition_assignees "
|
||||
"(definition_id, child_id) VALUES (?, ?) "
|
||||
"ON CONFLICT (definition_id, child_id) DO NOTHING",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
|
||||
async def remove_assignee(
|
||||
self, definition_id: int, child_id: int
|
||||
) -> bool:
|
||||
"""Remove the assignee link; True when a row was removed.
|
||||
|
||||
Future materialization stops for this (definition, child) pair;
|
||||
existing instances and completion history are never touched
|
||||
here (Feature 06 guardrail).
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ? AND child_id = ?",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def list_assignees(self, definition_id: int) -> list[ChildRecord]:
|
||||
"""Return the definition's assignees as child records.
|
||||
|
||||
Ordered by the children table's stable display order
|
||||
(``sort_order``, then id) so callers render a deterministic
|
||||
roster. A definition with no assignees returns [].
|
||||
"""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_CHILD_COLUMNS} FROM children "
|
||||
"WHERE id IN (SELECT child_id "
|
||||
"FROM quest_definition_assignees WHERE definition_id = ?) "
|
||||
"ORDER BY sort_order, id",
|
||||
(definition_id,),
|
||||
)
|
||||
return [_child_from_row(row) for row in rows]
|
||||
|
||||
async def _validate_assignable_child(self, child_id: int) -> None:
|
||||
"""Raise ValueError unless the child exists and is active.
|
||||
|
||||
MUST be called inside the connection lock so the verdict cannot
|
||||
go stale before the caller's UPDATE commits.
|
||||
go stale before the caller's INSERT commits.
|
||||
"""
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT is_active FROM children WHERE id = ?", (child_id,)
|
||||
|
||||
@@ -20,15 +20,21 @@ 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.
|
||||
names before anything else assumes the new model. Migration 3 brings
|
||||
pre-D-008 databases to the multi-assignee model: it creates
|
||||
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
||||
carries a single ``child_id`` column, rebuilds the table without it,
|
||||
copying each definition's assignee across. Each callable migration
|
||||
runs inside its own transaction together with its version stamp, so a
|
||||
crash mid-step rolls the statements and the stamp 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.
|
||||
an async callable taking ``(database, target_version)``. Callables
|
||||
exist for steps whose SQL depends on the database's current state (a
|
||||
legacy rename only fires when a legacy table is present; a rebuild only
|
||||
when the old column shape is present) and own their transaction so they
|
||||
can bracket it with connection pragmas the runner's uniform transaction
|
||||
cannot express.
|
||||
|
||||
Each migration's statements and its version stamp run inside ONE
|
||||
transaction, and the stamp is written as DELETE + INSERT rather than
|
||||
@@ -63,13 +69,21 @@ from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Union
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
from .schema import SCHEMA_V1_STATEMENTS
|
||||
from .schema import (
|
||||
QUEST_DEFINITIONS_TABLE_SQL,
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
)
|
||||
|
||||
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]]]
|
||||
#: an async callable taking ``(database, target_version)`` that manages
|
||||
#: its own transaction and stamps via :func:`stamp_schema_version`.
|
||||
MigrationStep = Union[
|
||||
Sequence[str],
|
||||
Callable[[NestQuestDatabase, int], Awaitable[None]],
|
||||
]
|
||||
|
||||
#: Metadata table holding the applied schema version. Prefixed with the
|
||||
#: integration domain so a config directory shared with other tools can
|
||||
@@ -94,6 +108,10 @@ VERSION_TABLE_DDL = f"""
|
||||
#: version ``n-1`` to version ``n``.
|
||||
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
|
||||
|
||||
#: The assignees table DDL, shared with migration 1 via the schema
|
||||
#: module so a rebuilt and a freshly created table cannot drift.
|
||||
_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0]
|
||||
|
||||
#: 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.
|
||||
@@ -103,7 +121,34 @@ LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
|
||||
)
|
||||
|
||||
|
||||
async def _rename_legacy_task_tables(database: NestQuestDatabase) -> None:
|
||||
async def stamp_schema_version(
|
||||
database: NestQuestDatabase, version: int
|
||||
) -> None:
|
||||
"""Write the schema version stamp (inside the caller's transaction).
|
||||
|
||||
INSERT keyed on the singleton row (id = 1) with an upsert: a fresh
|
||||
file has no row yet, so UPDATE alone would silently match zero rows
|
||||
there and leave a migrated-but-unstamped database.
|
||||
"""
|
||||
await database.execute(
|
||||
f"INSERT INTO {VERSION_TABLE} (id, version) "
|
||||
"VALUES (1, ?) "
|
||||
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
|
||||
(version,),
|
||||
)
|
||||
|
||||
|
||||
async def _table_has_column(
|
||||
database: NestQuestDatabase, table: str, column: str
|
||||
) -> bool:
|
||||
"""Return True when ``table`` currently carries ``column``."""
|
||||
rows = await database.fetch_all(f"PRAGMA table_info({table})")
|
||||
return any(row[1] == column for row in rows)
|
||||
|
||||
|
||||
async def _rename_legacy_task_tables(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
|
||||
|
||||
Dev-machines-only path (D-007): a database stamped version 1 by the
|
||||
@@ -112,22 +157,96 @@ async def _rename_legacy_task_tables(database: NestQuestDatabase) -> None:
|
||||
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.
|
||||
internal autoindex names. Runs in its own transaction together
|
||||
with the version stamp, so a crash rolls both back.
|
||||
"""
|
||||
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
|
||||
async with database.transaction():
|
||||
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,
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
|
||||
|
||||
async def _add_definition_assignees(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring definitions to the multi-assignee model (D-008).
|
||||
|
||||
Two shapes arrive here:
|
||||
|
||||
- Fresh or renamed databases whose ``quest_definitions`` already
|
||||
matches the current canonical DDL (no ``child_id`` column): only
|
||||
the assignees table needs creating (``IF NOT EXISTS`` is a no-op
|
||||
when migration 1 already made it).
|
||||
- Databases stamped at version 2 whose ``quest_definitions`` still
|
||||
carries the single ``child_id`` column: the table is rebuilt via
|
||||
SQLite's canonical procedure — create the new table, copy rows,
|
||||
move each definition's assignee into
|
||||
``quest_definition_assignees``, drop the old table, rename — so
|
||||
``DROP COLUMN``'s foreign-key restriction never applies and no
|
||||
definition or instance row is lost.
|
||||
|
||||
The copy runs with ``foreign_keys = OFF`` because dropping the old
|
||||
table under enforcement would fire the implicit DELETE and violate
|
||||
the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check``
|
||||
runs inside the same transaction before the stamp, so a corrupt
|
||||
copy aborts the whole migration instead of persisting. The pragma
|
||||
is a connection-level no-op inside a transaction, hence the
|
||||
off/transaction/on bracket.
|
||||
"""
|
||||
legacy_child_id = await _table_has_column(
|
||||
database, "quest_definitions", "child_id"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
await database.execute(_ASSIGNEES_TABLE_DDL)
|
||||
if legacy_child_id:
|
||||
await database.execute(
|
||||
"CREATE TABLE quest_definitions_rebuilt "
|
||||
f"{QUEST_DEFINITIONS_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO quest_definitions_rebuilt "
|
||||
"(id, title, description, icon, schedule_rule_id, "
|
||||
"due_time, is_active, created_at) "
|
||||
"SELECT id, title, description, icon, "
|
||||
"schedule_rule_id, due_time, is_active, created_at "
|
||||
"FROM quest_definitions ORDER BY id"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT OR IGNORE INTO quest_definition_assignees "
|
||||
"(definition_id, child_id) "
|
||||
"SELECT id, child_id FROM quest_definitions"
|
||||
)
|
||||
await database.execute("DROP TABLE quest_definitions")
|
||||
await database.execute(
|
||||
"ALTER TABLE quest_definitions_rebuilt "
|
||||
"RENAME TO quest_definitions"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"definition rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
#: The ordered migration list. Append-only: never edit an applied
|
||||
@@ -136,6 +255,7 @@ async def _rename_legacy_task_tables(database: NestQuestDatabase) -> None:
|
||||
MIGRATIONS: Sequence[MigrationStep] = [
|
||||
MIGRATION_1_V1_DDL,
|
||||
_rename_legacy_task_tables,
|
||||
_add_definition_assignees,
|
||||
]
|
||||
|
||||
|
||||
@@ -248,17 +368,18 @@ async def apply_migrations(
|
||||
|
||||
for target in range(current + 1, latest + 1):
|
||||
applied = False
|
||||
async with database.transaction():
|
||||
# Re-read the version INSIDE the write transaction: the
|
||||
# outer read was advisory, and the wrapper serializes
|
||||
# individual statements, not the read-decide-apply
|
||||
# decision. Under the transaction's lock the version
|
||||
# cannot change between the read and the stamp, and the
|
||||
# loser of an interleaved race (a queued second caller
|
||||
# re-running apply_migrations after the winner already
|
||||
# migrated) re-reads here, sees the winner's stamp, and
|
||||
# skips instead of re-applying a non-idempotent
|
||||
# migration with a stale version.
|
||||
step = migrations[target - 1]
|
||||
if callable(step):
|
||||
# Callable migrations own their transaction: some steps
|
||||
# (table rebuilds) must run connection pragmas such as
|
||||
# ``foreign_keys = OFF`` BEFORE opening one, which the
|
||||
# runner's uniform transaction cannot express, and they
|
||||
# stamp inside their own transaction so a failure rolls
|
||||
# the statements and the version stamp back together.
|
||||
# The version is re-read here under the migration lock:
|
||||
# runners on one connection are serialized by it, so
|
||||
# the version cannot change between this read and the
|
||||
# callable's commit.
|
||||
in_tx_version = await read_schema_version(database)
|
||||
if in_tx_version > latest:
|
||||
raise RuntimeError(
|
||||
@@ -267,41 +388,39 @@ async def apply_migrations(
|
||||
f"{latest}); downgrades are not supported"
|
||||
)
|
||||
if in_tx_version < target:
|
||||
step = migrations[target - 1]
|
||||
if callable(step):
|
||||
await step(database)
|
||||
applied_statements = 0
|
||||
else:
|
||||
await step(database, target)
|
||||
applied = True
|
||||
else:
|
||||
async with database.transaction():
|
||||
# Re-read the version INSIDE the write transaction:
|
||||
# the outer read was advisory, and the wrapper
|
||||
# serializes individual statements, not the
|
||||
# read-decide-apply decision. Under the
|
||||
# transaction's lock the version cannot change
|
||||
# between the read and the stamp, and the loser of
|
||||
# an interleaved race (a queued second caller
|
||||
# re-running apply_migrations after the winner
|
||||
# already migrated) re-reads here, sees the
|
||||
# winner's stamp, and skips instead of re-applying
|
||||
# a non-idempotent migration with a stale version.
|
||||
in_tx_version = await read_schema_version(database)
|
||||
if in_tx_version > latest:
|
||||
raise RuntimeError(
|
||||
f"Database schema version {in_tx_version} is "
|
||||
f"newer than this integration understands "
|
||||
f"(latest known: {latest}); downgrades are "
|
||||
f"not supported"
|
||||
)
|
||||
if in_tx_version < target:
|
||||
for sql in step:
|
||||
await database.execute(sql)
|
||||
await stamp_schema_version(database, target)
|
||||
applied = True
|
||||
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
|
||||
# the row is seeded by the same statement batch
|
||||
# when needed. UPDATE alone would silently match
|
||||
# zero rows there.
|
||||
await database.execute(
|
||||
f"INSERT INTO {VERSION_TABLE} (id, version) "
|
||||
"VALUES (1, ?) "
|
||||
"ON CONFLICT (id) DO UPDATE SET version = "
|
||||
"excluded.version",
|
||||
(target,),
|
||||
)
|
||||
applied = True
|
||||
if applied:
|
||||
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 "",
|
||||
)
|
||||
LOGGER.info(
|
||||
"NestQuest schema migrated to version %d",
|
||||
target,
|
||||
)
|
||||
|
||||
return latest
|
||||
@@ -86,6 +86,15 @@ 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.
|
||||
|
||||
Multi-assignee definitions (D-008): ``quest_definitions`` carries no
|
||||
``child_id`` column. Assignment lives in
|
||||
``quest_definition_assignees``, one row per (definition, child) with a
|
||||
composite primary key and foreign keys to the definition and the child,
|
||||
so "brush teeth" is one definition covering three children. The
|
||||
migration runner rebuilds a pre-D-008 table into this shape and copies
|
||||
its single-assignee column across. The composite primary key makes
|
||||
duplicate assignment impossible at the storage layer.
|
||||
|
||||
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)
|
||||
@@ -219,18 +228,34 @@ SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
|
||||
""",
|
||||
]
|
||||
|
||||
#: Column body of ``quest_definitions``, shared between the v1 DDL
|
||||
#: below and the multi-assignee rebuild migration, which must recreate
|
||||
#: the table in exactly this shape (D-008: one definition, many
|
||||
#: assignees via ``quest_definition_assignees`` — no ``child_id``
|
||||
#: column on the definition itself).
|
||||
QUEST_DEFINITIONS_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
|
||||
due_time TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definitions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
CREATE TABLE IF NOT EXISTS quest_definition_assignees (
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
|
||||
due_time TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
PRIMARY KEY (definition_id, child_id)
|
||||
)
|
||||
""",
|
||||
]
|
||||
@@ -316,6 +341,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [
|
||||
*SCHEMA_V1_ADMIN_USERS_DDL,
|
||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
|
||||
Reference in New Issue
Block a user