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:
Joshua King
2026-09-15 09:59:29 -04:00
parent c988f6508b
commit 25f0a43e16
9 changed files with 883 additions and 261 deletions
+13 -6
View File
@@ -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,)
+123 -64
View File
@@ -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,)
+187 -68
View File
@@ -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
+35 -9
View File
@@ -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,