diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c90e54e..4823457 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,19 @@ # NestQuest Release Notes +## Unreleased + +- **Storage layer extraction (task d0b691d8):** the SQLite storage layer + (schema, migrations, db, store, const, recurrence, DAOs) is bundled + under `custom_components/nestquest/core/`, with the integration's + original module names preserved as re-export shims. +- **Logger namespace change (breaking for logging configuration):** the + migrations runner now logs under + `custom_components.nestquest.core.migrations` instead of + `custom_components.nestquest.migrations`. Users who tuned the old + logger name (e.g. in `logger.yaml` or `configuration.yaml`) must + update the entry to keep that level, as the old name no longer + receives migration log output. + ## Version 0.5.0 — 2026-09-18 ### Scope diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 53e395d..0b1ef1e 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -30,7 +30,7 @@ from .const import ( ) from .frontend import async_register_frontend from .coordinator import NestQuestCoordinator -from .db import NestQuestDatabase +from .db import NestQuestDatabase, make_database from .materialize import materialize as _materialize_run from .migrations import apply_migrations from .services import async_deregister_services, async_register_services @@ -174,7 +174,7 @@ async def _async_open_database( """ try: await _preflight_existing_file(hass, db_path) - database = await NestQuestDatabase(hass).open(db_path) + database = await make_database(hass).open(db_path) except asyncio.CancelledError: raise except sqlite3.DatabaseError as err: diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index e81d78e..5b97150 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -1,119 +1,10 @@ -"""Constants for the NestQuest integration.""" +"""Re-export of :mod:`custom_components.nestquest.core.const` for the integration package. + +The canonical constants live in :mod:`custom_components.nestquest.core.const` (the storage layer +extracted in task d0b691d8); this module re-exports them so existing +``from .const import ...`` references inside the integration keep +working without touching every import line. +""" from __future__ import annotations -import logging - -DOMAIN = "nestquest" -LOGGER_NAME = "custom_components.nestquest" -LOGGER = logging.getLogger(LOGGER_NAME) - -# Platforms set up per config entry (Feature 10): the per-child day -# sensors and binary sensors. -PLATFORMS: list[str] = ["sensor", "binary_sensor"] - -# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The -# panel and admin cards listen for these; Feature 10 fires completed, -# uncompleted, and child_day_complete from the service paths, and -# Feature 11's nightly sweep fires quest_missed. -EVENT_QUEST_COMPLETED = "nestquest_quest_completed" -EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted" -EVENT_QUEST_MISSED = "nestquest_quest_missed" -EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete" - -#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 -#: plus the existing Feature 07 regenerate service). -SERVICE_COMPLETE_QUEST = "complete_quest" -SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest" -SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition" -SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition" -SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active" -SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern" -SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override" -SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override" -SERVICE_EXPORT_HISTORY_CSV = "export_history_csv" -SERVICE_MANAGE_CHILD = "manage_child" -SERVICE_REGENERATE = "regenerate" - -DOMAIN_SERVICES: tuple[str, ...] = ( - SERVICE_COMPLETE_QUEST, - SERVICE_UNCOMPLETE_QUEST, - SERVICE_CREATE_QUEST_DEFINITION, - SERVICE_UPDATE_QUEST_DEFINITION, - SERVICE_SET_QUEST_DEFINITION_ACTIVE, - SERVICE_SET_PRESENCE_PATTERN, - SERVICE_CREATE_PRESENCE_OVERRIDE, - SERVICE_DELETE_PRESENCE_OVERRIDE, - SERVICE_EXPORT_HISTORY_CSV, - SERVICE_MANAGE_CHILD, - SERVICE_REGENERATE, -) - -SQLITE_DB_FILENAME = "nestquest.db" - -# Configuration keys and default values -CONF_HORIZON_DAYS = "horizon_days" -DEFAULT_HORIZON_DAYS = 14 - -CONF_DAY_ROLLOVER_TIME = "day_rollover_time" -DEFAULT_DAY_ROLLOVER_TIME = "00:00" - -CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout" -DEFAULT_PANEL_IDLE_TIMEOUT = 300 - -# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only -# services. Stored as IDs, never usernames (feature guardrail). The -# database allowlist is authoritative; the config entry carries the same -# list as a disaster-recovery copy so a lost database can be re-seeded. -CONF_ADMIN_USER_IDS = "admin_user_ids" - -# Shared coordinator refresh interval (Feature 10), in SECONDS. Every -# entity reads one snapshot per refresh; completions push an immediate -# refresh so this poll is only the fallback cadence. -CONF_UPDATE_INTERVAL = "update_interval" -DEFAULT_UPDATE_INTERVAL = 300 -MIN_UPDATE_INTERVAL = 30 - -# Notification automations (Feature 11). The notify target is a free-text -# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the -# household fills in — never a hard-coded personal target; the times are -# strict HH:MM local; each of the four automations has an independent -# enable toggle. Per the owner-settled resolution the end-of-day report -# defaults to 20:00 — deliberately BEFORE the midnight missed sweep — -# and never defaults to the rollover time. -CONF_NOTIFY_TARGET = "notify_target" -CONF_MORNING_SUMMARY_TIME = "morning_summary_time" -DEFAULT_MORNING_SUMMARY_TIME = "08:00" -CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time" -DEFAULT_AFTERNOON_REMINDER_TIME = "15:00" -CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time" -DEFAULT_END_OF_DAY_REPORT_TIME = "20:00" -CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled" -CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled" -CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled" -CONF_CELEBRATION_ENABLED = "celebration_enabled" -DEFAULT_AUTOMATION_ENABLED = True - -# Quest windows (D-008): a definition may declare several day windows; -# each produces its own instance per day and groups the panel's Quest -# Log into three columns. The clock ranges are stored here so they stay -# configurable later. Boundaries: a window's end equals the next -# window's start; classification (Feature 07 materialisation) treats a -# due time exactly on the boundary as belonging to the LATER window, so -# 12:00 is afternoon (not morning) and 17:00 is evening (not -# afternoon). Times after 21:00 belong to no window. -WINDOW_MORNING = "morning" -WINDOW_AFTERNOON = "afternoon" -WINDOW_EVENING = "evening" - -QUEST_WINDOWS: tuple[str, ...] = ( - WINDOW_MORNING, - WINDOW_AFTERNOON, - WINDOW_EVENING, -) - -#: Inclusive "HH:MM" bounds per window, in 24-hour local time. -WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = { - WINDOW_MORNING: ("00:00", "11:59"), - WINDOW_AFTERNOON: ("12:00", "17:00"), - WINDOW_EVENING: ("17:00", "21:00"), -} +from .core.const import * # noqa: F401,F403 diff --git a/custom_components/nestquest/core/__init__.py b/custom_components/nestquest/core/__init__.py new file mode 100644 index 0000000..89e132f --- /dev/null +++ b/custom_components/nestquest/core/__init__.py @@ -0,0 +1,11 @@ +"""NestQuest storage layer. + +A self-contained, Home-Assistant-free package holding the NestQuest +SQLite storage layer: schema DDL, the versioned migration runner, the +async connection wrapper, the path resolver, the domain constants and +recurrence model, and the typed DAOs. Nothing in this package imports +``homeassistant``; the integration wires it into HA at its own layer +(constructing :class:`custom_components.nestquest.core.db.NestQuestDatabase` +with an executor adapter backed by ``hass.async_add_executor_job``). +""" +from __future__ import annotations diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py new file mode 100644 index 0000000..e81d78e --- /dev/null +++ b/custom_components/nestquest/core/const.py @@ -0,0 +1,119 @@ +"""Constants for the NestQuest integration.""" +from __future__ import annotations + +import logging + +DOMAIN = "nestquest" +LOGGER_NAME = "custom_components.nestquest" +LOGGER = logging.getLogger(LOGGER_NAME) + +# Platforms set up per config entry (Feature 10): the per-child day +# sensors and binary sensors. +PLATFORMS: list[str] = ["sensor", "binary_sensor"] + +# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The +# panel and admin cards listen for these; Feature 10 fires completed, +# uncompleted, and child_day_complete from the service paths, and +# Feature 11's nightly sweep fires quest_missed. +EVENT_QUEST_COMPLETED = "nestquest_quest_completed" +EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted" +EVENT_QUEST_MISSED = "nestquest_quest_missed" +EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete" + +#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 +#: plus the existing Feature 07 regenerate service). +SERVICE_COMPLETE_QUEST = "complete_quest" +SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest" +SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition" +SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition" +SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active" +SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern" +SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override" +SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override" +SERVICE_EXPORT_HISTORY_CSV = "export_history_csv" +SERVICE_MANAGE_CHILD = "manage_child" +SERVICE_REGENERATE = "regenerate" + +DOMAIN_SERVICES: tuple[str, ...] = ( + SERVICE_COMPLETE_QUEST, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_UPDATE_QUEST_DEFINITION, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, +) + +SQLITE_DB_FILENAME = "nestquest.db" + +# Configuration keys and default values +CONF_HORIZON_DAYS = "horizon_days" +DEFAULT_HORIZON_DAYS = 14 + +CONF_DAY_ROLLOVER_TIME = "day_rollover_time" +DEFAULT_DAY_ROLLOVER_TIME = "00:00" + +CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout" +DEFAULT_PANEL_IDLE_TIMEOUT = 300 + +# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only +# services. Stored as IDs, never usernames (feature guardrail). The +# database allowlist is authoritative; the config entry carries the same +# list as a disaster-recovery copy so a lost database can be re-seeded. +CONF_ADMIN_USER_IDS = "admin_user_ids" + +# Shared coordinator refresh interval (Feature 10), in SECONDS. Every +# entity reads one snapshot per refresh; completions push an immediate +# refresh so this poll is only the fallback cadence. +CONF_UPDATE_INTERVAL = "update_interval" +DEFAULT_UPDATE_INTERVAL = 300 +MIN_UPDATE_INTERVAL = 30 + +# Notification automations (Feature 11). The notify target is a free-text +# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the +# household fills in — never a hard-coded personal target; the times are +# strict HH:MM local; each of the four automations has an independent +# enable toggle. Per the owner-settled resolution the end-of-day report +# defaults to 20:00 — deliberately BEFORE the midnight missed sweep — +# and never defaults to the rollover time. +CONF_NOTIFY_TARGET = "notify_target" +CONF_MORNING_SUMMARY_TIME = "morning_summary_time" +DEFAULT_MORNING_SUMMARY_TIME = "08:00" +CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time" +DEFAULT_AFTERNOON_REMINDER_TIME = "15:00" +CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time" +DEFAULT_END_OF_DAY_REPORT_TIME = "20:00" +CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled" +CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled" +CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled" +CONF_CELEBRATION_ENABLED = "celebration_enabled" +DEFAULT_AUTOMATION_ENABLED = True + +# Quest windows (D-008): a definition may declare several day windows; +# each produces its own instance per day and groups the panel's Quest +# Log into three columns. The clock ranges are stored here so they stay +# configurable later. Boundaries: a window's end equals the next +# window's start; classification (Feature 07 materialisation) treats a +# due time exactly on the boundary as belonging to the LATER window, so +# 12:00 is afternoon (not morning) and 17:00 is evening (not +# afternoon). Times after 21:00 belong to no window. +WINDOW_MORNING = "morning" +WINDOW_AFTERNOON = "afternoon" +WINDOW_EVENING = "evening" + +QUEST_WINDOWS: tuple[str, ...] = ( + WINDOW_MORNING, + WINDOW_AFTERNOON, + WINDOW_EVENING, +) + +#: Inclusive "HH:MM" bounds per window, in 24-hour local time. +WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = { + WINDOW_MORNING: ("00:00", "11:59"), + WINDOW_AFTERNOON: ("12:00", "17:00"), + WINDOW_EVENING: ("17:00", "21:00"), +} diff --git a/custom_components/nestquest/core/dao_children.py b/custom_components/nestquest/core/dao_children.py new file mode 100644 index 0000000..57c42a8 --- /dev/null +++ b/custom_components/nestquest/core/dao_children.py @@ -0,0 +1,319 @@ +"""Typed DAO layer for the children and admin_users tables. + +All SQL for ``children`` and ``admin_users`` lives in this module per +the feature guardrail: 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. + +Error contract: violations surface as the underlying +:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK +failure) from the execute call; the DAO adds no wrapping exception +type. A get that matches nothing returns ``None``; a mutation that +matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`, +which each method returns where the caller may need it (reorder, and +the admin remove/set paths). +""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from .db import NestQuestDatabase + + +@dataclass(frozen=True) +class ChildRecord: + """One row of ``children``.""" + + id: int + display_name: str + colour: str | None + avatar_ref: str | None + sort_order: int + is_active: bool + created_at: str + + +@dataclass(frozen=True) +class AdminUserRecord: + """One row of ``admin_users``.""" + + ha_user_id: str + added_at: str + + +def _child_from_row(row: tuple) -> ChildRecord: + """Build a :class:`ChildRecord` from a SELECT row.""" + return ChildRecord( + id=row[0], + display_name=row[1], + colour=row[2], + avatar_ref=row[3], + sort_order=row[4], + is_active=bool(row[5]), + created_at=row[6], + ) + + +_CHILD_COLUMNS = ( + "id, display_name, colour, avatar_ref, sort_order, is_active, created_at" +) + + +#: One asyncio.Lock per (connection wrapper, running loop), shared by +#: every ChildrenDao instance over the same database: the wrapper +#: rejects a second concurrent transaction with RuntimeError, so ALL +#: transactional DAO operations on one connection must queue here, not +#: per-instance. Keyed like the migration runner's lock; entries are +#: never evicted (a config entry holds one wrapper per process). +_CONNECTION_LOCKS: dict[tuple[int, int], asyncio.Lock] = {} + + +def _connection_lock(database) -> asyncio.Lock: + """Return the shared transaction-serializing lock for ``database``.""" + loop = asyncio.get_running_loop() + key = (id(database), id(loop)) + lock = _CONNECTION_LOCKS.get(key) + if lock is None: + lock = asyncio.Lock() + _CONNECTION_LOCKS[key] = lock + return lock + + +class ChildrenDao: + """Typed async data access for the ``children`` table. + + Deletion is deliberately absent: children are deactivated, never + deleted, because completion history references them (feature + guardrail). + """ + + def __init__(self, database) -> None: + self._database = database + + async def create( + self, + display_name: str, + created_at: str, + *, + colour: str | None = None, + avatar_ref: str | None = None, + sort_order: int = 0, + is_active: bool = True, + ) -> ChildRecord: + """Insert one child and return the record as stored.""" + result = await self._database.execute( + "INSERT INTO children (display_name, colour, avatar_ref, " + "sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ( + display_name, + colour, + avatar_ref, + sort_order, + int(is_active), + created_at, + ), + ) + child = await self.get(result.lastrowid) + assert child is not None + return child + + async def get(self, child_id: int) -> ChildRecord | None: + """Return the child with ``child_id``, or None.""" + row = await self._database.fetch_one( + f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?", + (child_id,), + ) + return _child_from_row(row) if row is not None else None + + async def list_active(self) -> list[ChildRecord]: + """Return active children ordered by sort_order, then id.""" + rows = await self._database.fetch_all( + f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 " + "ORDER BY sort_order, id" + ) + return [_child_from_row(row) for row in rows] + + async def list_all(self) -> list[ChildRecord]: + """Return all children ordered by sort_order, then id.""" + rows = await self._database.fetch_all( + f"SELECT {_CHILD_COLUMNS} FROM children " + "ORDER BY sort_order, id" + ) + return [record for record in map(_child_from_row, rows)] + + async def update( + self, + child_id: int, + *, + display_name: str | None = None, + colour: str | None = None, + avatar_ref: str | None = None, + sort_order: int | None = None, + ) -> int: + """Update the given fields; returns rows updated (0 if absent). + + Only non-None arguments are written, so a caller cannot + accidentally blank a column by omitting it. + """ + assignments: list[str] = [] + parameters: list[object] = [] + if display_name is not None: + assignments.append("display_name = ?") + parameters.append(display_name) + if colour is not None: + assignments.append("colour = ?") + parameters.append(colour) + if avatar_ref is not None: + assignments.append("avatar_ref = ?") + parameters.append(avatar_ref) + if sort_order is not None: + assignments.append("sort_order = ?") + parameters.append(sort_order) + if not assignments: + return 0 + parameters.append(child_id) + result = await self._database.execute( + f"UPDATE children SET {', '.join(assignments)} WHERE id = ?", + tuple(parameters), + ) + return result.rowcount + + async def set_active(self, child_id: int, is_active: bool) -> int: + """Set the child's active flag; returns rows updated (0 if absent).""" + result = await self._database.execute( + "UPDATE children SET is_active = ? WHERE id = ?", + (int(is_active), child_id), + ) + return result.rowcount + + async def reorder(self, ordered_ids: list[int]) -> None: + """Persist a new sort order: ``ordered_ids[i]`` gets sort_order i. + + The list must be a COMPLETE permutation of the children table: + every existing child id appears exactly once. Partial lists + (missing an existing child) and unknown ids (naming a child + that does not exist) are rejected with ValueError BEFORE any + write, leaving the current order untouched — a silent partial + reorder would leave unlisted children with stale positions and + hide a caller bug. Duplicate ids are rejected up front: they + cannot all hold distinct positions, and silently assigning the + last occurrence's index would hide a caller bug. + + Runs inside one transaction; positions are staged at offset + -len (all negative, so any permutation of the same ids never + collides mid-reorder under the wrapper's single-connection + serialization), then finalized 0..n-1. + + Concurrent reorder() calls on one connection are serialized by + a connection-scoped asyncio lock (shared across every ChildrenDao + over the same wrapper, however many instances): the wrapper's + transaction() rejects a second concurrent transaction with + RuntimeError, so racing callers would crash instead of + queueing. The lock makes the second call wait, then run + against the first call's final state — which also keeps the + completeness check honest: the re-read of the table happens + inside the same lock and transaction as the writes. + """ + if len(set(ordered_ids)) != len(ordered_ids): + raise ValueError( + "reorder() received duplicate child ids; every id must " + "appear at most once" + ) + async with _connection_lock(self._database): + async with self._database.transaction(): + rows = await self._database.fetch_all( + f"SELECT id FROM children" + ) + existing = {row[0] for row in rows} + listed = set(ordered_ids) + missing = sorted(existing - listed) + unknown = sorted(listed - existing) + if missing or unknown: + details = [] + if missing: + details.append( + f"missing child ids {missing} (a partial " + "reorder would leave them with stale " + "positions)" + ) + if unknown: + details.append( + f"unknown child ids {unknown}" + ) + raise ValueError( + "reorder() must list every child exactly once; " + + "; ".join(details) + ) + offset = len(ordered_ids) + await self._database.execute_many( + "UPDATE children SET sort_order = ? WHERE id = ?", + [ + (-(offset + index), child_id) + for index, child_id in enumerate(ordered_ids) + ], + ) + await self._database.execute_many( + "UPDATE children SET sort_order = ? WHERE id = ?", + [ + (index, child_id) + for index, child_id in enumerate(ordered_ids) + ], + ) + + +class AdminUsersDao: + """Typed async access to the ``admin_users`` allowlist. + + The allowlist stores HA user IDs, never usernames (feature + guardrail); an empty allowlist means no admin, enforced by callers + resolving through :meth:`exists`. + """ + + def __init__(self, database) -> None: + self._database = database + + async def add(self, ha_user_id: str, added_at: str) -> bool: + """Add a user to the allowlist; True if inserted, False if present. + + Idempotent under concurrency: a single INSERT ... ON CONFLICT DO + NOTHING decides and inserts in one statement (the wrapper + serializes statements, not check-then-insert sequences, so a + SELECT/INSERT pair would let two concurrent adds both see no + row and the second one raise IntegrityError). rowcount reports + whether the insert actually happened. + """ + result = await self._database.execute( + "INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?) " + "ON CONFLICT (ha_user_id) DO NOTHING", + (ha_user_id, added_at), + ) + return result.rowcount > 0 + + async def remove(self, ha_user_id: str) -> bool: + """Remove a user from the allowlist; True if a row was removed.""" + result = await self._database.execute( + "DELETE FROM admin_users WHERE ha_user_id = ?", + (ha_user_id,), + ) + return result.rowcount > 0 + + async def list(self) -> list[AdminUserRecord]: + """Return every allowlisted user, oldest first.""" + rows = await self._database.fetch_all( + "SELECT ha_user_id, added_at FROM admin_users " + "ORDER BY added_at, ha_user_id" + ) + return [ + AdminUserRecord(ha_user_id=row[0], added_at=row[1]) + for row in rows + ] + + async def exists(self, ha_user_id: str) -> bool: + """Return True if ``ha_user_id`` is allowlisted.""" + row = await self._database.fetch_one( + "SELECT 1 FROM admin_users WHERE ha_user_id = ?", + (ha_user_id,), + ) + return row is not None \ No newline at end of file diff --git a/custom_components/nestquest/core/dao_instances.py b/custom_components/nestquest/core/dao_instances.py new file mode 100644 index 0000000..3d30b61 --- /dev/null +++ b/custom_components/nestquest/core/dao_instances.py @@ -0,0 +1,832 @@ +"""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 +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. + +Append-only policy (Feature 08, D-005): ``completion_events`` is +strictly append-only. This module is where that property is enforced +mechanically — the completion-events DAO below exposes NO update or +delete method at all; completions append, un-completions append a +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: ``QuestInstancesDao.upsert`` is idempotent on +(definition_id, child_id, due_date, window) — re-running +materialization for a tuple whose instance already exists refreshes +only the generation-time snapshot columns and never duplicates a row. +The schema's UNIQUE(definition_id, child_id, due_date, window) is the +last line of defense. The upsert refuses a child that is not an +assignee of the definition and a window outside ``const.QUEST_WINDOWS`` +(D-008 multi-assignee, multi-window model). + +``delete_future_uncompleted`` implements the Feature 06/07 rule that +reassignment and schedule edits regenerate future instances: it +removes only instances at or after the cutoff that have NO completion +event, inside one transaction, so an instance completed mid-call can +never be deleted. Instances in the past are never touched (Feature 07 +guardrail: never delete history), and no method here deletes a single +instance that has a completion event. +""" +from __future__ import annotations + +import datetime +from dataclasses import dataclass + +from .dao_children import _connection_lock +from .dao_rules import _validate_date, _validate_window +from .db import NestQuestDatabase + +#: Timestamp policy for completion_events.occurred_at: strict UTC +#: ISO-8601 with explicit '+00:00' offset (module timestamp policy). +#: One accepted shape bounds lexicographic range queries correctly, so +#: the occurred-at scope does not guess at timestamp spellings. +_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" + + +def _today() -> datetime.date: + """Return the current local calendar date (the single date source). + + Every "today" read in this module goes through this one function so + the delete clamp and the materialize walk's no-past guard read the + same clock, and a caller or test can pin/roll that clock + deterministically (e.g. a queued-across-midnight regression). Pure + clock read — no business rule lives here. + """ + return datetime.date.today() + + +def _resolve_today(today: datetime.date | None) -> datetime.date: + """Return the caller-resolved "today", or the host date when absent. + + The no-past guard and the delete cutoffs must compare due dates + against the SAME calendar date the materialization horizon was built + from. Home Assistant's configured time zone can lag the host clock + around midnight, so when the caller has already resolved the local + date (``today``) that anchor is authoritative; ``None`` falls back to + the host clock (:func:`_today`), which is exactly the previous + behavior for callers with no HA-local date. + """ + return _today() if today is None else today + + +def _reject_past(due_date: str, today_iso: str) -> None: + """Raise unless ``due_date`` is on or after ``today_iso``. + + The single no-past rule shared by :meth:`QuestInstancesDao.upsert` + and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never + generated in the past, measured against the resolved "today". + """ + if due_date < today_iso: + raise ValueError( + f"instances are never generated in the past: due_date " + f"{due_date!r} is before today {today_iso!r}" + ) + + +def _validate_utc_timestamp(value: str, field: str) -> None: + """Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp. + + Same strictness policy as _validate_date: parse with the exact + format, then round-trip the parsed value against the input so + non-padded or re-ordered shapes are rejected. The round-trip + strips the UTC offset (isoformat() omits it when the offset is + zero), so the suffix is appended before comparing. + """ + try: + parsed = datetime.datetime.strptime( + value, _UTC_TIMESTAMP_FORMAT + ) + except (TypeError, ValueError): + raise ValueError( + f"{field} must be a UTC ISO-8601 timestamp " + f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}" + ) from None + if parsed.isoformat() + "+00:00" != value: + raise ValueError( + f"{field} must be a strict UTC timestamp " + f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}" + ) + + +def _next_day_start(date_str: str) -> str: + """Return the UTC timestamp one second past ``date_str``'s last second. + + Gives range queries an exact half-open upper bound without + fractional-second guessing. + """ + day = datetime.date.fromisoformat(date_str) + next_day = day + datetime.timedelta(days=1) + return f"{next_day.isoformat()}T00:00:00+00:00" + + +@dataclass(frozen=True) +class QuestInstanceRecord: + """One row of ``quest_instances``.""" + + id: int + definition_id: int + child_id: int + window: str + due_date: str + due_time: str | None + generated_at: str + + +@dataclass(frozen=True) +class CompletionEventRecord: + """One row of ``completion_events`` (append-only table).""" + + id: int + instance_id: int + child_id: int + event_type: str + actor_source: str + actor_user_id: str | None + actor_child_id: int | None + occurred_at: str + was_on_time: bool | None + + +_INSTANCE_COLUMNS = ( + "id, definition_id, child_id, window, due_date, due_time, generated_at" +) +_EVENT_COLUMNS = ( + "id, instance_id, child_id, event_type, actor_source, actor_user_id, " + "actor_child_id, occurred_at, was_on_time" +) + +#: The only event types the append path accepts; the schema CHECK is +#: the backstop, this is the DAO's typed front door. +EVENT_COMPLETED = "completed" +EVENT_UNCOMPLETED = "uncompleted" + + +def _instance_from_row(row: tuple) -> QuestInstanceRecord: + return QuestInstanceRecord( + id=row[0], + definition_id=row[1], + child_id=row[2], + window=row[3], + due_date=row[4], + due_time=row[5], + generated_at=row[6], + ) + + +def _event_from_row(row: tuple) -> CompletionEventRecord: + return CompletionEventRecord( + id=row[0], + instance_id=row[1], + child_id=row[2], + event_type=row[3], + actor_source=row[4], + actor_user_id=row[5], + actor_child_id=row[6], + occurred_at=row[7], + was_on_time=None if row[8] is None else bool(row[8]), + ) + + +class QuestInstancesDao: + """Typed async access to the ``quest_instances`` table.""" + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def upsert( + self, + definition_id: int, + child_id: int, + due_date: str, + generated_at: str, + *, + window: str, + due_time: str | None = None, + today: datetime.date | None = None, + ) -> QuestInstanceRecord: + """Create the dated instance for the widened key idempotently. + + The instance key is (definition_id, child_id, due_date, window) + (D-008): running materialization twice for the same tuple + yields ONE row — the ON CONFLICT path refreshes the + generation-time snapshot columns (due_time) and regenerates the + stamp, but can never create a duplicate. The schema's + UNIQUE(definition_id, child_id, due_date, window) backs this + up, so the twice-daily case (two windows, one child, one date) + yields two rows and the shared case (three assignees, one + window) yields three. + + Guardrails enforced here: + + - ``due_date`` must be TODAY or later: instances are never + generated in the past (Feature 07). The caller's + materialization horizon is future-dated by definition. + ``today`` optionally pins the resolved HA-local date the + guard compares against (falling back to the host clock when + omitted) so a household time zone behind the host around + midnight is not misread as "past". + - ``window`` must be one of ``const.QUEST_WINDOWS``. + - An existing instance WITH a completion event is IMMUTABLE: + the conflict path must not rewrite its snapshot columns + (Feature 07/08). The upsert refuses with ValueError in that + case — silently no-oping would mask a materialization bug. + - The child must be an assignee of the definition (D-008 + multi-assignee model). + + The definition must exist (validated atomically under the + connection lock so a concurrent definition deactivation or + child deletion cannot slip between check and insert). + """ + _validate_date(due_date, "due_date") + _validate_window(window) + # Fail fast on obviously-past dates BEFORE queuing on the + # connection lock (a nice early error); the AUTHORITATIVE + # check runs after the lock is acquired, because a caller can + # wait on the lock across midnight — a date that was "today" + # at call time may be "yesterday" by the time the INSERT runs. + # A caller-supplied ``today`` (HA-local) is used for BOTH + # checks and never re-read, since it is already the anchor the + # batch runs against. + today_date = _resolve_today(today) + _reject_past(due_date, today_date.isoformat()) + async with _connection_lock(self._database): + async with self._database.transaction(): + # Re-read "today" under the lock: this is the date the + # insert actually executes on, so the no-past rule + # holds across midnight rollovers. A pinned HA-local + # anchor is exempt from the re-read (see above). + if today is None: + today_date = _today() + _reject_past(due_date, today_date.isoformat()) + definition = await self._database.fetch_one( + "SELECT 1 FROM quest_definitions WHERE id = ?", + (definition_id,), + ) + if definition is None: + raise ValueError( + f"quest definition {definition_id} does not exist" + ) + 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 not " + f"assigned to child {child_id}; instance must " + "carry an assigned child" + ) + existing = await self._database.fetch_one( + "SELECT 1 FROM completion_events " + "WHERE instance_id = (SELECT id FROM quest_instances " + "WHERE definition_id = ? AND child_id = ? " + "AND due_date = ? AND window = ?)", + (definition_id, child_id, due_date, window), + ) + if existing is not None: + raise ValueError( + f"instance for (definition {definition_id}, " + f"child {child_id}, due_date {due_date!r}, " + f"window {window!r}) already has a completion " + "event and is immutable; it cannot be regenerated" + ) + await self._database.execute( + "INSERT INTO quest_instances (definition_id, child_id, " + "window, due_date, due_time, generated_at) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT (definition_id, child_id, due_date, " + "window) DO UPDATE SET " + "due_time = excluded.due_time, " + "generated_at = excluded.generated_at", + ( + definition_id, + child_id, + window, + due_date, + due_time, + generated_at, + ), + ) + instance = await self.get( + definition_id, child_id, due_date, window + ) + assert instance is not None + return instance + + async def upsert_if_valid( + self, + definition_id: int, + child_id: int, + due_date: str, + generated_at: str, + *, + window: str, + due_time: str | None = None, + today: datetime.date | None = None, + ) -> QuestInstanceRecord | None: + """Insert one dated instance iff its tuple is still materializable. + + This is the materialization walk's single write path. Unlike the + generic :meth:`upsert`, which validates only existence and + assignment, this method re-validates EVERY materialization + precondition in the SAME connection-lock-held transaction as the + INSERT — closing the check-then-insert gap a concurrent config + write could otherwise slip through: + + - the definition exists AND is active (a definition deactivated + after the input snapshot is skipped, never written); + - the child exists AND is active (a deactivated child is skipped); + - the (definition, child) assignment link still exists (a removed + assignment is skipped); + - the window is still declared on the definition (a removed window + is skipped); + - the window's live ``due_time`` still EQUALS the caller-supplied + ``due_time`` (NULL-safe: NULL == NULL counts as equal; a window + with no due time matches a snapshot with no due time). A + window-time edit landed after the input snapshot makes the + caller's ``due_time`` stale, and writing it would overwrite the + newer due_time a concurrent regeneration already upserted — so + the tuple is skipped instead of written. + + The due_time check closes the one check-then-insert gap this + method previously left open: it re-validated every OTHER + precondition but then wrote the snapshot's ``due_time`` + unconditionally, so a stale materialization could clobber a + newer, concurrently-regenerated due_time. + + Returns the stored instance, or ``None`` when a precondition + failed — the tuple no longer materializable, which the walk SKIPS + rather than raising, so the rest of the batch is unaffected. The + no-past rule (instances are never generated in the past) is + preserved from :meth:`upsert` and still raises ValueError; ``today`` + optionally pins the HA-local anchor it compares against. An + instance that ALREADY has a completion event is also skipped + (returns ``None``) rather than raised: it is immutable, so the + walk leaves it untouched instead of failing the batch — the + generic :meth:`upsert` keeps its hard refusal for the + delete/regenerate path. + """ + _validate_date(due_date, "due_date") + _validate_window(window) + # Fail fast on obviously-past dates before queuing on the lock; + # the authoritative check re-runs under the lock (see upsert). + today_date = _resolve_today(today) + _reject_past(due_date, today_date.isoformat()) + async with _connection_lock(self._database): + async with self._database.transaction(): + if today is None: + today_date = _today() + _reject_past(due_date, today_date.isoformat()) + still_valid = await self._database.fetch_one( + "SELECT 1 FROM quest_definitions d " + "JOIN quest_definition_assignees a " + "ON a.definition_id = d.id " + "JOIN children c ON c.id = a.child_id " + "JOIN quest_definition_windows w " + "ON w.definition_id = d.id " + "WHERE d.id = ? AND a.child_id = ? AND w.window = ? " + "AND d.is_active = 1 AND c.is_active = 1 " + "AND w.due_time IS ? LIMIT 1", + (definition_id, child_id, window, due_time), + ) + if still_valid is None: + # A config change invalidated the tuple since the + # snapshot: skip it rather than abort the batch. + return None + existing = await self._database.fetch_one( + "SELECT 1 FROM completion_events " + "WHERE instance_id = (SELECT id FROM quest_instances " + "WHERE definition_id = ? AND child_id = ? " + "AND due_date = ? AND window = ?)", + (definition_id, child_id, due_date, window), + ) + if existing is not None: + # The instance is already completed and therefore + # immutable: the walk SKIPS it (no-op) rather than + # rewriting its snapshot columns or failing the + # batch. This is the materialization walk's skip, + # distinct from the generic :meth:`upsert` refusal. + return None + await self._database.execute( + "INSERT INTO quest_instances (definition_id, child_id, " + "window, due_date, due_time, generated_at) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT (definition_id, child_id, due_date, " + "window) DO UPDATE SET " + "due_time = excluded.due_time, " + "generated_at = excluded.generated_at", + ( + definition_id, + child_id, + window, + due_date, + due_time, + generated_at, + ), + ) + instance = await self.get( + definition_id, child_id, due_date, window + ) + assert instance is not None + return instance + + async def get( + self, + definition_id: int, + child_id: int, + due_date: str, + window: str, + ) -> QuestInstanceRecord | None: + """Return the instance for the widened key tuple, or None.""" + row = await self._database.fetch_one( + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " + "WHERE definition_id = ? AND child_id = ? AND due_date = ? " + "AND window = ?", + (definition_id, child_id, due_date, window), + ) + return _instance_from_row(row) if row is not None else 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 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[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 quest_instances " + "WHERE child_id = ? AND due_date = ? ORDER BY id", + (child_id, due_date), + ) + return [_instance_from_row(row) for row in rows] + + async def list_by_date_range( + self, + child_id: int, + range_start: str, + range_end: str, + ) -> 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") + if range_end < range_start: + raise ValueError( + "range_end must be on or after range_start, got " + f"{range_end!r} < {range_start!r}" + ) + rows = await self._database.fetch_all( + 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), + ) + return [_instance_from_row(row) for row in rows] + + async def list_missed_unswept( + self, today_iso: str, watermark: str | None + ) -> list[QuestInstanceRecord]: + """Return past-due open instances the sweep has not announced. + + Feature 11's missed-sweep query: ``due_date`` strictly before + ``today_iso`` (past due), at or after ``watermark`` when one is + recorded, and with NO completion event ever (the Feature 08 + append-only contract: an instance that was ever completed is + never reported missed, even when later reversed — history keeps + both rows and the sweep never mutates any). The watermark is + the HA-local date the sweep last ran, and the window is + ``[watermark, today)``: the nightly run entering day D+1 sweeps + exactly day D's instances, so every day's quests are announced + once — at the midnight after their due date — and a same-night + rerun (watermark == today) sees an empty window. Read-only; + ordered deterministically by (due_date, id). + """ + _validate_date(today_iso, "today_iso") + if watermark is not None: + _validate_date(watermark, "watermark") + sql = ( + f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " + "WHERE due_date < ? " + "AND NOT EXISTS (" + " SELECT 1 FROM completion_events " + " WHERE instance_id = quest_instances.id" + ")" + ) + parameters: tuple = (today_iso,) + if watermark is not None: + sql += " AND due_date >= ?" + parameters += (watermark,) + sql += " ORDER BY due_date, id" + rows = await self._database.fetch_all(sql, parameters) + return [_instance_from_row(row) for row in rows] + + async def delete_future_uncompleted( + self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None + ) -> int: + """Delete the definition's open instances at/after cutoff. + + Implements regeneration after a definition edit or reassignment + (Features 06/07): only instances at or after the cutoff with NO + completion event are removed, inside one transaction — an + instance that gains a completion event mid-flight cannot be + deleted. The cutoff cannot be backdated below today: "future" + is measured against TODAY (materialization regenerates the + rolling horizon from now), so a caller cannot use this to + rewrite past open instances either — the day-rollover missed + sweep (Feature 11) handles past-due instances instead. ``today`` + optionally pins the HA-local anchor the clamp compares against + (falling back to the host clock when omitted). Any instance with + a completion event is never touched, regardless of its date. + Returns the number deleted. + """ + _validate_date(cutoff_date, "cutoff_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + # "today" is read under the lock: the DELETE actually + # executes on this date, so a caller queued across + # midnight cannot delete instances that became past + # while it waited (backdating is clamped to the + # execution date, not the call date). + today_date = _resolve_today(today) + effective_cutoff = max(cutoff_date, today_date.isoformat()) + result = await self._database.execute( + "DELETE FROM quest_instances WHERE definition_id = ? " + "AND due_date >= ? AND NOT EXISTS (" + " SELECT 1 FROM completion_events " + " WHERE instance_id = quest_instances.id" + ")", + (definition_id, effective_cutoff), + ) + return result.rowcount + + async def delete_future_uncompleted_for_child( + self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None + ) -> tuple[int, str]: + """Delete the child's open instances at/after cutoff, across every + definition, returning ``(deleted, effective_cutoff)``. + + Child-scoped counterpart to :meth:`delete_future_uncompleted`, + used when a child's presence changes (Feature 09): only instances + at or after the cutoff with NO completion event are removed, inside + one transaction — an instance that gains a completion event + mid-flight cannot be deleted. The cutoff cannot be backdated below + today, for the same reason as the definition-scoped method (see its + docstring). ``today`` optionally pins the HA-local anchor the clamp + compares against (falling back to the host clock when omitted). + Any instance with a completion event is never touched, regardless + of its date. + + Returns the number deleted and the effective cutoff that was + actually applied (``max(cutoff_date, today)``, read under the + lock). The caller (:func:`~.materialize.regenerate_for_child`) + passes that effective cutoff back as its re-materialization anchor, + so the delete and the rebuild share ONE execution-day even when the + call is queued across midnight — instead of the caller re-reading a + stale "today" and materializing a window the delete already + superseded. + """ + _validate_date(cutoff_date, "cutoff_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + # "today" is read under the lock, matching the + # definition-scoped method: the DELETE executes on this + # date, so a caller queued across midnight cannot delete + # instances that became past while it waited. + today_date = _resolve_today(today) + effective_cutoff = max(cutoff_date, today_date.isoformat()) + result = await self._database.execute( + "DELETE FROM quest_instances WHERE child_id = ? " + "AND due_date >= ? AND NOT EXISTS (" + " SELECT 1 FROM completion_events " + " WHERE instance_id = quest_instances.id" + ")", + (child_id, effective_cutoff), + ) + return result.rowcount, effective_cutoff + + +class CompletionEventsDao: + """Typed append-only access to the ``completion_events`` table. + + APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the + three read methods below, and NOTHING else. There is no update, + no delete, no upsert — completions and un-completions both arrive + through :meth:`append` as new rows, and history is immutable + (Feature 08 done-condition: no code path updates or deletes an + event). The package-wide leak-guard test additionally fails if + mutation SQL for this table ever appears in any module. + """ + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def append( + self, + instance_id: int, + child_id: int, + event_type: str, + actor_source: str, + occurred_at: str, + was_on_time: bool | None, + *, + actor_user_id: str | None = None, + actor_child_id: int | None = None, + ) -> CompletionEventRecord: + """Append one completion or un-completion event. + + ``actor_source`` is 'user' (an admin action: ``actor_user_id`` + set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user + id, and ``actor_child_id`` carrying the tapped child profile, + D-008); the actor-pair CHECKs enforce this shape at the schema + level. ``occurred_at`` must be the module's one strict UTC + shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range + queries stay exact. ``was_on_time`` is required (True/False) + for completions; for un-completions it may be None or the + reversed completion's flag (Feature 08 still deciding). The + instance, child and — for panel events — the tapped child must + exist — validated atomically under the connection lock. + """ + if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED): + raise ValueError( + f"event_type must be '{EVENT_COMPLETED}' or " + f"'{EVENT_UNCOMPLETED}', got {event_type!r}" + ) + if actor_source not in ("user", "panel"): + raise ValueError( + f"actor_source must be 'user' or 'panel', got " + f"{actor_source!r}" + ) + if actor_source == "user" and not actor_user_id: + raise ValueError( + "actor_source 'user' requires actor_user_id" + ) + if actor_source == "panel" and actor_user_id is not None: + raise ValueError( + "actor_source 'panel' must not carry actor_user_id" + ) + if actor_source == "panel" and actor_child_id is None: + raise ValueError( + "actor_source 'panel' requires actor_child_id " + "(the tapped child profile)" + ) + if actor_source == "user" and actor_child_id is not None: + raise ValueError( + "actor_source 'user' must not carry actor_child_id" + ) + if was_on_time is not None and type(was_on_time) is not bool: + raise ValueError( + f"was_on_time must be True, False or None, got " + f"{was_on_time!r}" + ) + if event_type == EVENT_COMPLETED and was_on_time is None: + raise ValueError( + "a completed event requires was_on_time (True/False)" + ) + _validate_utc_timestamp(occurred_at, "occurred_at") + async with _connection_lock(self._database): + async with self._database.transaction(): + instance = await self._database.fetch_one( + "SELECT child_id FROM quest_instances WHERE id = ?", + (instance_id,), + ) + if instance is None: + raise ValueError( + f"quest instance {instance_id} does not exist" + ) + if instance[0] != child_id: + raise ValueError( + f"quest instance {instance_id} belongs to child " + f"{instance[0]}, not {child_id}" + ) + child = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", (child_id,) + ) + if child is None: + raise ValueError(f"child {child_id} does not exist") + if actor_child_id is not None: + tapped = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", + (actor_child_id,), + ) + if tapped is None: + raise ValueError( + f"actor_child_id {actor_child_id} does not " + "reference an existing child" + ) + result = await self._database.execute( + "INSERT INTO completion_events (instance_id, child_id, " + "event_type, actor_source, actor_user_id, " + "actor_child_id, occurred_at, was_on_time) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + instance_id, + child_id, + event_type, + actor_source, + actor_user_id, + actor_child_id, + occurred_at, + None if was_on_time is None else int(was_on_time), + ), + ) + event = await self._database.fetch_one( + f"SELECT {_EVENT_COLUMNS} FROM completion_events " + "WHERE id = ?", + (result.lastrowid,), + ) + assert event is not None + return _event_from_row(event) + + async def list_by_instance( + self, instance_id: int + ) -> list[CompletionEventRecord]: + """Return every event for the instance, in append order.""" + rows = await self._database.fetch_all( + f"SELECT {_EVENT_COLUMNS} FROM completion_events " + "WHERE instance_id = ? ORDER BY id", + (instance_id,), + ) + return [_event_from_row(row) for row in rows] + + async def list_by_child_and_date_range( + self, + child_id: int, + range_start: str, + range_end: str, + *, + due_date_scope: bool = True, + ) -> list[CompletionEventRecord]: + """Return the child's events for instances due in the closed range. + + Scopes by the INSTANCE's due date (the day the task was owed), + not the event's wall-clock timestamp: history reporting asks + "what happened to the tasks due that day" (Feature 13). Set + ``due_date_scope=False`` to filter by ``occurred_at`` date + instead. Ordered by instance, then event id. + """ + _validate_date(range_start, "range_start") + _validate_date(range_end, "range_end") + if range_end < range_start: + raise ValueError( + "range_end must be on or after range_start, got " + f"{range_end!r} < {range_start!r}" + ) + if due_date_scope: + predicate = ( + "SELECT id FROM quest_instances WHERE child_id = ? " + "AND due_date >= ? AND due_date <= ?" + ) + parameters: list[object] = [child_id, range_start, range_end] + else: + # Occurred-at scope: occurred_at is validated to the one + # accepted UTC shape at append time, so half-open day + # bounds in that same shape are exact (no spelling drift). + occurred_start = f"{range_start}T00:00:00+00:00" + occurred_end = f"{range_end}T23:59:59+00:00" + query = ( + f"SELECT {_EVENT_COLUMNS} FROM completion_events " + "WHERE child_id = ? AND occurred_at >= ? " + "AND occurred_at < ? ORDER BY instance_id, id" + ) + rows = await self._database.fetch_all( + query, + [ + child_id, + occurred_start, + _next_day_start(range_end), + ], + ) + return [_event_from_row(row) for row in rows] + rows = await self._database.fetch_all( + f"SELECT {_EVENT_COLUMNS} FROM completion_events " + f"WHERE instance_id IN ({predicate}) " + "ORDER BY instance_id, id", + tuple(parameters), + ) + return [_event_from_row(row) for row in rows] + + async def get_latest_for_instance( + self, instance_id: int + ) -> CompletionEventRecord | None: + """Return the instance's most recent event, or None if untouched. + + Instance current state derives from this event (Feature 08): + 'completed' means done, 'uncompleted' as the latest means open + again, None means never completed. + """ + row = await self._database.fetch_one( + f"SELECT {_EVENT_COLUMNS} FROM completion_events " + "WHERE instance_id = ? ORDER BY id DESC LIMIT 1", + (instance_id,), + ) + return _event_from_row(row) if row is not None else None \ No newline at end of file diff --git a/custom_components/nestquest/core/dao_meta.py b/custom_components/nestquest/core/dao_meta.py new file mode 100644 index 0000000..ada8c09 --- /dev/null +++ b/custom_components/nestquest/core/dao_meta.py @@ -0,0 +1,44 @@ +"""Typed DAO for the integration-owned ``nestquest_meta_state`` table. + +Generic persistent key/value bookkeeping that belongs to the +integration itself rather than any domain table — currently the +Feature 11 missed-sweep watermark. Values are plain strings; callers +interpret them. All SQL for this table lives here per the feature +guardrails, and every method runs through +:class:`~.db.NestQuestDatabase` so nothing blocks the event loop. +""" +from __future__ import annotations + +from .dao_children import _connection_lock +from .db import NestQuestDatabase + +_TABLE = "nestquest_meta_state" + + +class MetaStateDao: + """Typed async access to the ``nestquest_meta_state`` table.""" + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def get(self, key: str) -> str | None: + """Return the stored value for ``key``, or None when absent.""" + row = await self._database.fetch_one( + f"SELECT value FROM {_TABLE} WHERE key = ?", (key,) + ) + return row[0] if row is not None else None + + async def set(self, key: str, value: str) -> None: + """Upsert one key/value row atomically. + + The write runs inside one transaction under the shared + connection lock, so a racing reader either sees the old value + or the fully-written new one, never a half-applied state. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + await self._database.execute( + f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) " + "ON CONFLICT (key) DO UPDATE SET value = excluded.value", + (key, value), + ) \ No newline at end of file diff --git a/custom_components/nestquest/core/dao_presence.py b/custom_components/nestquest/core/dao_presence.py new file mode 100644 index 0000000..c273287 --- /dev/null +++ b/custom_components/nestquest/core/dao_presence.py @@ -0,0 +1,343 @@ +"""Typed DAO layer for the presence_schedules and presence_overrides tables. + +All SQL for these tables 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. + +Semantics per the done-condition and Feature 05: + +- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting + by child updates the existing row rather than raising. +- A child with NO schedule row is present every day — the absence of a + row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child` + returning None, never by an empty pattern row. +- Overrides beat the pattern for their date range (that resolution is + the presence engine's job, Feature 05); the DAO only stores and + lists them. A single-day override stores the same date as both + start and end (schema CHECK requires end >= start). + +Date arguments are validated as strict YYYY-MM-DD per the same policy +the rules DAO applies: the schema's lexical CHECKs cannot prove +calendar validity. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from .dao_children import _connection_lock +from .dao_rules import _UNSET, _validate_date +from .db import NestQuestDatabase + + +@dataclass(frozen=True) +class PresenceScheduleRecord: + """One row of ``presence_schedules``.""" + + id: int + child_id: int + cycle_length_weeks: int + anchor_date: str + pattern: str + + +@dataclass(frozen=True) +class PresenceOverrideRecord: + """One row of ``presence_overrides``.""" + + id: int + child_id: int + start_date: str + end_date: str + is_present: bool + note: str | None + + +_SCHEDULE_COLUMNS = ( + "id, child_id, cycle_length_weeks, anchor_date, pattern" +) +_OVERRIDE_COLUMNS = ( + "id, child_id, start_date, end_date, is_present, note" +) + + +def _schedule_from_row(row: tuple) -> PresenceScheduleRecord: + return PresenceScheduleRecord( + id=row[0], + child_id=row[1], + cycle_length_weeks=row[2], + anchor_date=row[3], + pattern=row[4], + ) + + +def _override_from_row(row: tuple) -> PresenceOverrideRecord: + return PresenceOverrideRecord( + id=row[0], + child_id=row[1], + start_date=row[2], + end_date=row[3], + is_present=bool(row[4]), + note=row[5], + ) + + +class PresenceSchedulesDao: + """Typed async access to the ``presence_schedules`` table.""" + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def upsert_by_child( + self, + child_id: int, + cycle_length_weeks: int, + anchor_date: str, + pattern: str, + ) -> PresenceScheduleRecord: + """Set the child's presence schedule, replacing any existing one. + + Upsert semantics are the done-condition: a second schedule for + the same child updates the existing row rather than raising. + Runs under the connection lock so a concurrent upsert/delete + for the same child serializes instead of colliding on the + schema's UNIQUE(child_id). Pattern validity (segment shape, + segment count = cycle_length_weeks) is enforced by the schema + CHECKs; the child must exist (FK) — validated atomically here + so a deleted child cannot pass a stale check. + """ + _validate_date(anchor_date, "anchor_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + child = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", (child_id,) + ) + if child is None: + raise ValueError(f"child {child_id} does not exist") + await self._database.execute( + "INSERT INTO presence_schedules (child_id, " + "cycle_length_weeks, anchor_date, pattern) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT (child_id) DO UPDATE SET " + "cycle_length_weeks = excluded.cycle_length_weeks, " + "anchor_date = excluded.anchor_date, " + "pattern = excluded.pattern", + (child_id, cycle_length_weeks, anchor_date, pattern), + ) + schedule = await self.get_by_child(child_id) + assert schedule is not None + return schedule + + async def get_by_child( + self, child_id: int + ) -> PresenceScheduleRecord | None: + """Return the child's schedule, or None when the child has none. + + None is meaningful and UNAMBIGUOUS here: the child exists and + has no schedule row, i.e. the child is present every day + (Feature 05 guardrail). An unknown child id raises ValueError + instead of returning None, because "nonexistent child" must + never be silently read as "present every day". + """ + child = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", (child_id,) + ) + if child is None: + raise ValueError(f"child {child_id} does not exist") + row = await self._database.fetch_one( + f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules " + "WHERE child_id = ?", + (child_id,), + ) + return _schedule_from_row(row) if row is not None else None + + async def delete(self, child_id: int) -> bool: + """Remove the child's schedule; True if a row was removed. + + After deletion the child is present every day (no row means + no restriction). Deleting a schedule the child never had + returns False. + """ + result = await self._database.execute( + "DELETE FROM presence_schedules WHERE child_id = ?", + (child_id,), + ) + return result.rowcount > 0 + + +class PresenceOverridesDao: + """Typed async access to the ``presence_overrides`` table.""" + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def create( + self, + child_id: int, + start_date: str, + end_date: str, + is_present: bool, + *, + note: str | None = None, + ) -> PresenceOverrideRecord: + """Insert one override and return the record as stored. + + The child must exist (validated atomically under the + connection lock). Date shape is validated strictly, and the + schema CHECK rejects end < start. + + Overlap policy (Feature 05): an override for a child may not + overlap another override of the SAME child — two different + present/absent answers for one date are unresolvable. The + overlap check (inclusive boundaries) runs inside the same + transaction as the INSERT, so a racing create cannot slip a + conflicting override between the check and the write; a + conflict raises ValueError naming the conflicting override's + id and range. + """ + _validate_date(start_date, "start_date") + _validate_date(end_date, "end_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + child = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", (child_id,) + ) + if child is None: + raise ValueError(f"child {child_id} does not exist") + # Only a well-ordered range has overlap semantics: + # inverted ranges fall through to the INSERT and hit + # the schema's end>=start CHECK, preserving the + # IntegrityError contract the matrix tests rely on. + if end_date >= start_date: + conflicting = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} " + "FROM presence_overrides " + "WHERE child_id = ? AND end_date >= ? " + "AND start_date <= ? ORDER BY id LIMIT 1", + (child_id, start_date, end_date), + ) + else: + conflicting = None + if conflicting is not None: + conflict = _override_from_row(conflicting) + raise ValueError( + f"override for child {child_id} " + f"[{start_date}, {end_date}] overlaps existing " + f"override {conflict.id} " + f"[{conflict.start_date}, {conflict.end_date}] " + f"(is_present={conflict.is_present})" + ) + result = await self._database.execute( + "INSERT INTO presence_overrides (child_id, " + "start_date, end_date, is_present, note) " + "VALUES (?, ?, ?, ?, ?)", + (child_id, start_date, end_date, int(is_present), note), + ) + override = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE id = ?", + (result.lastrowid,), + ) + assert override is not None + return _override_from_row(override) + + async def list_by_child_and_range( + self, + child_id: int, + range_start: str, + range_end: str, + ) -> list[PresenceOverrideRecord]: + """Return the child's overrides overlapping [range_start, range_end]. + + An override overlaps the range when it ends on or after the + range starts and starts on or before the range ends (inclusive + boundary dates, matching the storage model where overrides are + closed date ranges). Ordered by start date. + """ + _validate_date(range_start, "range_start") + _validate_date(range_end, "range_end") + if range_end < range_start: + raise ValueError( + "range_end must be on or after range_start, got " + f"{range_end!r} < {range_start!r}" + ) + rows = await self._database.fetch_all( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE child_id = ? AND end_date >= ? AND start_date <= ? " + "ORDER BY start_date", + (child_id, range_start, range_end), + ) + return [_override_from_row(row) for row in rows] + + async def get(self, override_id: int) -> PresenceOverrideRecord | None: + """Return one override by id, or None when it does not exist.""" + row = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE id = ?", + (override_id,), + ) + return _override_from_row(row) if row is not None else None + + async def delete(self, override_id: int) -> bool: + """Remove one override by id; True if a row was removed.""" + result = await self._database.execute( + "DELETE FROM presence_overrides WHERE id = ?", + (override_id,), + ) + return result.rowcount > 0 + + +async def read_snapshot_unlocked( + database: NestQuestDatabase, + child_ids: list[int], + range_start: str, + range_end: str, +) -> tuple[ + dict[int, PresenceScheduleRecord], + dict[int, list[PresenceOverrideRecord]], +]: + """Read schedules and overrides for ``child_ids`` WITHOUT locking. + + MUST be called inside the connection lock and an open transaction, so + the schedules and overrides returned here stay coherent with every + other table the caller reads inside that same transaction (the + materialization walk's single input snapshot does exactly that). The + date range comes already validated by the caller. + + Returns ``(schedules, overrides)``: + + - ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord` + for every child that HAS a schedule row. A child absent from the + mapping has no schedule, i.e. is present every day (Feature 05). + - ``overrides`` maps ``child_id`` to that child's overrides overlapping + ``[range_start, range_end]`` (inclusive boundaries), ordered by start + date; a child with no matching override is absent from the mapping. + + ``child_ids`` must name existing children (the caller derives them + from a trusted snapshot, within the same transaction). + """ + children = sorted(set(child_ids)) + if not children: + return {}, {} + placeholders = ",".join("?" for _ in children) + schedule_rows = await database.fetch_all( + f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules " + f"WHERE child_id IN ({placeholders})", + tuple(children), + ) + override_rows = await database.fetch_all( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + f"WHERE child_id IN ({placeholders}) AND end_date >= ? " + "AND start_date <= ? ORDER BY start_date", + tuple(children) + (range_start, range_end), + ) + schedules = { + record.child_id: record + for record in map(_schedule_from_row, schedule_rows) + } + overrides: dict[int, list[PresenceOverrideRecord]] = {} + for row in override_rows: + record = _override_from_row(row) + overrides.setdefault(record.child_id, []).append(record) + return schedules, overrides \ No newline at end of file diff --git a/custom_components/nestquest/core/dao_rules.py b/custom_components/nestquest/core/dao_rules.py new file mode 100644 index 0000000..ec3f6d9 --- /dev/null +++ b/custom_components/nestquest/core/dao_rules.py @@ -0,0 +1,1250 @@ +"""Typed DAO layer for the schedule_rules, quest_definitions and +quest_definition_assignees tables. + +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. + +Guardrail mapping: + +- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still + referenced by any quest definition is rejected (the done-condition), + not cascaded. +- ``quest_definitions`` has no delete path at all: definitions are + deactivated, never hard-deleted, and history must survive. +- 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` (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`` 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 .const import QUEST_WINDOWS +from .dao_children import ChildRecord, _child_from_row, _connection_lock +from .dao_children import _CHILD_COLUMNS +from .db import NestQuestDatabase +from .recurrence import RuleType, RuleValidationError, ScheduleRule + +#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL" +#: in update methods: passing ``None`` must mean clearing a nullable +#: column, not skipping it. +_UNSET = object() + +#: Date-shape policy: schedule-rule dates are ISO YYYY-MM-DD. The +#: schema's lexical end>=start CHECK cannot prove a value is a real +#: calendar date, so the DAO validates the shape it accepts. +_DATE_FORMAT = "%Y-%m-%d" + + +def _validate_date(value: str, field: str) -> None: + """Raise ValueError unless ``value`` is a YYYY-MM-DD calendar date. + + ``datetime.date.fromisoformat`` accepts ISO dates but is lenient on + some builds (e.g. '2026-9-4'); strptime with an exact format is + strict, and strptime round-trips the parsed value to reject zero + padding ('2026-9-4' parses but its isoformat differs). + """ + try: + parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date() + except (TypeError, ValueError): + raise ValueError( + f"{field} must be an ISO date (YYYY-MM-DD), got {value!r}" + ) from None + if parsed.isoformat() != value: + raise ValueError( + f"{field} must be a strict YYYY-MM-DD date, got {value!r}" + ) + + +_TIME_FORMAT = "%H:%M" + + +def _validate_time(value: str, field: str) -> None: + """Raise ValueError unless ``value`` is a strict 24-hour HH:MM. + + Same strictness policy as _validate_date: parse with the exact + format, then round-trip so '9:30' (non-padded) is rejected. Time + shapes are caller-side policy (the schema stores TEXT): windows' + optional ``due_time`` and definition ``due_time`` both use this. + """ + try: + parsed = datetime.datetime.strptime(value, _TIME_FORMAT).time() + except (TypeError, ValueError): + raise ValueError( + f"{field} must be a 24-hour HH:MM time, got {value!r}" + ) from None + if parsed.strftime(_TIME_FORMAT) != value: + raise ValueError( + f"{field} must be a strict HH:MM time, got {value!r}" + ) + + +def _validate_window(window: str) -> None: + """Raise ValueError unless ``window`` is a ``const`` window name.""" + if window not in QUEST_WINDOWS: + raise ValueError( + f"window must be one of {', '.join(QUEST_WINDOWS)}, " + f"got {window!r}" + ) + + +@dataclass(frozen=True) +class ScheduleRuleRecord: + """One row of ``schedule_rules``.""" + + id: int + rule_type: str + interval: int + weekday_set: str | None + day_of_month: int | None + nth_weekday: int | None + month: int | None + start_date: str + end_date: str | None + + +@dataclass(frozen=True) +class ScheduleRuleStorage: + """The eight value columns a ScheduleRule maps to (no id column). + + ``id`` is assigned by the database on insert, so the model<->storage + mapping concerns only the value columns. ``nth_weekday_weekday`` is + deliberately absent here: for MONTHLY_WEEKDAY its value is folded + into ``weekday_set`` as that column's single CSV element. + """ + + rule_type: str + interval: int + weekday_set: str | None + day_of_month: int | None + nth_weekday: int | None + month: int | None + start_date: str + end_date: str | None + + +@dataclass(frozen=True) +class QuestDefinitionRecord: + """One row of ``quest_definitions`` (without its assignees).""" + + id: int + title: str + description: str | None + icon: str | None + schedule_rule_id: int + due_time: str | None + is_active: bool + created_at: str + + +@dataclass(frozen=True) +class QuestDefinitionWindowRecord: + """One row of ``quest_definition_windows``.""" + + definition_id: int + window: str + due_time: str | None + + +@dataclass(frozen=True) +class QuestDefinitionSnapshot: + """A definition plus its rule, assignees and windows, read atomically. + + Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows` + and :meth:`QuestDefinitionsDao.edit_definition` with the rule, + assignees and windows read inside the SAME transaction that wrote + them, so the snapshot is a consistent creation/edit-time view — a + concurrent rule, assignee or window mutation cannot change it + between the write and the read. + """ + + definition: QuestDefinitionRecord + rule: ScheduleRuleRecord + assignees: list[ChildRecord] + windows: list[QuestDefinitionWindowRecord] + + +_RULE_COLUMNS = ( + "id, rule_type, interval, weekday_set, day_of_month, nth_weekday, " + "month, start_date, end_date" +) +_DEFINITION_COLUMNS = ( + "id, title, description, icon, schedule_rule_id, due_time, " + "is_active, created_at" +) +_WINDOW_COLUMNS = "definition_id, window, due_time" + + +def _rule_from_row(row: tuple) -> ScheduleRuleRecord: + return ScheduleRuleRecord( + id=row[0], + rule_type=row[1], + interval=row[2], + weekday_set=row[3], + day_of_month=row[4], + nth_weekday=row[5], + month=row[6], + start_date=row[7], + end_date=row[8], + ) + + +def _definition_from_row(row: tuple) -> QuestDefinitionRecord: + return QuestDefinitionRecord( + id=row[0], + title=row[1], + description=row[2], + icon=row[3], + schedule_rule_id=row[4], + due_time=row[5], + is_active=bool(row[6]), + created_at=row[7], + ) + + +def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord: + return QuestDefinitionWindowRecord( + definition_id=row[0], + window=row[1], + due_time=row[2], + ) + + +#: Storage columns that are forbidden for each rule type. A populated +#: forbidden column means the row's shape is invalid and must raise on +#: read, never be silently dropped. ``weekday_set`` is absent for +#: DAILY/MONTHLY_DAY/YEARLY; MONTHLY_WEEKDAY is the only shape allowed a +#: non-NULL ``nth_weekday``; YEARLY is the only shape allowed ``month``. +_FORBIDDEN_STORAGE_FIELDS: dict[str, tuple[RuleType, ...]] = { + "weekday_set": (RuleType.DAILY, RuleType.MONTHLY_DAY, RuleType.YEARLY), + "day_of_month": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_WEEKDAY, + ), + "nth_weekday": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, + RuleType.YEARLY, + ), + "month": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, + RuleType.MONTHLY_WEEKDAY, + ), +} + + +def schedule_rule_to_storage(rule: ScheduleRule) -> ScheduleRuleStorage: + """Map a validated ScheduleRule onto its storage columns. + + ``rule_type`` stores as ``RuleType.storage_value`` (MONTHLY_DAY and + MONTHLY_WEEKDAY both store ``'monthly'``); ``weekday_set`` stores as + a sorted CSV list. MONTHLY_WEEKDAY folds its model-only + ``nth_weekday_weekday`` into ``weekday_set`` as that list's single + element; every other type leaves the column as its own set + (WEEKLY/CUSTOM_DAYS) or NULL. + """ + shape = rule.rule_type + if shape is RuleType.MONTHLY_WEEKDAY: + weekday_set = str(rule.nth_weekday_weekday) + elif shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: + weekday_set = ",".join(str(w) for w in sorted(rule.weekday_set)) + else: + weekday_set = None + return ScheduleRuleStorage( + rule_type=shape.storage_value, + interval=rule.interval, + weekday_set=weekday_set, + day_of_month=rule.day_of_month, + nth_weekday=rule.nth_weekday, + month=rule.month, + start_date=rule.start_date, + end_date=rule.end_date, + ) + + +def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule: + """Reconstruct a ScheduleRule from storage columns. + + Disambiguates MONTHLY_DAY from MONTHLY_WEEKDAY by which field is + populated, and raises RuleValidationError rather than silently + mis-decoding an ambiguous or invalid shape. + """ + rule_type = storage.rule_type + if rule_type == "daily": + shape = RuleType.DAILY + elif rule_type == "weekly": + shape = RuleType.WEEKLY + elif rule_type == "monthly": + shape = _disambiguate_monthly(storage) + elif rule_type == "yearly": + shape = RuleType.YEARLY + elif rule_type == "custom": + shape = RuleType.CUSTOM_DAYS + else: + raise RuleValidationError( + f"unknown storage rule_type {rule_type!r}" + ) + + _reject_forbidden_storage_fields(storage, shape) + + weekday_set = None + nth_weekday_weekday = None + if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: + weekday_set = _weekdays_from_csv(storage.weekday_set) + elif shape is RuleType.MONTHLY_WEEKDAY: + nth_weekday_weekday = _single_weekday_from_csv(storage.weekday_set) + + return ScheduleRule( + rule_type=shape, + interval=storage.interval, + weekday_set=weekday_set, + day_of_month=storage.day_of_month, + nth_weekday=storage.nth_weekday, + nth_weekday_weekday=nth_weekday_weekday, + month=storage.month, + start_date=storage.start_date, + end_date=storage.end_date, + ) + + +def schedule_rule_storage_from_record( + record: ScheduleRuleRecord, +) -> ScheduleRuleStorage: + """Copy a rule row's value columns into a storage mapping. + + ``ScheduleRuleRecord`` is the read shape (it carries ``id``); + :func:`schedule_rule_from_storage` consumes the value-only + :class:`ScheduleRuleStorage` shape, so callers re-reading a stored + rule convert through this helper. + """ + return ScheduleRuleStorage( + rule_type=record.rule_type, + interval=record.interval, + weekday_set=record.weekday_set, + day_of_month=record.day_of_month, + nth_weekday=record.nth_weekday, + month=record.month, + start_date=record.start_date, + end_date=record.end_date, + ) + + +def _reject_forbidden_storage_fields( + storage: ScheduleRuleStorage, shape: RuleType +) -> None: + """Raise unless every populated column is permitted for ``shape``. + + The model constructor rejects a forbidden ``day_of_month``, + ``nth_weekday``, or ``month``, but ``weekday_set`` never reaches it + (it is folded for MONTHLY_WEEKDAY and dropped otherwise), so a daily + row carrying ``weekday_set="0"`` would otherwise decode as a valid + daily rule and silently lose the stored data. This validates every + shape column explicitly so a populated-forbidden column raises + instead of being discarded. + """ + for field, forbidden_shapes in _FORBIDDEN_STORAGE_FIELDS.items(): + value = getattr(storage, field) + if value is not None and shape in forbidden_shapes: + raise RuleValidationError( + f"{shape.value} storage rows must not set {field}, " + f"got {value!r}" + ) + + +def _disambiguate_monthly(storage: ScheduleRuleStorage) -> RuleType: + """Resolve the shared 'monthly' storage value to a model type. + + MONTHLY_DAY names ``day_of_month`` only; MONTHLY_WEEKDAY names + ``nth_weekday`` only. Neither, or both, is ambiguous and raises. + """ + day_of_month = storage.day_of_month + nth_weekday = storage.nth_weekday + if day_of_month is not None and nth_weekday is None: + return RuleType.MONTHLY_DAY + if nth_weekday is not None and day_of_month is None: + return RuleType.MONTHLY_WEEKDAY + raise RuleValidationError( + "storage rule_type 'monthly' is ambiguous: must name exactly one " + "of day_of_month or nth_weekday, got " + f"day_of_month={day_of_month!r}, nth_weekday={nth_weekday!r}" + ) + + +def _weekdays_from_csv(value: str | None) -> frozenset[int]: + """Parse a weekday CSV column into a frozenset of ints. + + ``None`` and ``""`` both yield an empty frozenset (the caller decides + whether empty is legal); a non-integer token raises + RuleValidationError rather than leaking a raw ValueError. + """ + if value is None or value == "": + return frozenset() + try: + entries = [int(token) for token in value.split(",")] + except ValueError: + raise RuleValidationError( + f"weekday_set must be a CSV of integers, got {value!r}" + ) from None + return frozenset(entries) + + +def _single_weekday_from_csv(value: str | None) -> int: + """The one weekday a MONTHLY_WEEKDAY stores in weekday_set. + + Parses the CSV into tokens FIRST and requires exactly one token, + so a duplicate pair like ``"1,1"`` is rejected rather than silently + canonicalized to a single weekday on re-storage. An empty or + multi-element column raises RuleValidationError. + """ + if value is None or value == "": + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " + f"weekday, got {value!r}" + ) + tokens = value.split(",") + if len(tokens) != 1: + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " + f"weekday, got {value!r}" + ) + try: + return int(tokens[0]) + except ValueError: + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must be an integer, " + f"got {value!r}" + ) from None + + +async def _insert_rule_row( + database: NestQuestDatabase, storage: ScheduleRuleStorage +) -> int: + """Insert one ``schedule_rules`` row and return its new id. + + Runs inside the caller's transaction (no lock or transaction of its + own) so a multi-table write can persist the rule together with the + rows that reference it. The caller is responsible for validating + the storage fields — :meth:`ScheduleRulesDao.create` validates the + date shape, and :meth:`QuestDefinitionsDao.create_with_rule_and_windows` + receives storage already mapped from a validated + :class:`~.recurrence.ScheduleRule`. + """ + result = await database.execute( + "INSERT INTO schedule_rules (rule_type, interval, weekday_set, " + "day_of_month, nth_weekday, month, start_date, end_date) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + storage.rule_type, + storage.interval, + storage.weekday_set, + storage.day_of_month, + storage.nth_weekday, + storage.month, + storage.start_date, + storage.end_date, + ), + ) + return result.lastrowid + + +class ScheduleRulesDao: + """Typed async access to the ``schedule_rules`` table.""" + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def create( + self, + rule_type: str, + start_date: str, + *, + interval: int = 1, + weekday_set: str | None = None, + day_of_month: int | None = None, + nth_weekday: int | None = None, + month: int | None = None, + end_date: str | None = None, + ) -> ScheduleRuleRecord: + """Insert one rule and return the record as stored. + + Rule-type coherence (a weekly rule needs ``weekday_set``, a + yearly rule needs ``month``, etc.) is enforced by the schema + CHECKs and surfaces as :class:`sqlite3.IntegrityError`. Date + shape (strict YYYY-MM-DD) is validated here because the + schema's lexical end>=start CHECK cannot prove a real calendar + date. + """ + _validate_date(start_date, "start_date") + if end_date is not None: + _validate_date(end_date, "end_date") + rule_id = await _insert_rule_row( + self._database, + ScheduleRuleStorage( + rule_type=rule_type, + interval=interval, + weekday_set=weekday_set, + day_of_month=day_of_month, + nth_weekday=nth_weekday, + month=month, + start_date=start_date, + end_date=end_date, + ), + ) + rule = await self.get(rule_id) + assert rule is not None + return rule + + async def get(self, rule_id: int) -> ScheduleRuleRecord | None: + """Return the rule with ``rule_id``, or None.""" + row = await self._database.fetch_one( + f"SELECT {_RULE_COLUMNS} FROM schedule_rules WHERE id = ?", + (rule_id,), + ) + return _rule_from_row(row) if row is not None else None + + async def update( + self, + rule_id: int, + *, + rule_type: str | None | object = _UNSET, + interval: int | None | object = _UNSET, + weekday_set: str | None | object = _UNSET, + day_of_month: int | None | object = _UNSET, + nth_weekday: int | None | object = _UNSET, + month: int | None | object = _UNSET, + start_date: str | None | object = _UNSET, + end_date: str | None | object = _UNSET, + ) -> int: + """Update the given fields; returns rows updated (0 if absent). + + Arguments default to the module sentinel ``_UNSET`` meaning + "leave this column alone"; passing ``None`` explicitly writes + SQL NULL, so callers can reopen an ended rule (``end_date`` + back to NULL) or clear nullable shape fields. Passing + ``None``/a new value for a date field validates its shape. + Editing a rule that a definition references changes future + materializations only — this method touches no instances + (Feature 06/07 separation). + """ + date_args = ( + ("start_date", start_date), + ("end_date", end_date), + ) + for field, value in date_args: + if value is not _UNSET and value is not None: + _validate_date(value, field) + assignments: list[str] = [] + parameters: list[object] = [] + for column, value in ( + ("rule_type", rule_type), + ("interval", interval), + ("weekday_set", weekday_set), + ("day_of_month", day_of_month), + ("nth_weekday", nth_weekday), + ("month", month), + ("start_date", start_date), + ("end_date", end_date), + ): + if value is not _UNSET: + assignments.append(f"{column} = ?") + parameters.append(value) + if not assignments: + return 0 + parameters.append(rule_id) + result = await self._database.execute( + f"UPDATE schedule_rules SET {', '.join(assignments)} " + "WHERE id = ?", + tuple(parameters), + ) + return result.rowcount + + async def delete_if_unreferenced(self, rule_id: int) -> bool: + """Delete the rule only if no definition references it. + + Returns True if the rule was deleted, False if it is still + referenced (or does not exist — distinguish via :meth:`get`). + The reference check and the DELETE run inside one transaction + under the connection lock, so a definition cannot start + referencing the rule between the check and the delete. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + row = await self._database.fetch_one( + "SELECT 1 FROM quest_definitions " + "WHERE schedule_rule_id = ? LIMIT 1", + (rule_id,), + ) + if row is not None: + return False + result = await self._database.execute( + "DELETE FROM schedule_rules WHERE id = ?", + (rule_id,), + ) + return result.rowcount > 0 + + +class QuestDefinitionsDao: + """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). 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: + self._database = database + + async def create( + self, + title: str, + schedule_rule_id: int, + created_at: str, + *, + description: str | None = None, + 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 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_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, schedule_rule_id, due_time, is_active, " + "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + title, + description, + icon, + schedule_rule_id, + due_time, + int(is_active), + created_at, + ), + ) + 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 create_with_rule_and_windows( + self, + title: str, + schedule_rule: ScheduleRuleStorage, + created_at: str, + assignee_child_ids: list[int], + windows: list[tuple[str, str | None]], + *, + description: str | None = None, + icon: str | None = None, + ) -> QuestDefinitionSnapshot: + """Insert a rule, definition, assignees and windows atomically. + + The whole write — the schedule rule row, the definition row, + every assignee link and every window declaration — runs inside + ONE transaction under the connection lock, so a validation or + write failure rolls the lot back together (all-or-nothing): the + caller can never observe a definition with a missing rule, + assignee or window, and a rejected create leaves no partial + rows. + + Returns a :class:`QuestDefinitionSnapshot` whose assignees and + windows are read back inside the same transaction, so the + returned view cannot be raced by a concurrent assignee/window + mutation after the write. + + The rule arrives as pre-validated storage fields (mapped from a + :class:`~.recurrence.ScheduleRule` via + :func:`schedule_rule_to_storage`); its dates are re-validated + here so a hand-built storage can never persist a malformed date. + The definition-level ``due_time`` column is deliberately left + NULL — per-window due times supersede it (D-008). Assignees are + validated as existing-and-active at insert time, and windows are + validated for name and strict HH:MM due time, matching + :meth:`upsert_window`. + """ + _validate_date(schedule_rule.start_date, "start_date") + if schedule_rule.end_date is not None: + _validate_date(schedule_rule.end_date, "end_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + for child_id in assignee_child_ids: + await self._validate_assignable_child(child_id) + rule_id = await _insert_rule_row(self._database, schedule_rule) + result = await self._database.execute( + "INSERT INTO quest_definitions (title, description, " + "icon, schedule_rule_id, due_time, is_active, " + "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (title, description, icon, rule_id, None, 1, created_at), + ) + definition_id = result.lastrowid + for child_id in assignee_child_ids: + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?)", + (definition_id, child_id), + ) + for window, due_time in windows: + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, ?)", + (definition_id, window, due_time), + ) + definition = await self.get(definition_id) + assignees = await self.list_assignees(definition_id) + window_records = await self.list_windows(definition_id) + rule_record = await ScheduleRulesDao(self._database).get( + rule_id + ) + assert definition is not None + assert rule_record is not None + return QuestDefinitionSnapshot( + definition=definition, + rule=rule_record, + assignees=assignees, + windows=window_records, + ) + + async def edit_definition( + self, + definition_id: int, + *, + title: str | None | object = _UNSET, + description: str | None | object = _UNSET, + icon: str | None | object = _UNSET, + rule: ScheduleRuleStorage | object = _UNSET, + windows: list[tuple[str, str | None]] | object = _UNSET, + ) -> QuestDefinitionSnapshot: + """Edit a definition's metadata, rule and windows atomically. + + Mirrors :meth:`create_with_rule_and_windows`: everything runs + inside ONE transaction under the connection lock, and the + returned snapshot's rule, assignees and windows are read back + inside that transaction. Arguments default to the module sentinel + ``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL + NULL so optional metadata (description, icon) can be cleared. + + - ``title``/``description``/``icon`` update the definition row + in place; the definition-level ``due_time`` column is never + written (per-window due times supersede it, D-008). + - ``rule`` (a pre-validated :class:`ScheduleRuleStorage`) + overwrites the referenced ``schedule_rules`` row in place: the + definition keeps its ``schedule_rule_id``, so no new or + orphaned rule row appears. + - ``windows`` REPLACES the whole window set: the current rows + are deleted and the given set re-inserted, so missing windows + are added, changed due times updated and removed windows + dropped. + + Assignment is deliberately not settable here (multi-assignee is + managed by :meth:`add_assignee`/:meth:`remove_assignee`), and + activation is untouched. Raises ValueError when the definition + does not exist. Edits change future instances only: no + ``quest_instances`` or ``completion_events`` row is touched. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definition = await self.get(definition_id) + if definition is None: + raise ValueError( + f"quest definition {definition_id} does not exist" + ) + assignments: list[str] = [] + parameters: list[object] = [] + for column, value in ( + ("title", title), + ("description", description), + ("icon", icon), + ): + if value is not _UNSET: + assignments.append(f"{column} = ?") + parameters.append(value) + if assignments: + parameters.append(definition_id) + await self._database.execute( + f"UPDATE quest_definitions SET " + f"{', '.join(assignments)} WHERE id = ?", + tuple(parameters), + ) + if rule is not _UNSET: + await ScheduleRulesDao(self._database).update( + definition.schedule_rule_id, + rule_type=rule.rule_type, + interval=rule.interval, + weekday_set=rule.weekday_set, + day_of_month=rule.day_of_month, + nth_weekday=rule.nth_weekday, + month=rule.month, + start_date=rule.start_date, + end_date=rule.end_date, + ) + if windows is not _UNSET: + await self._database.execute( + "DELETE FROM quest_definition_windows " + "WHERE definition_id = ?", + (definition_id,), + ) + for window, due_time in windows: + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) " + "VALUES (?, ?, ?)", + (definition_id, window, due_time), + ) + updated = await self.get(definition_id) + assignees = await self.list_assignees(definition_id) + window_records = await self.list_windows(definition_id) + rule_record = await ScheduleRulesDao(self._database).get( + updated.schedule_rule_id + ) + assert updated is not None + assert rule_record is not None + return QuestDefinitionSnapshot( + definition=updated, + rule=rule_record, + assignees=assignees, + windows=window_records, + ) + + 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. + """ + rule = await self._database.fetch_one( + "SELECT 1 FROM schedule_rules WHERE id = ?", + (schedule_rule_id,), + ) + if rule is None: + raise ValueError( + f"schedule rule {schedule_rule_id} does not exist" + ) + + 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 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[QuestDefinitionRecord]: + """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 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] + + 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 quest_definitions " + "WHERE is_active = 1 ORDER BY id" + ) + return [_definition_from_row(row) for row in rows] + + async def _snapshots_for( + self, definitions: list[QuestDefinitionRecord] + ) -> list[QuestDefinitionSnapshot]: + """Read each definition's rule, assignees and windows. + + MUST be called inside the connection lock (and ideally a + transaction) so the definitions handed in and the rows this + reads back cannot be raced by a concurrent edit between list + and bundle. Each rule is read through + :class:`ScheduleRulesDao` on the same connection, so the + snapshot's rule record is the one the definition references AT + the moment of the locked read. + """ + rules_dao = ScheduleRulesDao(self._database) + snapshots: list[QuestDefinitionSnapshot] = [] + for definition in definitions: + rule_record = await rules_dao.get(definition.schedule_rule_id) + assert rule_record is not None + snapshots.append( + QuestDefinitionSnapshot( + definition=definition, + rule=rule_record, + assignees=await self.list_assignees(definition.id), + windows=await self.list_windows(definition.id), + ) + ) + return snapshots + + async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]: + """Return every active definition with its rule, assignees and + windows, all read inside one locked transaction. + + The definitive list and each definition's linked rows are read + in a single BEGIN..COMMIT span under the connection lock, so a + concurrent atomic edit cannot interleave between the definition + list and the bundle read: the caller receives a coherent + snapshot (each definition's rule, assignees and windows as they + were at one instant), never a mixed old-rule/new-windows state. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definitions = await self.list_active() + return await self._snapshots_for(definitions) + + async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]: + """Return every active definition's snapshot WITHOUT locking. + + Mirrors :meth:`list_snapshots_active` minus the lock and + transaction: the definitions and their rules, assignees and + windows are read on the caller's connection under whatever + transaction the caller is already holding, so they stay coherent + with every OTHER table the caller reads inside that same + transaction. MUST be called inside the connection lock and an + open transaction (the single-transaction input snapshot the + materialization walk uses is that caller). + """ + definitions = await self.list_active() + return await self._snapshots_for(definitions) + + async def list_snapshots_by_child( + self, child_id: int + ) -> list[QuestDefinitionSnapshot]: + """Return each definition assigned to ``child_id`` with its rule, + assignees and windows, all read inside one locked transaction. + + Mirrors :meth:`list_snapshots_active` but selects by assignment + (``list_by_child`` order: newest first). Locks read-and-bundle + into one transaction so the bundle never mixes one edit's + definition with another's windows. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definitions = await self.list_by_child(child_id) + return await self._snapshots_for(definitions) + + async def update( + self, + definition_id: int, + *, + title: str | None | object = _UNSET, + description: str | None | object = _UNSET, + icon: str | None | object = _UNSET, + due_time: str | None | object = _UNSET, + ) -> int: + """Update the given fields; returns rows updated (0 if absent). + + Arguments default to the module sentinel ``_UNSET`` meaning + "leave this column alone"; passing ``None`` explicitly writes + 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:`add_assignee`, :meth:`remove_assignee`, + :meth:`set_active`) so they stay separately loggable and + permission-gateable. + """ + assignments: list[str] = [] + parameters: list[object] = [] + for column, value in ( + ("title", title), + ("description", description), + ("icon", icon), + ("due_time", due_time), + ): + if value is not _UNSET: + assignments.append(f"{column} = ?") + parameters.append(value) + if not assignments: + return 0 + parameters.append(definition_id) + result = await self._database.execute( + f"UPDATE quest_definitions SET {', '.join(assignments)} " + "WHERE id = ?", + tuple(parameters), + ) + return result.rowcount + + async def set_active(self, definition_id: int, is_active: bool) -> int: + """Deactivate/reactivate; returns rows updated (0 if absent). + + Deactivating stops future instance generation but leaves + existing instances and completion history untouched. + """ + result = await self._database.execute( + "UPDATE quest_definitions SET is_active = ? WHERE id = ?", + (int(is_active), definition_id), + ) + return result.rowcount + + async def add_assignee( + self, definition_id: int, child_id: int + ) -> list[ChildRecord]: + """Assign ``child_id`` to the definition (idempotent). + + 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. Returns the + definition's assignees read back INSIDE the same transaction + that performed the write, so the returned roster is a + consistent post-assignment snapshot — a concurrent assign or + unassign cannot race it after the write commits. 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) + definition = await self._database.fetch_one( + "SELECT 1 FROM quest_definitions WHERE id = ?", + (definition_id,), + ) + 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), + ) + return await self.list_assignees(definition_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 upsert_window( + self, + definition_id: int, + window: str, + *, + due_time: str | None = None, + ) -> QuestDefinitionWindowRecord: + """Declare (or re-declare) ``window`` on the definition. + + The composite primary key makes the write idempotent per + (definition, window): re-declaring updates ``due_time`` in + place instead of duplicating. The window name must be one of + ``const.QUEST_WINDOWS`` (the schema CHECK backs this up) and a + provided ``due_time`` must be strict 24-hour HH:MM. The + definition must exist — validated under the connection lock so + the verdict cannot go stale before the write. + """ + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + async with _connection_lock(self._database): + async with self._database.transaction(): + definition = await self._database.fetch_one( + "SELECT 1 FROM quest_definitions WHERE id = ?", + (definition_id,), + ) + if definition is None: + raise ValueError( + f"quest definition {definition_id} does not exist" + ) + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, ?) " + "ON CONFLICT (definition_id, window) DO UPDATE SET " + "due_time = excluded.due_time", + (definition_id, window, due_time), + ) + row = await self._database.fetch_one( + f"SELECT {_WINDOW_COLUMNS} " + "FROM quest_definition_windows " + "WHERE definition_id = ? AND window = ?", + (definition_id, window), + ) + assert row is not None + return _window_from_row(row) + + async def list_windows( + self, definition_id: int + ) -> list[QuestDefinitionWindowRecord]: + """Return the definition's windows in ``const`` order. + + Ordered by the canonical window sequence (morning, afternoon, + evening) rather than insertion order, so callers always render + the Quest Log's three columns consistently. A definition with + no windows returns []. + """ + rows = await self._database.fetch_all( + f"SELECT {_WINDOW_COLUMNS} FROM quest_definition_windows " + "WHERE definition_id = ?", + (definition_id,), + ) + by_name = {row[1]: _window_from_row(row) for row in rows} + return [ + by_name[name] + for name in QUEST_WINDOWS + if name in by_name + ] + + async def remove_window( + self, definition_id: int, window: str + ) -> bool: + """Remove the window declaration; True when a row was removed. + + Future materialization stops producing instances for this + (definition, window); existing instances and completion history + are never touched here (Feature 06 guardrail). + """ + _validate_window(window) + async with _connection_lock(self._database): + result = await self._database.execute( + "DELETE FROM quest_definition_windows " + "WHERE definition_id = ? AND window = ?", + (definition_id, window), + ) + return result.rowcount > 0 + + 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 INSERT commits. + """ + child = await self._database.fetch_one( + "SELECT is_active FROM children WHERE id = ?", (child_id,) + ) + if child is None: + raise ValueError(f"child {child_id} does not exist") + if not child[0]: + raise ValueError( + f"child {child_id} is inactive and cannot be assigned" + ) + + +async def load_materialization_input( + database: NestQuestDatabase, + range_start: str, + range_end: str, +): + """Read every materialization input in ONE locked transaction. + + Returns ``(snapshots, schedules, overrides)``: + + - ``snapshots``: every active definition with its rule, assignees and + windows (the :class:`QuestDefinitionSnapshot` shape). + - ``schedules`` / ``overrides``: the presence schedules and + range-scoped overrides of every assignee child, in + :mod:`.dao_presence`'s record shapes. + + The whole read — definitions, rules, assignees, windows, and the + assignees' presence schedules and overrides — runs inside a single + BEGIN..COMMIT span under the connection lock, so a concurrent edit + (definition, assignment, window, or presence) cannot interleave + between the parts and the walk never sees a mixed-time view. Each + table's SQL stays in its own DAO module: the presence portion is + delegated to :mod:`.dao_presence` (a lazy import here, since that + module imports this one). + """ + definitions_dao = QuestDefinitionsDao(database) + async with _connection_lock(database): + async with database.transaction(): + snapshots = await definitions_dao.read_snapshots_active() + child_ids = sorted( + { + child.id + for snapshot in snapshots + for child in snapshot.assignees + } + ) + from . import dao_presence # lazy: dao_presence imports us + + schedules, overrides = await dao_presence.read_snapshot_unlocked( + database, child_ids, range_start, range_end + ) + return snapshots, schedules, overrides \ No newline at end of file diff --git a/custom_components/nestquest/core/db.py b/custom_components/nestquest/core/db.py new file mode 100644 index 0000000..f72d7ec --- /dev/null +++ b/custom_components/nestquest/core/db.py @@ -0,0 +1,436 @@ +"""Async SQLite connection wrapper for the NestQuest integration.""" +from __future__ import annotations + +import asyncio +import sqlite3 +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, TypeVar + +T = TypeVar("T") + +#: Executor contract: schedule a synchronous callable on a worker +#: thread/futures pool and await its result. Mirrors the shape of +#: ``hass.async_add_executor_job`` so the integration wires it in with a +#: bare method reference, but this package never imports Home Assistant. +Executor = Callable[..., Awaitable[T]] + + +@dataclass(frozen=True) +class ExecutionResult: + """Plain-data outcome of one statement. + + ``rowcount`` and ``lastrowid`` mirror the corresponding + :class:`sqlite3.Cursor` attributes; both are captured on the executor + thread so no sqlite3 object ever crosses back to the event loop. + """ + + rowcount: int + lastrowid: int | None + + +class NestQuestDatabase: + """Async wrapper around a single SQLite connection. + + Every sqlite3 call is delegated to a worker through the ``executor`` + callable passed at construction (mirroring the contract of + ``hass.async_add_executor_job``) so the event loop is never blocked, + and only plain Python data (row tuples and + :class:`ExecutionResult`) is returned to the event loop. + + Every operation is serialized through one asyncio.Lock per wrapper. A + ``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so + interleaved coroutines cannot corrupt the transaction and concurrent + BEGINs are impossible; statements issued while a transaction is open + queue until it completes. Concurrent transactions on one wrapper are + not supported: a second ``transaction()`` entered while one is active + raises :class:`RuntimeError`. ``close()`` serializes on the same lock + and is idempotent. + + Every connection-affecting executor job (connect, pragmas, statements, + transaction lifecycle and close) is awaited through the settlement + helper :meth:`_await_settled`: if the awaiting task is cancelled while + a job is in flight, the job still settles on the worker thread before + the cancellation propagates, so the wrapper lock is never released + while the executor thread is mid-job. + """ + + def __init__(self, executor: Callable[..., Awaitable[T]]) -> None: + """Initialize the wrapper around an executor callable. + + ``executor`` schedules a synchronous callable on a worker and + returns an awaitable of its result — the contract of + ``hass.async_add_executor_job``. The integration passes that + method directly; tests pass a helper backed by + :func:`asyncio.to_thread` or an inline runner. + """ + self._executor: Callable[..., Awaitable[Any]] = executor + self._conn: sqlite3.Connection | None = None + self._rowcount: int = -1 + self._lastrowid: int | None = None + self._lock: asyncio.Lock | None = None + self._lock_loop: asyncio.AbstractEventLoop | None = None + self._in_transaction = False + self._tx_task: asyncio.Task | None = None + + def _get_lock(self) -> asyncio.Lock: + """Return the per-wrapper lock, (re)bound to the running loop. + + HA drives every wrapper call from one event loop, so the lock is + created once and reused; re-binding only when the running loop + differs keeps the wrapper usable from plain synchronous drivers + that run each call on a fresh loop. + """ + loop = asyncio.get_running_loop() + if self._lock is None or self._lock_loop is not loop: + self._lock = asyncio.Lock() + self._lock_loop = loop + return self._lock + + @property + def connected(self) -> bool: + """Return True while the underlying connection is open.""" + return self._conn is not None + + @property + def rowcount(self) -> int: + """Row count of the most recent statement, or -1 when unset.""" + return self._rowcount + + @property + def lastrowid(self) -> int | None: + """Row id of the most recent insert, or None when unset.""" + return self._lastrowid + + @asynccontextmanager + async def _serialized(self) -> AsyncIterator[None]: + """Hold the wrapper lock unless the caller owns the active transaction. + + A transaction holds the lock for its whole BEGIN..COMMIT span; + statements issued by the transaction's own task are already + serialized by it and must not re-acquire the non-reentrant lock. + Any other task queues on the lock until the transaction ends. + """ + if ( + self._in_transaction + and self._tx_task is not None + and asyncio.current_task() is self._tx_task + ): + yield + return + async with self._get_lock(): + yield + + async def open(self, path: Path | str) -> NestQuestDatabase: + """Open the SQLite file with WAL journal mode and foreign keys on. + + Cancellation-safe: if this call is cancelled while the connect job + is in flight, the job still settles (see :meth:`_await_settled`). + A connection the settled job created is never adopted by the + cancelled call; it is closed through a settled executor job instead + of being abandoned to garbage collection, and the cancellation + propagates afterwards, so a follow-up ``open()`` starts from a + clean state. + """ + if self._conn is not None: + return self + async with self._get_lock(): + if self._conn is not None: + return self + + def _open() -> sqlite3.Connection: + conn = sqlite3.connect( + path, check_same_thread=False, isolation_level=None + ) + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + except BaseException: + conn.close() + raise + return conn + + settled, cancelled = await self._await_settled_state( + self._executor(_open) + ) + if cancelled: + if isinstance(settled, sqlite3.Connection): + + def _close_abandoned() -> None: + settled.close() + + await self._await_settled( + self._executor(_close_abandoned) + ) + raise asyncio.CancelledError() + self._conn = settled + return self + + async def execute( + self, sql: str, parameters: Sequence[Any] = () + ) -> ExecutionResult: + """Run one statement in the executor and return its plain result.""" + async with self._serialized(): + conn = self._require_conn() + + def _execute() -> ExecutionResult: + cursor = conn.execute(sql, parameters) + return ExecutionResult(cursor.rowcount, cursor.lastrowid) + + result = await self._await_settled( + self._executor(_execute) + ) + self._rowcount = result.rowcount + self._lastrowid = result.lastrowid + return result + + async def execute_many( + self, sql: str, parameters: Sequence[Sequence[Any]] + ) -> ExecutionResult: + """Run executemany in the executor and return its plain result.""" + async with self._serialized(): + conn = self._require_conn() + + def _execute_many() -> ExecutionResult: + cursor = conn.executemany(sql, parameters) + return ExecutionResult(cursor.rowcount, cursor.lastrowid) + + result = await self._await_settled( + self._executor(_execute_many) + ) + self._rowcount = result.rowcount + self._lastrowid = result.lastrowid + return result + + async def fetch_one( + self, sql: str, parameters: Sequence[Any] = () + ) -> tuple | None: + """Run a query in the executor and return its first row, or None.""" + async with self._serialized(): + conn = self._require_conn() + + def _fetch_one() -> tuple[tuple | None, int, int | None]: + cursor = conn.execute(sql, parameters) + return cursor.fetchone(), cursor.rowcount, cursor.lastrowid + + row, rowcount, lastrowid = await self._await_settled( + self._executor(_fetch_one) + ) + self._rowcount = rowcount + self._lastrowid = lastrowid + return row + + async def fetch_all( + self, sql: str, parameters: Sequence[Any] = () + ) -> list[tuple]: + """Run a query in the executor and return all of its rows.""" + async with self._serialized(): + conn = self._require_conn() + + def _fetch_all() -> tuple[list[tuple], int, int | None]: + cursor = conn.execute(sql, parameters) + return cursor.fetchall(), cursor.rowcount, cursor.lastrowid + + rows, rowcount, lastrowid = await self._await_settled( + self._executor(_fetch_all) + ) + self._rowcount = rowcount + self._lastrowid = lastrowid + return rows + + @asynccontextmanager + async def transaction(self) -> AsyncIterator[None]: + """Run a block inside BEGIN/COMMIT, rolling back on any exception. + + The wrapper lock is held for the entire BEGIN..COMMIT span, so no + other statement can interleave inside the transaction. Entering a + second transaction while one is active raises RuntimeError. A + failing COMMIT (e.g. a deferred foreign-key violation) also triggers + a ROLLBACK before the commit error propagates, so the real + connection is never left inside a stray transaction. + + The lifecycle is cancellation-safe. Transaction ownership and the + wrapper lock are only released after the executor work has fully + settled, even when the owning task is cancelled mid-flight: if a + cancelled BEGIN or COMMIT still completes on the worker thread, + the corresponding ROLLBACK is shielded and awaited before any state + is cleared. A lifecycle statement that fails plus a failing + follow-up cleanup surface both errors in an ExceptionGroup. + """ + if self._in_transaction: + raise RuntimeError( + "A NestQuest transaction is already active on this " + "connection; concurrent transactions are not supported" + ) + async with self._get_lock(): + conn = self._require_conn() + self._in_transaction = True + self._tx_task = asyncio.current_task() + try: + + def _begin() -> None: + conn.execute("BEGIN") + + await self._settle_lifecycle( + conn, + self._executor(_begin), + "BEGIN did not settle cleanly and the follow-up " + "cleanup also raised; the transaction may need manual " + "cleanup", + ) + try: + yield + except BaseException: + + def _rollback() -> None: + conn.execute("ROLLBACK") + + await self._settle_lifecycle( + conn, + self._executor(_rollback), + "ROLLBACK failed and the follow-up cleanup also " + "raised; the transaction may need manual cleanup", + ) + raise + + def _commit() -> None: + conn.execute("COMMIT") + + await self._settle_lifecycle( + conn, + self._executor(_commit), + "COMMIT failed and the follow-up cleanup also raised; " + "the transaction may need manual cleanup", + ) + finally: + self._in_transaction = False + self._tx_task = None + + async def _settle_lifecycle( + self, conn: sqlite3.Connection, job: Awaitable[Any], message: str + ) -> None: + """Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean. + + The job always settles first (see :meth:`_await_settled`), so a + cancelled statement that the worker thread completed anyway is + never abandoned mid-flight. If the job raised, a follow-up + ROLLBACK — itself settled — guarantees the connection ends outside + any SQLite transaction before the error propagates. When both the + job and the cleanup raise, both errors are preserved in an + ExceptionGroup instead of the original being lost. + """ + try: + await self._await_settled(job) + except BaseException as settle_error: + errors: list[BaseException] = [settle_error] + try: + await self._ensure_no_stray_transaction(conn) + except BaseException as cleanup_error: + errors.append(cleanup_error) + if len(errors) == 2: + raise BaseExceptionGroup(message, errors) from settle_error + raise + + async def _ensure_no_stray_transaction( + self, conn: sqlite3.Connection + ) -> None: + """Force the connection out of any transaction via ROLLBACK. + + Runs after a failed or cancelled lifecycle statement. The worker + thread may still be finishing (or may already have completed) the + very statement whose await was cancelled, so both the check and + the ROLLBACK settle in the executor before the caller releases + transaction ownership. + """ + + def _cleanup() -> bool: + if conn.in_transaction: + conn.execute("ROLLBACK") + return conn.in_transaction + + stray = await self._await_settled( + self._executor(_cleanup) + ) + if stray: + raise RuntimeError( + "The SQLite connection is still inside a transaction after " + "cleanup; the transaction may need manual cleanup" + ) + + async def _await_settled(self, job: Awaitable[Any]) -> Any: + """Await ``job``, letting it settle even if this task is cancelled. + + Cancellation is observed, not swallowed: it is re-raised only once + the job has fully settled, so the caller can finalize against a + known connection state instead of abandoning in-flight work. The + job is wrapped in a task because a bare coroutine dies un-awaited + once its await is cancelled. A second cancellation during + settlement is absorbed the same way; if the job itself fails after + a cancellation, both errors surface in an ExceptionGroup. + """ + result, cancelled = await self._await_settled_state(job) + if cancelled: + raise asyncio.CancelledError() + return result + + async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]: + """Await ``job`` to settlement and report ``(result, cancelled)``. + + Same settlement guarantees as :meth:`_await_settled`, but returns + the settled outcome together with whether the awaiting task was + cancelled, so callers that must act on a settled result before + re-raising cancellation (e.g. closing an abandoned connection) + can do so without losing the result. + """ + cancelled = False + inner = asyncio.ensure_future(job) + while not inner.done(): + try: + await asyncio.shield(inner) + except asyncio.CancelledError: + cancelled = True + try: + result = inner.result() + except BaseException as job_error: + if cancelled: + raise BaseExceptionGroup( + "executor job failed while settling after cancellation", + [asyncio.CancelledError(), job_error], + ) from job_error + raise + return result, cancelled + + async def close(self) -> None: + """Close the connection in the executor; safe to call repeatedly. + + Serialized on the wrapper lock, so an in-flight operation or open + transaction always completes before the connection is closed. + """ + if ( + self._in_transaction + and self._tx_task is not None + and asyncio.current_task() is self._tx_task + ): + raise RuntimeError( + "Cannot close the NestQuest database from inside its own " + "transaction; exit the transaction first" + ) + async with self._get_lock(): + conn, self._conn = self._conn, None + self._rowcount, self._lastrowid = -1, None + if conn is None: + return + + def _close() -> None: + conn.close() + + await self._await_settled(self._executor(_close)) + + def _require_conn(self) -> sqlite3.Connection: + if self._conn is None: + raise RuntimeError( + "The NestQuest database connection is closed; call open() first" + ) + return self._conn \ No newline at end of file diff --git a/custom_components/nestquest/core/migrations.py b/custom_components/nestquest/core/migrations.py new file mode 100644 index 0000000..cf40a7a --- /dev/null +++ b/custom_components/nestquest/core/migrations.py @@ -0,0 +1,593 @@ +"""Versioned schema migration runner for the NestQuest SQLite database. + +The runner owns one metadata table, ``nestquest_schema_version``, holding +a single row with the schema version the database file currently +reflects. Migrations are declared as an ordered list: entry ``n`` +upgrades version ``n-1`` to version ``n``. Applying a migration means +running its DDL inside one transaction and stamping the new version in +that same transaction, so a database never persists a half-applied +migration: any failure rolls the statements and the version stamp back +together, leaving the file at its prior version. + +Version 0 means "the file exists but carries no NestQuest schema yet" +— a fresh file, or a file this integration has never written. Starting +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. 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. Migration 4 adds the +windows table, migration 5 rebuilds ``quest_instances`` onto the +widened (definition_id, child_id, due_date, window) key, and migration +6 rebuilds ``completion_events`` with the D-008 ``actor_child_id`` +column. Migration 7 adds the generic ``nestquest_meta_state`` +key/value table for integration-owned bookkeeping (Feature 11's +missed-sweep watermark). 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 ``(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 +UPDATE because a version-0 database has no version row to update yet. +An UPDATE form would silently match zero rows there and leave the +database migrated but unstamped — reporting version 0 forever, and +re-applying migration 1 on every startup. The DELETE+INSERT shape is +also what makes the stamp a replace, so the row never duplicates. + +Idempotence across restarts: a database already at the latest version +has no pending migrations, so the runner opens a read-only check, finds +nothing to do, and leaves the file byte-untouched (no write transaction +is opened, so no WAL churn). + +Migration list policy: entries are append-only. Once shipped, a +migration's SQL must never be edited — an old database applying it for +the first time and a fresh database would then diverge. Fix-forward +with a new migration instead. The version stamp lives in the same +SQLite file as the data it describes, so restoring a backup restores +its matching schema version with it. + +Corrupt or missing files are not this module's concern: startup +handling of those cases is task d5bca74b, which decides whether to +open fresh or raise ConfigEntryNotReady. This runner assumes it is +handed an open, working connection. +""" +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable, Sequence +from typing import Union + +from .db import NestQuestDatabase +from .schema import ( + COMPLETION_EVENTS_TABLE_SQL, + QUEST_DEFINITIONS_TABLE_SQL, + QUEST_INSTANCES_TABLE_SQL, + SCHEMA_V1_STATEMENTS, + SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, + SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, + SCHEMA_V7_META_STATE_DDL, +) + +LOGGER = logging.getLogger(__name__) + +#: One migration entry: either an ordered sequence of SQL statements or +#: 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 +#: never collide with a foreign ``schema_version`` table. +VERSION_TABLE = "nestquest_schema_version" + +#: DDL for the version metadata table. The singleton-row invariant is +#: enforced by the schema itself: ``id`` is fixed at 1 by CHECK, keyed +#: PRIMARY KEY, so the table can never hold a second row, and the +#: stamp write is an UPDATE (no DELETE+INSERT needed). ``version`` is +#: a positive integer: version 0 means "no row" and is never stored, +#: so a negative or zero stored value is corruption, not a state the +#: runner can act on. +VERSION_TABLE_DDL = f""" + CREATE TABLE IF NOT EXISTS {VERSION_TABLE} ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL CHECK (version >= 1) + ) +""" + +#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades +#: 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. +LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = ( + ("task_definitions", "quest_definitions"), + ("task_instances", "quest_instances"), +) + + +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) + + +#: Window assigned to instances migrated from the pre-window instance +#: shape (D-008). Pre-window rows were generated for whole-day +#: obligations; the morning window is the documented default landing +#: spot, and future dates are regenerated with real window declarations +#: by materialization (delete_future_uncompleted + re-run). +LEGACY_MIGRATED_WINDOW = "morning" + + +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 + 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 in its own transaction together + with the version stamp, so a crash rolls both back. + """ + 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") + + +async def _widen_instance_key( + database: NestQuestDatabase, target_version: int +) -> None: + """Bring ``quest_instances`` to the D-008 instance key. + + Databases whose instance table still carries the pre-window shape + (no ``window`` column, UNIQUE on (definition_id, due_date)) are + rebuilt via SQLite's canonical procedure into the widened shape — + UNIQUE (definition_id, child_id, due_date, window) — with every + migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing + ids (and the completion events referencing them) survive intact. + The old rows were unique per (definition, due_date), so pinning one + window cannot collide with the widened key. Fresh databases whose + table already matches the canonical shape only stamp. + + The rebuild runs with ``foreign_keys = OFF`` (dropping the old + table under enforcement would fire the implicit DELETE and violate + the ``completion_events`` foreign key) and verifies + ``PRAGMA foreign_key_check`` inside the transaction before the + stamp, so a corrupt copy aborts the whole migration. + """ + legacy_shape = not await _table_has_column( + database, "quest_instances", "window" + ) + await database.execute("PRAGMA foreign_keys = OFF") + try: + async with database.transaction(): + if legacy_shape: + await database.execute( + "CREATE TABLE quest_instances_rebuilt " + f"{QUEST_INSTANCES_TABLE_SQL}" + ) + await database.execute( + "INSERT INTO quest_instances_rebuilt " + "(id, definition_id, child_id, window, due_date, " + "due_time, generated_at) " + "SELECT id, definition_id, child_id, ?, due_date, " + "due_time, generated_at " + "FROM quest_instances ORDER BY id", + (LEGACY_MIGRATED_WINDOW,), + ) + await database.execute("DROP TABLE quest_instances") + await database.execute( + "ALTER TABLE quest_instances_rebuilt " + "RENAME TO quest_instances" + ) + violations = await database.fetch_all( + "PRAGMA foreign_key_check" + ) + if violations: + raise RuntimeError( + "instance rebuild produced foreign-key " + f"violations: {violations!r}" + ) + LOGGER.info( + "Widened quest_instances key; migrated rows pinned " + "to the %r window", + LEGACY_MIGRATED_WINDOW, + ) + await stamp_schema_version(database, target_version) + finally: + await database.execute("PRAGMA foreign_keys = ON") + + +async def _add_actor_child_id( + database: NestQuestDatabase, target_version: int +) -> None: + """Bring ``completion_events`` to the D-008 actor-child shape. + + The ``actor_child_id`` column records which panel profile was + tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone + because the canonical shape also carries an actor-pair coherence + CHECK (panel events require it, user events forbid it), and CHECK + constraints cannot be added to an existing table. Databases with + the legacy shape are therefore rebuilt via SQLite's canonical + procedure, backfilling each legacy panel row's ``actor_child_id`` + to ``child_id`` — pre-D-008 panel completions were always recorded + against the tapped child themselves — while admin rows stay NULL. + Row ids are preserved so ``quest_instances`` references and the + append-only history stay intact. Fresh databases already match + the canonical shape and only stamp. + + The rebuild runs with ``foreign_keys = OFF`` and verifies + ``PRAGMA foreign_key_check`` inside the transaction before the + stamp. The copy is an INSERT..SELECT, not an UPDATE: the + completion_events table keeps its append-only guarantee. + """ + legacy_shape = not await _table_has_column( + database, "completion_events", "actor_child_id" + ) + await database.execute("PRAGMA foreign_keys = OFF") + try: + async with database.transaction(): + if legacy_shape: + await database.execute( + "CREATE TABLE completion_events_rebuilt " + f"{COMPLETION_EVENTS_TABLE_SQL}" + ) + await database.execute( + "INSERT INTO completion_events_rebuilt " + "(id, instance_id, child_id, event_type, actor_source, " + "actor_user_id, actor_child_id, occurred_at, " + "was_on_time) " + "SELECT id, instance_id, child_id, event_type, " + "actor_source, actor_user_id, " + "CASE WHEN actor_source = 'panel' THEN child_id " + "ELSE NULL END, " + "occurred_at, was_on_time " + "FROM completion_events ORDER BY id" + ) + await database.execute("DROP TABLE completion_events") + await database.execute( + "ALTER TABLE completion_events_rebuilt " + "RENAME TO completion_events" + ) + violations = await database.fetch_all( + "PRAGMA foreign_key_check" + ) + if violations: + raise RuntimeError( + "completion_events rebuild produced foreign-key " + f"violations: {violations!r}" + ) + LOGGER.info( + "Added actor_child_id to completion_events; legacy " + "panel rows backfilled to their own child_id" + ) + 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 +#: 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, + _add_definition_assignees, + # Migration 4 (D-008): the windows table is a plain new table — a + # CREATE IF NOT EXISTS suffices for both fresh databases (which + # already made it in migration 1) and pre-window databases. + list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL), + # Migration 5 (D-008): rebuild quest_instances onto the widened + # (definition_id, child_id, due_date, window) key. + _widen_instance_key, + # Migration 6 (D-008): rebuild completion_events with the + # actor_child_id column and its actor-pair coherence CHECK, + # backfilling legacy panel rows to their own child_id. + _add_actor_child_id, + # Migration 7 (Feature 11): the generic meta_state key/value table + # — a plain new table, so CREATE IF NOT EXISTS suffices for both + # fresh databases (which already made it in migration 1) and + # pre-v7 databases. Strictly additive; no existing table changes. + list(SCHEMA_V7_META_STATE_DDL), +] + + +async def _create_version_table(database: NestQuestDatabase) -> None: + """Create the version metadata table if it does not exist. + + The table is created before any version check: it is the runner's + own bookkeeping, and creating it is idempotent. The DDL enforces + the singleton-row invariant (``id`` fixed at 1) and a positive + integer version at the database level. + """ + await database.execute(VERSION_TABLE_DDL) + + +async def read_schema_version(database: NestQuestDatabase) -> int: + """Return the schema version recorded in the database. + + Returns 0 when the version table or its row is missing — the + "fresh file" case. A stored version higher than the runner knows + is surfaced as-is; the caller decides how to treat a future version. + + Reading is deliberately passive: a missing table raises, it is not + swallowed into 0, because :func:`apply_migrations` always creates + the table before reading and a direct reader hitting "no such + table" is a caller bug worth surfacing, not a fresh file. + """ + row = await database.fetch_one( + f"SELECT version FROM {VERSION_TABLE} LIMIT 1" + ) + if row is None: + return 0 + version = row[0] + if not isinstance(version, int) or isinstance(version, bool): + raise RuntimeError( + f"Schema version metadata is not an integer: {version!r}" + ) + if version < 1: + raise RuntimeError( + f"Schema version metadata is invalid (must be >= 1): {version!r}" + ) + return version + + +#: One asyncio.Lock per connection wrapper, created lazily and bound to +#: the running loop, serializing whole apply_migrations runs so two +#: concurrent callers on one connection queue instead of colliding on +#: the wrapper's single-transaction rule. +_MIGRATION_LOCKS: dict[int, asyncio.Lock] = {} + + +def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock: + """Return the migration lock bound to ``database``'s running loop.""" + loop = asyncio.get_running_loop() + key = (id(database), id(loop)) + lock = _MIGRATION_LOCKS.get(key) + if lock is None: + lock = asyncio.Lock() + _MIGRATION_LOCKS[key] = lock + return lock + + +async def apply_migrations( + database: NestQuestDatabase, + migrations: Sequence[MigrationStep] = MIGRATIONS, +) -> int: + """Bring the database schema up to the latest version. + + Reads the current version, applies every pending migration in order + — each inside one transaction that also stamps the new version — + and returns the resulting version. Applying zero pending + migrations (database already current) is a no-op that logs at debug + level and opens no write transaction. + + The version is re-read inside each migration transaction before + deciding to apply it: the outer read is advisory only (loop bounds, + no-op fast path), and on one shared connection two interleaved + runners would otherwise both read the same version and both apply + a non-idempotent migration. Under the transaction's lock the + second runner sees the first runner's stamp and skips. + + Runners on one connection are serialized by an asyncio lock held + for the whole decide-and-apply span: NestQuestDatabase rejects a + second transaction while one is active (RuntimeError), so + concurrent callers must queue, and the lock makes that queueing + explicit. The loser of the race re-reads the version once it + acquires the lock, sees the winner's stamp, and applies nothing. + + Raises whatever the failing migration raised, with the database + rolled back to its prior version and prior rows untouched. + """ + async with _migration_lock(database): + await _create_version_table(database) + current = await read_schema_version(database) + latest = len(migrations) + + if current > latest: + raise RuntimeError( + f"Database schema version {current} is newer than this " + f"integration understands (latest known: {latest}); " + "downgrades are not supported" + ) + + if current == latest: + LOGGER.debug( + "NestQuest schema already at version %d; " + "no migrations to apply", + current, + ) + return current + + for target in range(current + 1, latest + 1): + applied = False + 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( + f"Database schema version {in_tx_version} is newer " + f"than this integration understands (latest known: " + f"{latest}); downgrades are not supported" + ) + if in_tx_version < target: + 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) + if applied: + LOGGER.info( + "NestQuest schema migrated to version %d", + target, + ) + + return latest \ No newline at end of file diff --git a/custom_components/nestquest/core/recurrence.py b/custom_components/nestquest/core/recurrence.py new file mode 100644 index 0000000..c114568 --- /dev/null +++ b/custom_components/nestquest/core/recurrence.py @@ -0,0 +1,621 @@ +"""The ScheduleRule model: rule types, validation, and serialization. + +A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure +frozen dataclass with no Home Assistant and no database imports. The +evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02) +serializes its dict form into the ``schedule_rules`` table. + +Type model: one enum member per recurrence shape. ``MONTHLY_DAY`` +("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second +Tuesday of every month") are distinct types rather than one monthly +type with optional fields, so validation is total: a rule either +constructs, or raises :class:`RuleValidationError` naming the offending +field. Every type carries ONLY the fields its shape needs; carrying +any other shape's field is a construction error. + +Storage mapping (Feature 02, ``schedule_rules`` table): the enum's +``storage_value`` maps model types onto the schema's CHECK values — +MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are +disambiguated by which fields are populated; CUSTOM_DAYS stores as +``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the +table stores (one element for MONTHLY_WEEKDAY, which names the +position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field +carried through from_dict/to_dict for losslessness and folded into +``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the +model-side lossless pair; the DB mapping lives in the DAO layer and is +exercised there. + +Validation policy (fail fast at construction — the schema's CHECK +constraints are a second line of defense, but invalid rules must never +reach storage): + +- ``interval`` >= 1 for every rule. +- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden + elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans. +- ``day_of_month`` 1..31 is required for MONTHLY_DAY. +- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is + rejected) with a weekday 0..6 for MONTHLY_WEEKDAY. +- ``month`` 1..12 is required for YEARLY. +- ``start_date`` is a strict ISO date; ``end_date`` is optional and, + when present, on or after ``start_date``. + +Month-end policy (implemented in :func:`_occurs_monthly_day` and +documented for Feature 04): in each interval-eligible month, a +MONTHLY_DAY rule for a day that does not exist in that month — the +31st in April, the 30th or 29th in February, February 29th in a +non-leap year — fires on the LAST day of the month instead of being +skipped. A monthly chore still happens in every eligible month, +pinned to the month's end when its nominal date is absent; exactly one +day per eligible month matches the rule (nominal day when it exists, +month end when it does not). The interval anchors on nominal months +elapsed from start_date's month, so clamping never shifts the cadence. +""" +from __future__ import annotations + +import datetime +import enum +from dataclasses import dataclass, field + +#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers. +_DATE_FORMAT = "%Y-%m-%d" + +#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order). + + +class RuleType(enum.Enum): + """The six recurrence shapes the engine supports. + + ``storage_value`` is the string written to the ``schedule_rules`` + table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY + and MONTHLY_WEEKDAY are distinct shapes sharing one storage value, + disambiguated by which fields are populated. + """ + + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY_DAY = "monthly_day" + MONTHLY_WEEKDAY = "monthly_weekday" + YEARLY = "yearly" + CUSTOM_DAYS = "custom_days" + + @property + def storage_value(self) -> str: + """The schema-level rule_type string this model type maps to.""" + return { + RuleType.DAILY: "daily", + RuleType.WEEKLY: "weekly", + RuleType.MONTHLY_DAY: "monthly", + RuleType.MONTHLY_WEEKDAY: "monthly", + RuleType.YEARLY: "yearly", + RuleType.CUSTOM_DAYS: "custom", + }[self] + + +class RuleValidationError(ValueError): + """Raised when a ScheduleRule field combination is invalid.""" + + +def _validate_date(value: str, field_name: str) -> None: + """Raise unless ``value`` is a strict YYYY-MM-DD calendar date.""" + try: + parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date() + except (TypeError, ValueError): + raise RuleValidationError( + f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}" + ) from None + if parsed.isoformat() != value: + raise RuleValidationError( + f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}" + ) + + +def _validate_weekday_set(weekday_set) -> frozenset[int]: + """Return a normalized frozenset or raise with a clear message. + + Sequence entries are validated BEFORE set() coercion: a list like + [0, False] must not collapse the boolean into 0, and unhashable + entries must raise RuleValidationError, never a raw TypeError. + """ + if isinstance(weekday_set, (list, tuple)): + for entry in weekday_set: + # _is_plain_int: bool and int subclasses (potentially + # unhashable) are rejected before set() coercion. + if not _is_plain_int(entry): + raise RuleValidationError( + f"weekday_set entries must be plain integers, got " + f"{entry!r}" + ) + weekday_set = set(weekday_set) + if not isinstance(weekday_set, (set, frozenset)): + raise RuleValidationError( + f"weekday_set must be a set of weekday integers, got " + f"{type(weekday_set).__name__}" + ) + if not weekday_set: + raise RuleValidationError( + "weekday_set must not be empty for weekly/custom rules" + ) + for entry in weekday_set: + if not _is_plain_int(entry): + raise RuleValidationError( + f"weekday_set entries must be plain integers, got " + f"{entry!r}" + ) + if not 0 <= entry <= 6: + raise RuleValidationError( + f"weekday_set entries must be 0 (Monday)..6 (Sunday), " + f"got {entry!r}" + ) + return frozenset(weekday_set) + + +def _is_plain_int(value) -> bool: + """True only for real ints: bool excluded AND int subclasses too + (an unhashable int subclass would break the frozen-hashable model).""" + return type(value) is int + + +def _validate_monthly_weekday(nth_weekday, weekday) -> None: + """Validate the MONTHLY_WEEKDAY position fields.""" + if nth_weekday is None or not _is_plain_int(nth_weekday): + raise RuleValidationError( + "nth_weekday must be an integer for monthly_weekday rules, " + f"got {nth_weekday!r}" + ) + if not (-1 <= nth_weekday <= 5) or nth_weekday == 0: + raise RuleValidationError( + f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}" + ) + if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6: + raise RuleValidationError( + f"monthly_weekday rules require a weekday 0 (Monday)..6 " + f"(Sunday), got {weekday!r}" + ) + + + + + +@dataclass(frozen=True) +class ScheduleRule: + """An immutable, validated recurrence rule (see module docstring). + + Construct with keyword arguments; invalid combinations raise + :class:`RuleValidationError` at construction time. Fields map + one-to-one onto the ``schedule_rules`` table columns, with + ``nth_weekday_weekday`` as the model-only weekday companion to + ``nth_weekday`` (serialized inside ``weekday_set`` at the DAO + boundary). + """ + + rule_type: RuleType + interval: int = 1 + weekday_set: frozenset[int] | None = None + day_of_month: int | None = None + nth_weekday: int | None = None + nth_weekday_weekday: int | None = None + month: int | None = None + start_date: str = "1970-01-01" + end_date: str | None = None + + def __post_init__(self) -> None: + # Normalize a string rule_type into the enum; anything else + # that is not already a RuleType is rejected. + if isinstance(self.rule_type, str): + # EXACT model names only: no whitespace stripping, no storage + # aliases. The DAO maps storage strings to model fields when + # reading rows; the model boundary stays unambiguous. + candidates = {rt.name.lower(): rt for rt in RuleType} + if self.rule_type in candidates: + object.__setattr__( + self, "rule_type", candidates[self.rule_type] + ) + else: + raise RuleValidationError( + f"rule_type must be a RuleType or one of " + f"{sorted(candidates)}, got {self.rule_type!r}" + ) + if not isinstance(self.rule_type, RuleType): + raise RuleValidationError( + f"rule_type must be a RuleType or one of its string " + f"values, got {self.rule_type!r}" + ) + if not _is_plain_int(self.interval): + raise RuleValidationError( + f"interval must be an integer >= 1, got {self.interval!r}" + ) + if self.interval < 1: + raise RuleValidationError( + f"interval must be >= 1, got {self.interval!r}" + ) + + _validate_date(self.start_date, "start_date") + if self.end_date is not None: + _validate_date(self.end_date, "end_date") + if self.end_date < self.start_date: + raise RuleValidationError( + f"end_date {self.end_date!r} must be on or after " + f"start_date {self.start_date!r}" + ) + + # Type-exclusive field policy, enforced for every type: build + # the dict of present fields, then reject every field the shape + # must not carry (weekday_set present is detectable via None vs + # set even after normalization below). + shape = self.rule_type + if shape is RuleType.DAILY: + forbidden_fields = ( + "weekday_set", + "day_of_month", + "nth_weekday", + "nth_weekday_weekday", + "month", + ) + elif shape is RuleType.WEEKLY: + forbidden_fields = ( + "day_of_month", + "nth_weekday", + "nth_weekday_weekday", + "month", + ) + elif shape is RuleType.CUSTOM_DAYS: + forbidden_fields = ( + "day_of_month", + "nth_weekday", + "nth_weekday_weekday", + "month", + ) + elif shape is RuleType.MONTHLY_DAY: + forbidden_fields = ("weekday_set", "nth_weekday", + "nth_weekday_weekday", "month") + elif shape is RuleType.MONTHLY_WEEKDAY: + forbidden_fields = ("weekday_set", "day_of_month", "month") + elif shape is RuleType.YEARLY: + forbidden_fields = ( + "weekday_set", + "nth_weekday", + "nth_weekday_weekday", + ) + present_forbidden = [ + name + for name in forbidden_fields + if getattr(self, name) is not None + ] + if present_forbidden: + raise RuleValidationError( + f"{shape.value} rules must not set " + f"{', '.join(sorted(present_forbidden))}" + ) + + # Required fields per shape. + if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: + normalized = _validate_weekday_set(self.weekday_set) + object.__setattr__(self, "weekday_set", normalized) + elif shape is RuleType.MONTHLY_DAY: + if self.day_of_month is None or not _is_plain_int( + self.day_of_month + ): + raise RuleValidationError( + "monthly_day rules require day_of_month (1..31)" + ) + if not 1 <= self.day_of_month <= 31: + raise RuleValidationError( + f"day_of_month must be 1..31, got {self.day_of_month!r}" + ) + elif shape is RuleType.MONTHLY_WEEKDAY: + _validate_monthly_weekday( + self.nth_weekday, self.nth_weekday_weekday + ) + elif shape is RuleType.YEARLY: + if self.month is None or not _is_plain_int(self.month): + raise RuleValidationError( + "yearly rules require month (1..12)" + ) + if not 1 <= self.month <= 12: + raise RuleValidationError( + f"month must be 1..12, got {self.month!r}" + ) + if self.day_of_month is not None and not ( + _is_plain_int(self.day_of_month) + and 1 <= self.day_of_month <= 31 + ): + raise RuleValidationError( + f"day_of_month must be 1..31 for yearly rules, got " + f"{self.day_of_month!r}" + ) + + def to_dict(self) -> dict: + """Return the lossless dict form (model-side storage shape). + + ``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's + weekday travels inside it (appended) plus + ``nth_weekday_weekday`` so the pair round-trips without + ambiguity at the model layer. + """ + return { + "rule_type": self.rule_type.name.lower(), # model name + "interval": self.interval, + "weekday_set": ( + sorted(self.weekday_set) if self.weekday_set is not None + else None + ), + "day_of_month": self.day_of_month, + "nth_weekday": self.nth_weekday, + "nth_weekday_weekday": self.nth_weekday_weekday, + "month": self.month, + "start_date": self.start_date, + "end_date": self.end_date, + } + + @classmethod + def from_dict(cls, data: dict) -> "ScheduleRule": + """Rebuild a rule from :meth:`to_dict` output. + + Raises :class:`RuleValidationError` on unknown keys, missing + keys, or invalid combinations — never a raw TypeError. + """ + if not isinstance(data, dict): + raise RuleValidationError( + f"rule data must be a dict, got {type(data).__name__}" + ) + allowed = { + "rule_type", + "interval", + "weekday_set", + "day_of_month", + "nth_weekday", + "nth_weekday_weekday", + "month", + "start_date", + "end_date", + } + unknown = [key for key in data if key not in allowed] + if unknown: + raise RuleValidationError( + f"unknown rule fields: {sorted(unknown, key=repr)}" + ) + if "rule_type" not in data: + raise RuleValidationError("missing required rule field: rule_type") + weekday_set = data.get("weekday_set") + if isinstance(weekday_set, (list, tuple)): + # Validate entries as plain ints BEFORE set() coercion, so a + # list like [0, False] cannot collapse False into 0 and an + # unhashable/int-subclass entry cannot leak a raw TypeError. + for entry in weekday_set: + if not _is_plain_int(entry): + raise RuleValidationError( + f"weekday_set entries must be plain integers, got " + f"{entry!r}" + ) + weekday_set = set(weekday_set) + return cls( + rule_type=data["rule_type"], + interval=data.get("interval", 1), + weekday_set=weekday_set, + day_of_month=data.get("day_of_month"), + nth_weekday=data.get("nth_weekday"), + nth_weekday_weekday=data.get("nth_weekday_weekday"), + month=data.get("month"), + start_date=data.get("start_date", "1970-01-01"), + end_date=data.get("end_date"), + ) + + +# --------------------------------------------------------------------------- +# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule +# --------------------------------------------------------------------------- + + +def _parse_date(value: str) -> datetime.date: + """Parse a strict ISO date already validated by the model.""" + return datetime.datetime.strptime(value, _DATE_FORMAT).date() + + +def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int: + """Day offset from the rule's anchor date to ``target``. + + The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the + first day the rule CAN fire. A negative offset means the target + precedes the rule's window entirely. + """ + return (target - _parse_date(rule.start_date)).days + + +def _within_window(rule: ScheduleRule, target: datetime.date) -> bool: + """True when ``target`` is inside the rule's [start, end] window.""" + if target < _parse_date(rule.start_date): + return False + if rule.end_date is not None and target > _parse_date(rule.end_date): + return False + return True + + +def _month_end(year: int, month: int) -> int: + """The last calendar day of the given month (leap-aware).""" + if month == 12: + return 31 + return (datetime.date(year, month + 1, 1) + - datetime.timedelta(days=1)).day + + +def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool: + """MONTHLY_DAY evaluation with the documented month-end policy. + + Policy: in each interval-eligible month, a rule for a day-of-month + that does not exist in that month (the 31st in April, the 30th in + February, the 29th of February in a non-leap year) fires on the + LAST day of that month instead of being skipped — the chore still + happens that month, pinned to the month's end when its nominal date + is absent. + + The interval anchors on months ELAPSED from start_date's month: the + rule fires when the months between the anchor month and the target's + month form a multiple of the interval, keeping "every 2 months on + the 15th" aligned to the anchor month even when the firing lands on + a clamped month-end day. + """ + anchor = _parse_date(rule.start_date) + months_elapsed = ( + (target.year - anchor.year) * 12 + (target.month - anchor.month) + ) + if months_elapsed % rule.interval != 0: + return False + month_end = _month_end(target.year, target.month) + return target.day == min(rule.day_of_month, month_end) + + +def _nth_weekday_of_month( + year: int, month: int, weekday: int, nth: int +) -> int | None: + """The day-of-month of the ``nth`` ``weekday``, or None when the + month has no such occurrence (``nth`` 5 beyond four occurrences). + + Positive ``nth`` counts forward (1 = first); ``-1`` counts from the + end (last occurrence, whatever the total). Pure calendar math. + """ + if nth == -1: + last = datetime.date(year, month, _month_end(year, month)) + back = (last.weekday() - weekday) % 7 + return last.day - back + first = datetime.date(year, month, 1) + first_offset = (weekday - first.weekday()) % 7 + day = 1 + first_offset + (nth - 1) * 7 + if day > _month_end(year, month): + return None + return day + + +def _occurs_monthly_weekday( + rule: ScheduleRule, target: datetime.date +) -> bool: + """MONTHLY_WEEKDAY evaluation. + + Fires when the target is the ``nth`` occurrence of + ``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A + month with fewer occurrences than ``nth`` (5 on a four-occurrence + month) does not fire — n 5 is accepted by the model but skips such + months, matching the done-condition. The interval anchors on + nominal months elapsed from start_date's month. + """ + anchor = _parse_date(rule.start_date) + months_elapsed = ( + (target.year - anchor.year) * 12 + (target.month - anchor.month) + ) + if months_elapsed % rule.interval != 0: + return False + if target.weekday() != rule.nth_weekday_weekday: + return False + nth_day = _nth_weekday_of_month( + target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday + ) + return nth_day is not None and target.day == nth_day + + +def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool: + """YEARLY evaluation with the documented leap-day clamp policy. + + Policy: in each interval-eligible year, the rule fires on + ``day_of_month`` of ``month`` when that day exists. A YEARLY rule + for February 29th fires on FEBRUARY 28TH in a non-leap year (the + same clamp-as-month-end policy as MONTHLY_DAY: the annual chore + still happens every year, pinned to February's end when its + nominal date is absent) rather than silently vanishing. A YEARLY + rule with NO day_of_month fires on the first day of ``month``. + The interval anchors on years elapsed from start_date's year. + """ + anchor = _parse_date(rule.start_date) + years_elapsed = target.year - anchor.year + if years_elapsed % rule.interval != 0: + return False + if target.month != rule.month: + return False + if rule.day_of_month is None: + # Day-less yearly rules fire on the first of the month. + return target.day == 1 + month_end = _month_end(target.year, target.month) + return target.day == min(rule.day_of_month, month_end) + + +def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: + """Return True when ``rule`` fires on ``target``. + + Pure function: no I/O, no timezone awareness, no mutation. Dates + are plain calendar dates (Feature 04 guardrail); the caller decides + the timezone that produced them. + """ + # Dispatch FIRST: an unimplemented shape must raise loudly even for + # dates outside the window, never silently evaluate to False. + shape = rule.rule_type + if shape not in ( + RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY, + ): + raise NotImplementedError( + f"occurs_on for {shape} lands with its own engine task " + "(Features 04 task sequence)" + ) + if not _within_window(rule, target): + return False + if shape is RuleType.DAILY: + offset = (target - _parse_date(rule.start_date)).days + return offset % rule.interval == 0 + if shape is RuleType.MONTHLY_DAY: + return _occurs_monthly_day(rule, target) + if shape is RuleType.MONTHLY_WEEKDAY: + return _occurs_monthly_weekday(rule, target) + if shape is RuleType.YEARLY: + return _occurs_yearly(rule, target) + # WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never + # ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO + # parity on January 1st; anchor arithmetic does not). + anchor = _parse_date(rule.start_date) + if shape is RuleType.CUSTOM_DAYS: + interval = 1 + else: + interval = rule.interval + week_offset = (target - anchor).days // 7 + if week_offset % interval != 0: + return False + return target.weekday() in rule.weekday_set + + +def occurrences_between( + rule: ScheduleRule, start: str, end: str +) -> list[str]: + """Return every date string in [start, end] the rule fires on. + + Inclusive on both ends. Raises RuleValidationError on malformed or + inverted bounds; a window entirely outside [rule.start_date, + rule.end_date] yields an empty list. + """ + if rule.rule_type not in ( + RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY, + ): + raise NotImplementedError( + f"occurrences_between for {rule.rule_type} lands with its own " + "engine task (Features 04 task sequence)" + ) + _validate_date(start, "start") + _validate_date(end, "end") + start_date = _parse_date(start) + end_date = _parse_date(end) + if end_date < start_date: + raise RuleValidationError( + f"end {end!r} must be on or after start {start!r}" + ) + rule_start = _parse_date(rule.start_date) + if end_date < rule_start: + return [] + # Clamp the walk to the rule's own window and the queried range. + effective_start = max(start_date, rule_start) + effective_end = end_date + if rule.end_date is not None: + effective_end = min(effective_end, _parse_date(rule.end_date)) + results: list[str] = [] + cursor = effective_start + while cursor <= effective_end: + if occurs_on(rule, cursor): + results.append(cursor.isoformat()) + cursor += datetime.timedelta(days=1) + return results diff --git a/custom_components/nestquest/core/schema.py b/custom_components/nestquest/core/schema.py new file mode 100644 index 0000000..b3c3223 --- /dev/null +++ b/custom_components/nestquest/core/schema.py @@ -0,0 +1,427 @@ +"""Declarative schema DDL for the NestQuest SQLite database. + +Every v1 table is declared here as plain SQL statements so later schema +tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without +touching this module's tables. All statements use +``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned +migration runner is a separate later concern. + +Timestamp policy: all timestamps are UTC ISO-8601 strings, set by the +caller/DAO rather than a DB default, per the feature guardrails. No +``length(created_at) = 25`` CHECK is added: the exact string shape +produced by callers is not fixed yet (``datetime.isoformat()`` may +include microseconds, changing the length), so enforcing a fixed length +here would reject valid values. Only non-null is enforced for now. + +Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD, +no time component) compared lexically by the ``end_date >= start_date`` +CHECK; due_date/due_time string shapes are caller-validated by the rule +engine and DAO layers, not by DDL. + +``weekday_set`` format: comma-separated weekday integers (CSV), 0 = +Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON +because it sorts, compares and parses trivially in SQL. The format is +enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB +patterns for element counts 1..7 of single digits 0-6 (GLOB has no +repetition quantifier, so the element count is enumerated; more than 7 +elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and +``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules. +This keeps the schema layer storage-only — the DAO layer (tasks +8c72300c+) does not need its own validator, it can rely on the CHECK. + +Type affinity: SQLite applies column affinity on insert, but CHECKs +still observe text and fractional values ('abc' stays text, '1.5' +becomes REAL), so every INTEGER column carrying rule semantics carries +a ``typeof(...) = 'integer'`` guard in its CHECK. + +Rule-type coherence: two CHECKs enforce that ``weekly`` carries a +non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an +``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom`` +carry none of the month-specific fields. Relaxed deliberately: the DDL +does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks" +rule or a monthly rule could later be pinned to a month) nor extra +fields on weekly/monthly/yearly rules generally, so the CHECKs only +reject what the rule engine definitively cannot express now. + +Presence pattern format: ``presence_schedules.pattern`` encodes the +whole N-week repeating cycle in one TEXT column as pipe-separated +segments, one per week of the cycle: segment ``i`` is a ``weekday_set`` +CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``) +for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in +week 1. An empty segment means the child is absent every day of that +week; a wholly empty pattern with ``cycle_length_weeks = 1`` means +absent every day of every week, which is distinct from having no +schedule row at all (a child with no schedule is present every day, per +the feature guardrails). Chosen over JSON or per-week rows to stay +consistent with the CSV ``weekday_set`` convention and fully checkable +by plain CHECKs. DB-level validation is pure CHECK, no triggers or +custom functions: char-class GLOBs reject every character outside +digits 0-6, comma and pipe; adjacency GLOBs reject empty elements +(double comma, comma next to a pipe, leading/trailing comma) and +two-digit runs; a further GLOB rejects contiguous runs of 8+ comma +separated digits, which caps a segment at 7 elements — the GLOB cannot +match across a ``|``, so cross-segment boundaries stay safe; and the +segment count is pinned to ``cycle_length_weeks`` by +``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per +weekday_set this is not full CSV-set normalisation (duplicates like +``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``); +the engine treats the segment as a set. A +one-row-per-``week_index`` normalization was considered and rejected: +the flat single-column encoding keeps the done-condition's table shape +and proved fully checkable with plain CHECKs. + +``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the +common case, and a small cap keeps anchor arithmetic and the encoded +pattern human-readable. Revisit the cap — and re-run the pattern +validation matrix in tests — if a longer cycle is ever needed. +``anchor_date`` is an ISO date string pinning the cycle: the week index +of a date is (days since anchor) // 7 mod ``cycle_length_weeks``, +computed by the presence engine (Feature 05) from anchor-date +arithmetic, never ISO week parity. Like other date columns it carries +no shape CHECK, matching the established date policy. + +Presence overrides: ``presence_overrides.end_date`` is NOT NULL, +unlike ``schedule_rules.end_date`` — an override is always a concrete +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 windows (D-008): ``quest_definition_windows`` declares which day +windows a definition spans — one row per (definition, window), the +composite primary key making a window idempotent per definition. +``window`` is CHECK-constrained to the three spellings ``const`` owns; +``due_time`` optionally pins a due time inside the window and is +caller-validated (strict HH:MM) by the DAO layer, matching the date +policy above. The window clock ranges live in ``const.py``, not here: +this layer stores what the caller declares and never interprets clocks. + +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 +append-only log stays the single source of truth. ``definition_id``, +``child_id``, ``due_date`` and ``window`` snapshot the +materialization-time facts ("why this instance exists"): assignment +edits and rule edits change future instances only and never rewrite +these rows. ``window`` records which declared day window produced the +instance (D-008), CHECK-constrained to the same three spellings +``const`` owns. ``due_time`` snapshots the window's optional due time +at generation for the same reason — the instance keeps its +generation-time value even if the definition is edited later. +``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module +timestamp policy (no DB default, so a generation batch stamps one +coherent time). + +Uniqueness: the (definition_id, child_id, due_date, window) tuple 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_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 tuple: the twice-daily case (one definition, two +windows, one child, one date) yields two rows and the shared case (one +definition, three assignees, one window) yields three. No secondary +indexes beyond it are declared yet: history and board queries (Features +08/10/13) should justify their indexes as a later migration once their +query shapes exist. + +No ``ON DELETE`` clauses are declared on any foreign key in this +module: children and definitions are deactivated, never deleted, so a +deletion that would orphan instances or events is a bug and must fail +loudly rather than cascade. + +Completion events: ``completion_events`` is strictly append-only. That +property is enforced at the DAO layer — the completion-events DAO +exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT +by database triggers, matching the module's no-trigger policy: a RAISE +trigger would be the one exception to "pure CHECK, no triggers", +complicate the later migration runner, and still prove nothing (any +writer can run raw SQL). ``event_type`` is constrained to +``completed``/``uncompleted``; a ``missed`` row is intentionally not +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 +``quest_instances``): history reporting (Feature 13) filters and rates +per child directly against the log, and the log should stay +self-describing without joins. + +Actor policy: ``actor_source`` distinguishes an authenticated HA user +(``'user'``, with ``actor_user_id`` holding the HA user id) from a +panel tap with no user context (``'panel'``, with ``actor_user_id`` +NULL — the tapped child profile is ``actor_child_id``, D-008). Two +coherence CHECKs make both shapes non-negotiable: ``'user'`` requires +``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires +``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``. +Other actor sources are rejected; if an unattended system actor is +ever needed, that is a schema migration, not a silent widening. + +On-time policy: ``was_on_time`` is required (integer 0 or 1) for +``completed`` events — at completion time the due date is known and the +flag is decidable. For ``uncompleted`` events the flag is optional: +Feature 08 decides whether a reversal carries the reversed completion's +on-time flag or NULL, and the DDL deliberately accepts both rather than +guessing. +""" +from __future__ import annotations + +from .db import NestQuestDatabase + +#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per +#: possible element count 1..7 (a weekday set cannot exceed 7 elements). +#: Each element is a single digit 0-6, separated by a literal comma. +#: GLOB has no repetition quantifier, so the element count is enumerated. +#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for +#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid +#: because weekly rules need at least one weekday. +WEEKDAY_SET_GLOB_PATTERNS: list[str] = [ + "[0-6]", + "[0-6],[0-6]", + "[0-6],[0-6],[0-6]", + "[0-6],[0-6],[0-6],[0-6]", + "[0-6],[0-6],[0-6],[0-6],[0-6]", + "[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]", + "[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]", +] + +SCHEMA_V1_CHILDREN_DDL: list[str] = [ + """ + CREATE TABLE IF NOT EXISTS children ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + colour TEXT, + avatar_ref TEXT, + sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'), + 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_ADMIN_USERS_DDL: list[str] = [ + """ + CREATE TABLE IF NOT EXISTS admin_users ( + ha_user_id TEXT PRIMARY KEY NOT NULL, + added_at TEXT NOT NULL + ) + """, +] + +_WEEKDAY_SET_CHECK = " OR ".join( + f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS +) + +SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS schedule_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')), + interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1), + weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})), + day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)), + nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))), + month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)), + start_date TEXT NOT NULL, + end_date TEXT, + CHECK (end_date IS NULL OR end_date >= start_date), + CHECK ( + (rule_type = 'weekly' AND weekday_set IS NOT NULL) + OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL)) + OR (rule_type = 'yearly' AND month IS NOT NULL) + OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL) + ) + ) + """, +] + +#: 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_definition_assignees ( + definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), + child_id INTEGER NOT NULL REFERENCES children(id), + PRIMARY KEY (definition_id, child_id) + ) + """, +] + +SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [ + """ + CREATE TABLE IF NOT EXISTS quest_definition_windows ( + definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), + window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')), + due_time TEXT, + PRIMARY KEY (definition_id, window) + ) + """, +] + +SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS presence_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + child_id INTEGER NOT NULL UNIQUE REFERENCES children(id), + cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4), + anchor_date TEXT NOT NULL, + pattern TEXT NOT NULL CHECK ( + NOT pattern GLOB '*[^0-6,|]*' + AND NOT pattern GLOB '*[0-6][0-6]*' + AND NOT pattern GLOB '*,,*' + AND NOT pattern GLOB '*,|*' + AND NOT pattern GLOB '*|,*' + AND NOT pattern GLOB ',*' + AND NOT pattern GLOB '*,' + AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*' + AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks + ) + ) + """, +] + +SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [ + """ + CREATE TABLE IF NOT EXISTS presence_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + child_id INTEGER NOT NULL REFERENCES children(id), + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)), + note TEXT, + CHECK (end_date >= start_date) + ) + """, +] + +#: Column body of ``quest_instances``, shared between the v1 DDL +#: below and the instance-key rebuild migration, which must recreate +#: the table in exactly this shape (D-008: the instance key is +#: (definition_id, child_id, due_date, window) — one instance per +#: assigned child per declared window per firing date). +QUEST_INSTANCES_TABLE_SQL = """( + id INTEGER PRIMARY KEY AUTOINCREMENT, + definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), + child_id INTEGER NOT NULL REFERENCES children(id), + window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')), + due_date TEXT NOT NULL, + due_time TEXT, + generated_at TEXT NOT NULL, + UNIQUE (definition_id, child_id, due_date, window) +)""" + +SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL} + """, +] + +#: Column body of ``completion_events``, shared between the v1 DDL +#: below and the actor-child rebuild migration, which must recreate the +#: table in exactly this shape (D-008: ``actor_child_id`` records which +#: panel profile was tapped — set for panel events, NULL for admin +#: ones; legacy panel rows backfill to ``child_id``, the child they +#: always were). +COMPLETION_EVENTS_TABLE_SQL = """( + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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')), + actor_user_id TEXT, + actor_child_id INTEGER REFERENCES children(id), + occurred_at TEXT NOT NULL, + was_on_time INTEGER CHECK ( + was_on_time IS NULL + OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1)) + ), + CHECK ( + (actor_source = 'user' AND actor_user_id IS NOT NULL) + OR (actor_source = 'panel' AND actor_user_id IS NULL) + ), + CHECK ( + (actor_source = 'user' AND actor_child_id IS NULL) + OR (actor_source = 'panel' AND actor_child_id IS NOT NULL) + ), + CHECK ( + (event_type = 'completed' AND was_on_time IS NOT NULL) + OR (event_type = 'uncompleted') + ) +)""" + +SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL} + """, +] + +#: Generic integration-owned key/value state (schema version 7, +#: Feature 11): persistent bookkeeping that belongs to the integration +#: rather than any domain table — currently the missed-sweep +#: watermark. ``value`` is a plain string; callers interpret it. +META_STATE_TABLE_SQL = """( + key TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL +)""" + +SCHEMA_V7_META_STATE_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL} + """, +] + +SCHEMA_V1_STATEMENTS: list[str] = [ + *SCHEMA_V1_CHILDREN_DDL, + *SCHEMA_V1_ADMIN_USERS_DDL, + *SCHEMA_V1_SCHEDULE_RULES_DDL, + *SCHEMA_V1_QUEST_DEFINITIONS_DDL, + *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, + *SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, + *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, + *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, + *SCHEMA_V1_QUEST_INSTANCES_DDL, + *SCHEMA_V1_COMPLETION_EVENTS_DDL, + *SCHEMA_V7_META_STATE_DDL, +] + + +async def async_apply_ddl( + database: NestQuestDatabase, statements: list[str] +) -> None: + """Apply each DDL statement inside one transaction on ``database``. + + Idempotent: every statement is ``CREATE TABLE IF NOT EXISTS``, so + applying the same list twice leaves the schema and its rows intact. + """ + async with database.transaction(): + for sql in statements: + await database.execute(sql) \ No newline at end of file diff --git a/custom_components/nestquest/core/store.py b/custom_components/nestquest/core/store.py new file mode 100644 index 0000000..0843d70 --- /dev/null +++ b/custom_components/nestquest/core/store.py @@ -0,0 +1,30 @@ +"""SQLite storage path resolution for the NestQuest storage layer. + +Home-Assistant-free: callers resolve the database file location from +their own config (HA resolves it from ``hass.config.path``) and pass +the full path here; this module ensures the parent directory exists +off-thread via :func:`asyncio.to_thread` and returns the absolute path. +""" +from __future__ import annotations + +import asyncio +from pathlib import Path + + +def _ensure_dir(path: Path) -> None: + """Create the given directory and any missing parents.""" + path.mkdir(parents=True, exist_ok=True) + + +async def async_get_db_path(db_path: Path | str) -> Path: + """Return the absolute path to the NestQuest SQLite database file. + + ``db_path`` is the full path to the database file (the integration + resolves it from ``hass.config.path(SQLITE_DB_FILENAME)``); the + parent directory is created off the event loop via + :func:`asyncio.to_thread` so this module never blocks and never + imports Home Assistant. + """ + path = Path(db_path).absolute() + await asyncio.to_thread(_ensure_dir, path.parent) + return path diff --git a/custom_components/nestquest/dao_children.py b/custom_components/nestquest/dao_children.py index 57c42a8..6ee1e2e 100644 --- a/custom_components/nestquest/dao_children.py +++ b/custom_components/nestquest/dao_children.py @@ -1,319 +1,12 @@ -"""Typed DAO layer for the children and admin_users tables. +"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package. -All SQL for ``children`` and ``admin_users`` lives in this module per -the feature guardrail: 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. - -Error contract: violations surface as the underlying -:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK -failure) from the execute call; the DAO adds no wrapping exception -type. A get that matches nothing returns ``None``; a mutation that -matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`, -which each method returns where the caller may need it (reorder, and -the admin remove/set paths). +The children/admin DAO lives in +:mod:`custom_components.nestquest.core.dao_children` (extracted in +task d0b691d8); this module re-exports it so existing +``from .dao_children import ...`` references inside the integration keep +working. """ from __future__ import annotations -import asyncio -from dataclasses import dataclass - -from .db import NestQuestDatabase - - -@dataclass(frozen=True) -class ChildRecord: - """One row of ``children``.""" - - id: int - display_name: str - colour: str | None - avatar_ref: str | None - sort_order: int - is_active: bool - created_at: str - - -@dataclass(frozen=True) -class AdminUserRecord: - """One row of ``admin_users``.""" - - ha_user_id: str - added_at: str - - -def _child_from_row(row: tuple) -> ChildRecord: - """Build a :class:`ChildRecord` from a SELECT row.""" - return ChildRecord( - id=row[0], - display_name=row[1], - colour=row[2], - avatar_ref=row[3], - sort_order=row[4], - is_active=bool(row[5]), - created_at=row[6], - ) - - -_CHILD_COLUMNS = ( - "id, display_name, colour, avatar_ref, sort_order, is_active, created_at" -) - - -#: One asyncio.Lock per (connection wrapper, running loop), shared by -#: every ChildrenDao instance over the same database: the wrapper -#: rejects a second concurrent transaction with RuntimeError, so ALL -#: transactional DAO operations on one connection must queue here, not -#: per-instance. Keyed like the migration runner's lock; entries are -#: never evicted (a config entry holds one wrapper per process). -_CONNECTION_LOCKS: dict[tuple[int, int], asyncio.Lock] = {} - - -def _connection_lock(database) -> asyncio.Lock: - """Return the shared transaction-serializing lock for ``database``.""" - loop = asyncio.get_running_loop() - key = (id(database), id(loop)) - lock = _CONNECTION_LOCKS.get(key) - if lock is None: - lock = asyncio.Lock() - _CONNECTION_LOCKS[key] = lock - return lock - - -class ChildrenDao: - """Typed async data access for the ``children`` table. - - Deletion is deliberately absent: children are deactivated, never - deleted, because completion history references them (feature - guardrail). - """ - - def __init__(self, database) -> None: - self._database = database - - async def create( - self, - display_name: str, - created_at: str, - *, - colour: str | None = None, - avatar_ref: str | None = None, - sort_order: int = 0, - is_active: bool = True, - ) -> ChildRecord: - """Insert one child and return the record as stored.""" - result = await self._database.execute( - "INSERT INTO children (display_name, colour, avatar_ref, " - "sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)", - ( - display_name, - colour, - avatar_ref, - sort_order, - int(is_active), - created_at, - ), - ) - child = await self.get(result.lastrowid) - assert child is not None - return child - - async def get(self, child_id: int) -> ChildRecord | None: - """Return the child with ``child_id``, or None.""" - row = await self._database.fetch_one( - f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?", - (child_id,), - ) - return _child_from_row(row) if row is not None else None - - async def list_active(self) -> list[ChildRecord]: - """Return active children ordered by sort_order, then id.""" - rows = await self._database.fetch_all( - f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 " - "ORDER BY sort_order, id" - ) - return [_child_from_row(row) for row in rows] - - async def list_all(self) -> list[ChildRecord]: - """Return all children ordered by sort_order, then id.""" - rows = await self._database.fetch_all( - f"SELECT {_CHILD_COLUMNS} FROM children " - "ORDER BY sort_order, id" - ) - return [record for record in map(_child_from_row, rows)] - - async def update( - self, - child_id: int, - *, - display_name: str | None = None, - colour: str | None = None, - avatar_ref: str | None = None, - sort_order: int | None = None, - ) -> int: - """Update the given fields; returns rows updated (0 if absent). - - Only non-None arguments are written, so a caller cannot - accidentally blank a column by omitting it. - """ - assignments: list[str] = [] - parameters: list[object] = [] - if display_name is not None: - assignments.append("display_name = ?") - parameters.append(display_name) - if colour is not None: - assignments.append("colour = ?") - parameters.append(colour) - if avatar_ref is not None: - assignments.append("avatar_ref = ?") - parameters.append(avatar_ref) - if sort_order is not None: - assignments.append("sort_order = ?") - parameters.append(sort_order) - if not assignments: - return 0 - parameters.append(child_id) - result = await self._database.execute( - f"UPDATE children SET {', '.join(assignments)} WHERE id = ?", - tuple(parameters), - ) - return result.rowcount - - async def set_active(self, child_id: int, is_active: bool) -> int: - """Set the child's active flag; returns rows updated (0 if absent).""" - result = await self._database.execute( - "UPDATE children SET is_active = ? WHERE id = ?", - (int(is_active), child_id), - ) - return result.rowcount - - async def reorder(self, ordered_ids: list[int]) -> None: - """Persist a new sort order: ``ordered_ids[i]`` gets sort_order i. - - The list must be a COMPLETE permutation of the children table: - every existing child id appears exactly once. Partial lists - (missing an existing child) and unknown ids (naming a child - that does not exist) are rejected with ValueError BEFORE any - write, leaving the current order untouched — a silent partial - reorder would leave unlisted children with stale positions and - hide a caller bug. Duplicate ids are rejected up front: they - cannot all hold distinct positions, and silently assigning the - last occurrence's index would hide a caller bug. - - Runs inside one transaction; positions are staged at offset - -len (all negative, so any permutation of the same ids never - collides mid-reorder under the wrapper's single-connection - serialization), then finalized 0..n-1. - - Concurrent reorder() calls on one connection are serialized by - a connection-scoped asyncio lock (shared across every ChildrenDao - over the same wrapper, however many instances): the wrapper's - transaction() rejects a second concurrent transaction with - RuntimeError, so racing callers would crash instead of - queueing. The lock makes the second call wait, then run - against the first call's final state — which also keeps the - completeness check honest: the re-read of the table happens - inside the same lock and transaction as the writes. - """ - if len(set(ordered_ids)) != len(ordered_ids): - raise ValueError( - "reorder() received duplicate child ids; every id must " - "appear at most once" - ) - async with _connection_lock(self._database): - async with self._database.transaction(): - rows = await self._database.fetch_all( - f"SELECT id FROM children" - ) - existing = {row[0] for row in rows} - listed = set(ordered_ids) - missing = sorted(existing - listed) - unknown = sorted(listed - existing) - if missing or unknown: - details = [] - if missing: - details.append( - f"missing child ids {missing} (a partial " - "reorder would leave them with stale " - "positions)" - ) - if unknown: - details.append( - f"unknown child ids {unknown}" - ) - raise ValueError( - "reorder() must list every child exactly once; " - + "; ".join(details) - ) - offset = len(ordered_ids) - await self._database.execute_many( - "UPDATE children SET sort_order = ? WHERE id = ?", - [ - (-(offset + index), child_id) - for index, child_id in enumerate(ordered_ids) - ], - ) - await self._database.execute_many( - "UPDATE children SET sort_order = ? WHERE id = ?", - [ - (index, child_id) - for index, child_id in enumerate(ordered_ids) - ], - ) - - -class AdminUsersDao: - """Typed async access to the ``admin_users`` allowlist. - - The allowlist stores HA user IDs, never usernames (feature - guardrail); an empty allowlist means no admin, enforced by callers - resolving through :meth:`exists`. - """ - - def __init__(self, database) -> None: - self._database = database - - async def add(self, ha_user_id: str, added_at: str) -> bool: - """Add a user to the allowlist; True if inserted, False if present. - - Idempotent under concurrency: a single INSERT ... ON CONFLICT DO - NOTHING decides and inserts in one statement (the wrapper - serializes statements, not check-then-insert sequences, so a - SELECT/INSERT pair would let two concurrent adds both see no - row and the second one raise IntegrityError). rowcount reports - whether the insert actually happened. - """ - result = await self._database.execute( - "INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?) " - "ON CONFLICT (ha_user_id) DO NOTHING", - (ha_user_id, added_at), - ) - return result.rowcount > 0 - - async def remove(self, ha_user_id: str) -> bool: - """Remove a user from the allowlist; True if a row was removed.""" - result = await self._database.execute( - "DELETE FROM admin_users WHERE ha_user_id = ?", - (ha_user_id,), - ) - return result.rowcount > 0 - - async def list(self) -> list[AdminUserRecord]: - """Return every allowlisted user, oldest first.""" - rows = await self._database.fetch_all( - "SELECT ha_user_id, added_at FROM admin_users " - "ORDER BY added_at, ha_user_id" - ) - return [ - AdminUserRecord(ha_user_id=row[0], added_at=row[1]) - for row in rows - ] - - async def exists(self, ha_user_id: str) -> bool: - """Return True if ``ha_user_id`` is allowlisted.""" - row = await self._database.fetch_one( - "SELECT 1 FROM admin_users WHERE ha_user_id = ?", - (ha_user_id,), - ) - return row is not None \ No newline at end of file +from .core.dao_children import * # noqa: F401,F403 +from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401 diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 3d30b61..1ac0c9e 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -1,832 +1,12 @@ -"""Typed DAO layer for quest_instances and the append-only completion_events. +"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package. -All SQL for these tables 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. - -Append-only policy (Feature 08, D-005): ``completion_events`` is -strictly append-only. This module is where that property is enforced -mechanically — the completion-events DAO below exposes NO update or -delete method at all; completions append, un-completions append a -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: ``QuestInstancesDao.upsert`` is idempotent on -(definition_id, child_id, due_date, window) — re-running -materialization for a tuple whose instance already exists refreshes -only the generation-time snapshot columns and never duplicates a row. -The schema's UNIQUE(definition_id, child_id, due_date, window) is the -last line of defense. The upsert refuses a child that is not an -assignee of the definition and a window outside ``const.QUEST_WINDOWS`` -(D-008 multi-assignee, multi-window model). - -``delete_future_uncompleted`` implements the Feature 06/07 rule that -reassignment and schedule edits regenerate future instances: it -removes only instances at or after the cutoff that have NO completion -event, inside one transaction, so an instance completed mid-call can -never be deleted. Instances in the past are never touched (Feature 07 -guardrail: never delete history), and no method here deletes a single -instance that has a completion event. +The instances/completion-events DAO lives in +:mod:`custom_components.nestquest.core.dao_instances` +(extracted in task d0b691d8); this module re-exports it so existing +``from .dao_instances import ...`` references inside the integration keep +working. """ from __future__ import annotations -import datetime -from dataclasses import dataclass - -from .dao_children import _connection_lock -from .dao_rules import _validate_date, _validate_window -from .db import NestQuestDatabase - -#: Timestamp policy for completion_events.occurred_at: strict UTC -#: ISO-8601 with explicit '+00:00' offset (module timestamp policy). -#: One accepted shape bounds lexicographic range queries correctly, so -#: the occurred-at scope does not guess at timestamp spellings. -_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" - - -def _today() -> datetime.date: - """Return the current local calendar date (the single date source). - - Every "today" read in this module goes through this one function so - the delete clamp and the materialize walk's no-past guard read the - same clock, and a caller or test can pin/roll that clock - deterministically (e.g. a queued-across-midnight regression). Pure - clock read — no business rule lives here. - """ - return datetime.date.today() - - -def _resolve_today(today: datetime.date | None) -> datetime.date: - """Return the caller-resolved "today", or the host date when absent. - - The no-past guard and the delete cutoffs must compare due dates - against the SAME calendar date the materialization horizon was built - from. Home Assistant's configured time zone can lag the host clock - around midnight, so when the caller has already resolved the local - date (``today``) that anchor is authoritative; ``None`` falls back to - the host clock (:func:`_today`), which is exactly the previous - behavior for callers with no HA-local date. - """ - return _today() if today is None else today - - -def _reject_past(due_date: str, today_iso: str) -> None: - """Raise unless ``due_date`` is on or after ``today_iso``. - - The single no-past rule shared by :meth:`QuestInstancesDao.upsert` - and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never - generated in the past, measured against the resolved "today". - """ - if due_date < today_iso: - raise ValueError( - f"instances are never generated in the past: due_date " - f"{due_date!r} is before today {today_iso!r}" - ) - - -def _validate_utc_timestamp(value: str, field: str) -> None: - """Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp. - - Same strictness policy as _validate_date: parse with the exact - format, then round-trip the parsed value against the input so - non-padded or re-ordered shapes are rejected. The round-trip - strips the UTC offset (isoformat() omits it when the offset is - zero), so the suffix is appended before comparing. - """ - try: - parsed = datetime.datetime.strptime( - value, _UTC_TIMESTAMP_FORMAT - ) - except (TypeError, ValueError): - raise ValueError( - f"{field} must be a UTC ISO-8601 timestamp " - f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}" - ) from None - if parsed.isoformat() + "+00:00" != value: - raise ValueError( - f"{field} must be a strict UTC timestamp " - f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}" - ) - - -def _next_day_start(date_str: str) -> str: - """Return the UTC timestamp one second past ``date_str``'s last second. - - Gives range queries an exact half-open upper bound without - fractional-second guessing. - """ - day = datetime.date.fromisoformat(date_str) - next_day = day + datetime.timedelta(days=1) - return f"{next_day.isoformat()}T00:00:00+00:00" - - -@dataclass(frozen=True) -class QuestInstanceRecord: - """One row of ``quest_instances``.""" - - id: int - definition_id: int - child_id: int - window: str - due_date: str - due_time: str | None - generated_at: str - - -@dataclass(frozen=True) -class CompletionEventRecord: - """One row of ``completion_events`` (append-only table).""" - - id: int - instance_id: int - child_id: int - event_type: str - actor_source: str - actor_user_id: str | None - actor_child_id: int | None - occurred_at: str - was_on_time: bool | None - - -_INSTANCE_COLUMNS = ( - "id, definition_id, child_id, window, due_date, due_time, generated_at" -) -_EVENT_COLUMNS = ( - "id, instance_id, child_id, event_type, actor_source, actor_user_id, " - "actor_child_id, occurred_at, was_on_time" -) - -#: The only event types the append path accepts; the schema CHECK is -#: the backstop, this is the DAO's typed front door. -EVENT_COMPLETED = "completed" -EVENT_UNCOMPLETED = "uncompleted" - - -def _instance_from_row(row: tuple) -> QuestInstanceRecord: - return QuestInstanceRecord( - id=row[0], - definition_id=row[1], - child_id=row[2], - window=row[3], - due_date=row[4], - due_time=row[5], - generated_at=row[6], - ) - - -def _event_from_row(row: tuple) -> CompletionEventRecord: - return CompletionEventRecord( - id=row[0], - instance_id=row[1], - child_id=row[2], - event_type=row[3], - actor_source=row[4], - actor_user_id=row[5], - actor_child_id=row[6], - occurred_at=row[7], - was_on_time=None if row[8] is None else bool(row[8]), - ) - - -class QuestInstancesDao: - """Typed async access to the ``quest_instances`` table.""" - - def __init__(self, database: NestQuestDatabase) -> None: - self._database = database - - async def upsert( - self, - definition_id: int, - child_id: int, - due_date: str, - generated_at: str, - *, - window: str, - due_time: str | None = None, - today: datetime.date | None = None, - ) -> QuestInstanceRecord: - """Create the dated instance for the widened key idempotently. - - The instance key is (definition_id, child_id, due_date, window) - (D-008): running materialization twice for the same tuple - yields ONE row — the ON CONFLICT path refreshes the - generation-time snapshot columns (due_time) and regenerates the - stamp, but can never create a duplicate. The schema's - UNIQUE(definition_id, child_id, due_date, window) backs this - up, so the twice-daily case (two windows, one child, one date) - yields two rows and the shared case (three assignees, one - window) yields three. - - Guardrails enforced here: - - - ``due_date`` must be TODAY or later: instances are never - generated in the past (Feature 07). The caller's - materialization horizon is future-dated by definition. - ``today`` optionally pins the resolved HA-local date the - guard compares against (falling back to the host clock when - omitted) so a household time zone behind the host around - midnight is not misread as "past". - - ``window`` must be one of ``const.QUEST_WINDOWS``. - - An existing instance WITH a completion event is IMMUTABLE: - the conflict path must not rewrite its snapshot columns - (Feature 07/08). The upsert refuses with ValueError in that - case — silently no-oping would mask a materialization bug. - - The child must be an assignee of the definition (D-008 - multi-assignee model). - - The definition must exist (validated atomically under the - connection lock so a concurrent definition deactivation or - child deletion cannot slip between check and insert). - """ - _validate_date(due_date, "due_date") - _validate_window(window) - # Fail fast on obviously-past dates BEFORE queuing on the - # connection lock (a nice early error); the AUTHORITATIVE - # check runs after the lock is acquired, because a caller can - # wait on the lock across midnight — a date that was "today" - # at call time may be "yesterday" by the time the INSERT runs. - # A caller-supplied ``today`` (HA-local) is used for BOTH - # checks and never re-read, since it is already the anchor the - # batch runs against. - today_date = _resolve_today(today) - _reject_past(due_date, today_date.isoformat()) - async with _connection_lock(self._database): - async with self._database.transaction(): - # Re-read "today" under the lock: this is the date the - # insert actually executes on, so the no-past rule - # holds across midnight rollovers. A pinned HA-local - # anchor is exempt from the re-read (see above). - if today is None: - today_date = _today() - _reject_past(due_date, today_date.isoformat()) - definition = await self._database.fetch_one( - "SELECT 1 FROM quest_definitions WHERE id = ?", - (definition_id,), - ) - if definition is None: - raise ValueError( - f"quest definition {definition_id} does not exist" - ) - 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 not " - f"assigned to child {child_id}; instance must " - "carry an assigned child" - ) - existing = await self._database.fetch_one( - "SELECT 1 FROM completion_events " - "WHERE instance_id = (SELECT id FROM quest_instances " - "WHERE definition_id = ? AND child_id = ? " - "AND due_date = ? AND window = ?)", - (definition_id, child_id, due_date, window), - ) - if existing is not None: - raise ValueError( - f"instance for (definition {definition_id}, " - f"child {child_id}, due_date {due_date!r}, " - f"window {window!r}) already has a completion " - "event and is immutable; it cannot be regenerated" - ) - await self._database.execute( - "INSERT INTO quest_instances (definition_id, child_id, " - "window, due_date, due_time, generated_at) " - "VALUES (?, ?, ?, ?, ?, ?) " - "ON CONFLICT (definition_id, child_id, due_date, " - "window) DO UPDATE SET " - "due_time = excluded.due_time, " - "generated_at = excluded.generated_at", - ( - definition_id, - child_id, - window, - due_date, - due_time, - generated_at, - ), - ) - instance = await self.get( - definition_id, child_id, due_date, window - ) - assert instance is not None - return instance - - async def upsert_if_valid( - self, - definition_id: int, - child_id: int, - due_date: str, - generated_at: str, - *, - window: str, - due_time: str | None = None, - today: datetime.date | None = None, - ) -> QuestInstanceRecord | None: - """Insert one dated instance iff its tuple is still materializable. - - This is the materialization walk's single write path. Unlike the - generic :meth:`upsert`, which validates only existence and - assignment, this method re-validates EVERY materialization - precondition in the SAME connection-lock-held transaction as the - INSERT — closing the check-then-insert gap a concurrent config - write could otherwise slip through: - - - the definition exists AND is active (a definition deactivated - after the input snapshot is skipped, never written); - - the child exists AND is active (a deactivated child is skipped); - - the (definition, child) assignment link still exists (a removed - assignment is skipped); - - the window is still declared on the definition (a removed window - is skipped); - - the window's live ``due_time`` still EQUALS the caller-supplied - ``due_time`` (NULL-safe: NULL == NULL counts as equal; a window - with no due time matches a snapshot with no due time). A - window-time edit landed after the input snapshot makes the - caller's ``due_time`` stale, and writing it would overwrite the - newer due_time a concurrent regeneration already upserted — so - the tuple is skipped instead of written. - - The due_time check closes the one check-then-insert gap this - method previously left open: it re-validated every OTHER - precondition but then wrote the snapshot's ``due_time`` - unconditionally, so a stale materialization could clobber a - newer, concurrently-regenerated due_time. - - Returns the stored instance, or ``None`` when a precondition - failed — the tuple no longer materializable, which the walk SKIPS - rather than raising, so the rest of the batch is unaffected. The - no-past rule (instances are never generated in the past) is - preserved from :meth:`upsert` and still raises ValueError; ``today`` - optionally pins the HA-local anchor it compares against. An - instance that ALREADY has a completion event is also skipped - (returns ``None``) rather than raised: it is immutable, so the - walk leaves it untouched instead of failing the batch — the - generic :meth:`upsert` keeps its hard refusal for the - delete/regenerate path. - """ - _validate_date(due_date, "due_date") - _validate_window(window) - # Fail fast on obviously-past dates before queuing on the lock; - # the authoritative check re-runs under the lock (see upsert). - today_date = _resolve_today(today) - _reject_past(due_date, today_date.isoformat()) - async with _connection_lock(self._database): - async with self._database.transaction(): - if today is None: - today_date = _today() - _reject_past(due_date, today_date.isoformat()) - still_valid = await self._database.fetch_one( - "SELECT 1 FROM quest_definitions d " - "JOIN quest_definition_assignees a " - "ON a.definition_id = d.id " - "JOIN children c ON c.id = a.child_id " - "JOIN quest_definition_windows w " - "ON w.definition_id = d.id " - "WHERE d.id = ? AND a.child_id = ? AND w.window = ? " - "AND d.is_active = 1 AND c.is_active = 1 " - "AND w.due_time IS ? LIMIT 1", - (definition_id, child_id, window, due_time), - ) - if still_valid is None: - # A config change invalidated the tuple since the - # snapshot: skip it rather than abort the batch. - return None - existing = await self._database.fetch_one( - "SELECT 1 FROM completion_events " - "WHERE instance_id = (SELECT id FROM quest_instances " - "WHERE definition_id = ? AND child_id = ? " - "AND due_date = ? AND window = ?)", - (definition_id, child_id, due_date, window), - ) - if existing is not None: - # The instance is already completed and therefore - # immutable: the walk SKIPS it (no-op) rather than - # rewriting its snapshot columns or failing the - # batch. This is the materialization walk's skip, - # distinct from the generic :meth:`upsert` refusal. - return None - await self._database.execute( - "INSERT INTO quest_instances (definition_id, child_id, " - "window, due_date, due_time, generated_at) " - "VALUES (?, ?, ?, ?, ?, ?) " - "ON CONFLICT (definition_id, child_id, due_date, " - "window) DO UPDATE SET " - "due_time = excluded.due_time, " - "generated_at = excluded.generated_at", - ( - definition_id, - child_id, - window, - due_date, - due_time, - generated_at, - ), - ) - instance = await self.get( - definition_id, child_id, due_date, window - ) - assert instance is not None - return instance - - async def get( - self, - definition_id: int, - child_id: int, - due_date: str, - window: str, - ) -> QuestInstanceRecord | None: - """Return the instance for the widened key tuple, or None.""" - row = await self._database.fetch_one( - f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " - "WHERE definition_id = ? AND child_id = ? AND due_date = ? " - "AND window = ?", - (definition_id, child_id, due_date, window), - ) - return _instance_from_row(row) if row is not None else 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 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[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 quest_instances " - "WHERE child_id = ? AND due_date = ? ORDER BY id", - (child_id, due_date), - ) - return [_instance_from_row(row) for row in rows] - - async def list_by_date_range( - self, - child_id: int, - range_start: str, - range_end: str, - ) -> 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") - if range_end < range_start: - raise ValueError( - "range_end must be on or after range_start, got " - f"{range_end!r} < {range_start!r}" - ) - rows = await self._database.fetch_all( - 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), - ) - return [_instance_from_row(row) for row in rows] - - async def list_missed_unswept( - self, today_iso: str, watermark: str | None - ) -> list[QuestInstanceRecord]: - """Return past-due open instances the sweep has not announced. - - Feature 11's missed-sweep query: ``due_date`` strictly before - ``today_iso`` (past due), at or after ``watermark`` when one is - recorded, and with NO completion event ever (the Feature 08 - append-only contract: an instance that was ever completed is - never reported missed, even when later reversed — history keeps - both rows and the sweep never mutates any). The watermark is - the HA-local date the sweep last ran, and the window is - ``[watermark, today)``: the nightly run entering day D+1 sweeps - exactly day D's instances, so every day's quests are announced - once — at the midnight after their due date — and a same-night - rerun (watermark == today) sees an empty window. Read-only; - ordered deterministically by (due_date, id). - """ - _validate_date(today_iso, "today_iso") - if watermark is not None: - _validate_date(watermark, "watermark") - sql = ( - f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " - "WHERE due_date < ? " - "AND NOT EXISTS (" - " SELECT 1 FROM completion_events " - " WHERE instance_id = quest_instances.id" - ")" - ) - parameters: tuple = (today_iso,) - if watermark is not None: - sql += " AND due_date >= ?" - parameters += (watermark,) - sql += " ORDER BY due_date, id" - rows = await self._database.fetch_all(sql, parameters) - return [_instance_from_row(row) for row in rows] - - async def delete_future_uncompleted( - self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None - ) -> int: - """Delete the definition's open instances at/after cutoff. - - Implements regeneration after a definition edit or reassignment - (Features 06/07): only instances at or after the cutoff with NO - completion event are removed, inside one transaction — an - instance that gains a completion event mid-flight cannot be - deleted. The cutoff cannot be backdated below today: "future" - is measured against TODAY (materialization regenerates the - rolling horizon from now), so a caller cannot use this to - rewrite past open instances either — the day-rollover missed - sweep (Feature 11) handles past-due instances instead. ``today`` - optionally pins the HA-local anchor the clamp compares against - (falling back to the host clock when omitted). Any instance with - a completion event is never touched, regardless of its date. - Returns the number deleted. - """ - _validate_date(cutoff_date, "cutoff_date") - async with _connection_lock(self._database): - async with self._database.transaction(): - # "today" is read under the lock: the DELETE actually - # executes on this date, so a caller queued across - # midnight cannot delete instances that became past - # while it waited (backdating is clamped to the - # execution date, not the call date). - today_date = _resolve_today(today) - effective_cutoff = max(cutoff_date, today_date.isoformat()) - result = await self._database.execute( - "DELETE FROM quest_instances WHERE definition_id = ? " - "AND due_date >= ? AND NOT EXISTS (" - " SELECT 1 FROM completion_events " - " WHERE instance_id = quest_instances.id" - ")", - (definition_id, effective_cutoff), - ) - return result.rowcount - - async def delete_future_uncompleted_for_child( - self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None - ) -> tuple[int, str]: - """Delete the child's open instances at/after cutoff, across every - definition, returning ``(deleted, effective_cutoff)``. - - Child-scoped counterpart to :meth:`delete_future_uncompleted`, - used when a child's presence changes (Feature 09): only instances - at or after the cutoff with NO completion event are removed, inside - one transaction — an instance that gains a completion event - mid-flight cannot be deleted. The cutoff cannot be backdated below - today, for the same reason as the definition-scoped method (see its - docstring). ``today`` optionally pins the HA-local anchor the clamp - compares against (falling back to the host clock when omitted). - Any instance with a completion event is never touched, regardless - of its date. - - Returns the number deleted and the effective cutoff that was - actually applied (``max(cutoff_date, today)``, read under the - lock). The caller (:func:`~.materialize.regenerate_for_child`) - passes that effective cutoff back as its re-materialization anchor, - so the delete and the rebuild share ONE execution-day even when the - call is queued across midnight — instead of the caller re-reading a - stale "today" and materializing a window the delete already - superseded. - """ - _validate_date(cutoff_date, "cutoff_date") - async with _connection_lock(self._database): - async with self._database.transaction(): - # "today" is read under the lock, matching the - # definition-scoped method: the DELETE executes on this - # date, so a caller queued across midnight cannot delete - # instances that became past while it waited. - today_date = _resolve_today(today) - effective_cutoff = max(cutoff_date, today_date.isoformat()) - result = await self._database.execute( - "DELETE FROM quest_instances WHERE child_id = ? " - "AND due_date >= ? AND NOT EXISTS (" - " SELECT 1 FROM completion_events " - " WHERE instance_id = quest_instances.id" - ")", - (child_id, effective_cutoff), - ) - return result.rowcount, effective_cutoff - - -class CompletionEventsDao: - """Typed append-only access to the ``completion_events`` table. - - APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the - three read methods below, and NOTHING else. There is no update, - no delete, no upsert — completions and un-completions both arrive - through :meth:`append` as new rows, and history is immutable - (Feature 08 done-condition: no code path updates or deletes an - event). The package-wide leak-guard test additionally fails if - mutation SQL for this table ever appears in any module. - """ - - def __init__(self, database: NestQuestDatabase) -> None: - self._database = database - - async def append( - self, - instance_id: int, - child_id: int, - event_type: str, - actor_source: str, - occurred_at: str, - was_on_time: bool | None, - *, - actor_user_id: str | None = None, - actor_child_id: int | None = None, - ) -> CompletionEventRecord: - """Append one completion or un-completion event. - - ``actor_source`` is 'user' (an admin action: ``actor_user_id`` - set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user - id, and ``actor_child_id`` carrying the tapped child profile, - D-008); the actor-pair CHECKs enforce this shape at the schema - level. ``occurred_at`` must be the module's one strict UTC - shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range - queries stay exact. ``was_on_time`` is required (True/False) - for completions; for un-completions it may be None or the - reversed completion's flag (Feature 08 still deciding). The - instance, child and — for panel events — the tapped child must - exist — validated atomically under the connection lock. - """ - if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED): - raise ValueError( - f"event_type must be '{EVENT_COMPLETED}' or " - f"'{EVENT_UNCOMPLETED}', got {event_type!r}" - ) - if actor_source not in ("user", "panel"): - raise ValueError( - f"actor_source must be 'user' or 'panel', got " - f"{actor_source!r}" - ) - if actor_source == "user" and not actor_user_id: - raise ValueError( - "actor_source 'user' requires actor_user_id" - ) - if actor_source == "panel" and actor_user_id is not None: - raise ValueError( - "actor_source 'panel' must not carry actor_user_id" - ) - if actor_source == "panel" and actor_child_id is None: - raise ValueError( - "actor_source 'panel' requires actor_child_id " - "(the tapped child profile)" - ) - if actor_source == "user" and actor_child_id is not None: - raise ValueError( - "actor_source 'user' must not carry actor_child_id" - ) - if was_on_time is not None and type(was_on_time) is not bool: - raise ValueError( - f"was_on_time must be True, False or None, got " - f"{was_on_time!r}" - ) - if event_type == EVENT_COMPLETED and was_on_time is None: - raise ValueError( - "a completed event requires was_on_time (True/False)" - ) - _validate_utc_timestamp(occurred_at, "occurred_at") - async with _connection_lock(self._database): - async with self._database.transaction(): - instance = await self._database.fetch_one( - "SELECT child_id FROM quest_instances WHERE id = ?", - (instance_id,), - ) - if instance is None: - raise ValueError( - f"quest instance {instance_id} does not exist" - ) - if instance[0] != child_id: - raise ValueError( - f"quest instance {instance_id} belongs to child " - f"{instance[0]}, not {child_id}" - ) - child = await self._database.fetch_one( - "SELECT 1 FROM children WHERE id = ?", (child_id,) - ) - if child is None: - raise ValueError(f"child {child_id} does not exist") - if actor_child_id is not None: - tapped = await self._database.fetch_one( - "SELECT 1 FROM children WHERE id = ?", - (actor_child_id,), - ) - if tapped is None: - raise ValueError( - f"actor_child_id {actor_child_id} does not " - "reference an existing child" - ) - result = await self._database.execute( - "INSERT INTO completion_events (instance_id, child_id, " - "event_type, actor_source, actor_user_id, " - "actor_child_id, occurred_at, was_on_time) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ( - instance_id, - child_id, - event_type, - actor_source, - actor_user_id, - actor_child_id, - occurred_at, - None if was_on_time is None else int(was_on_time), - ), - ) - event = await self._database.fetch_one( - f"SELECT {_EVENT_COLUMNS} FROM completion_events " - "WHERE id = ?", - (result.lastrowid,), - ) - assert event is not None - return _event_from_row(event) - - async def list_by_instance( - self, instance_id: int - ) -> list[CompletionEventRecord]: - """Return every event for the instance, in append order.""" - rows = await self._database.fetch_all( - f"SELECT {_EVENT_COLUMNS} FROM completion_events " - "WHERE instance_id = ? ORDER BY id", - (instance_id,), - ) - return [_event_from_row(row) for row in rows] - - async def list_by_child_and_date_range( - self, - child_id: int, - range_start: str, - range_end: str, - *, - due_date_scope: bool = True, - ) -> list[CompletionEventRecord]: - """Return the child's events for instances due in the closed range. - - Scopes by the INSTANCE's due date (the day the task was owed), - not the event's wall-clock timestamp: history reporting asks - "what happened to the tasks due that day" (Feature 13). Set - ``due_date_scope=False`` to filter by ``occurred_at`` date - instead. Ordered by instance, then event id. - """ - _validate_date(range_start, "range_start") - _validate_date(range_end, "range_end") - if range_end < range_start: - raise ValueError( - "range_end must be on or after range_start, got " - f"{range_end!r} < {range_start!r}" - ) - if due_date_scope: - predicate = ( - "SELECT id FROM quest_instances WHERE child_id = ? " - "AND due_date >= ? AND due_date <= ?" - ) - parameters: list[object] = [child_id, range_start, range_end] - else: - # Occurred-at scope: occurred_at is validated to the one - # accepted UTC shape at append time, so half-open day - # bounds in that same shape are exact (no spelling drift). - occurred_start = f"{range_start}T00:00:00+00:00" - occurred_end = f"{range_end}T23:59:59+00:00" - query = ( - f"SELECT {_EVENT_COLUMNS} FROM completion_events " - "WHERE child_id = ? AND occurred_at >= ? " - "AND occurred_at < ? ORDER BY instance_id, id" - ) - rows = await self._database.fetch_all( - query, - [ - child_id, - occurred_start, - _next_day_start(range_end), - ], - ) - return [_event_from_row(row) for row in rows] - rows = await self._database.fetch_all( - f"SELECT {_EVENT_COLUMNS} FROM completion_events " - f"WHERE instance_id IN ({predicate}) " - "ORDER BY instance_id, id", - tuple(parameters), - ) - return [_event_from_row(row) for row in rows] - - async def get_latest_for_instance( - self, instance_id: int - ) -> CompletionEventRecord | None: - """Return the instance's most recent event, or None if untouched. - - Instance current state derives from this event (Feature 08): - 'completed' means done, 'uncompleted' as the latest means open - again, None means never completed. - """ - row = await self._database.fetch_one( - f"SELECT {_EVENT_COLUMNS} FROM completion_events " - "WHERE instance_id = ? ORDER BY id DESC LIMIT 1", - (instance_id,), - ) - return _event_from_row(row) if row is not None else None \ No newline at end of file +from .core.dao_instances import * # noqa: F401,F403 +from .core.dao_instances import _resolve_today # noqa: F401 diff --git a/custom_components/nestquest/dao_meta.py b/custom_components/nestquest/dao_meta.py index ada8c09..bef9c16 100644 --- a/custom_components/nestquest/dao_meta.py +++ b/custom_components/nestquest/dao_meta.py @@ -1,44 +1,11 @@ -"""Typed DAO for the integration-owned ``nestquest_meta_state`` table. +"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package. -Generic persistent key/value bookkeeping that belongs to the -integration itself rather than any domain table — currently the -Feature 11 missed-sweep watermark. Values are plain strings; callers -interpret them. All SQL for this table lives here per the feature -guardrails, and every method runs through -:class:`~.db.NestQuestDatabase` so nothing blocks the event loop. +The meta-state DAO lives in +:mod:`custom_components.nestquest.core.dao_meta` (extracted in task +d0b691d8); this module re-exports it so existing +``from .dao_meta import ...`` references inside the integration keep +working. """ from __future__ import annotations -from .dao_children import _connection_lock -from .db import NestQuestDatabase - -_TABLE = "nestquest_meta_state" - - -class MetaStateDao: - """Typed async access to the ``nestquest_meta_state`` table.""" - - def __init__(self, database: NestQuestDatabase) -> None: - self._database = database - - async def get(self, key: str) -> str | None: - """Return the stored value for ``key``, or None when absent.""" - row = await self._database.fetch_one( - f"SELECT value FROM {_TABLE} WHERE key = ?", (key,) - ) - return row[0] if row is not None else None - - async def set(self, key: str, value: str) -> None: - """Upsert one key/value row atomically. - - The write runs inside one transaction under the shared - connection lock, so a racing reader either sees the old value - or the fully-written new one, never a half-applied state. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - await self._database.execute( - f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) " - "ON CONFLICT (key) DO UPDATE SET value = excluded.value", - (key, value), - ) \ No newline at end of file +from .core.dao_meta import * # noqa: F401,F403 diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index c273287..422ae17 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -1,343 +1,11 @@ -"""Typed DAO layer for the presence_schedules and presence_overrides tables. +"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package. -All SQL for these tables 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. - -Semantics per the done-condition and Feature 05: - -- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting - by child updates the existing row rather than raising. -- A child with NO schedule row is present every day — the absence of a - row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child` - returning None, never by an empty pattern row. -- Overrides beat the pattern for their date range (that resolution is - the presence engine's job, Feature 05); the DAO only stores and - lists them. A single-day override stores the same date as both - start and end (schema CHECK requires end >= start). - -Date arguments are validated as strict YYYY-MM-DD per the same policy -the rules DAO applies: the schema's lexical CHECKs cannot prove -calendar validity. +The presence DAOs live in +:mod:`custom_components.nestquest.core.dao_presence` (extracted in +task d0b691d8); this module re-exports them so existing +``from .dao_presence import ...`` references inside the integration keep +working. """ from __future__ import annotations -from dataclasses import dataclass - -from .dao_children import _connection_lock -from .dao_rules import _UNSET, _validate_date -from .db import NestQuestDatabase - - -@dataclass(frozen=True) -class PresenceScheduleRecord: - """One row of ``presence_schedules``.""" - - id: int - child_id: int - cycle_length_weeks: int - anchor_date: str - pattern: str - - -@dataclass(frozen=True) -class PresenceOverrideRecord: - """One row of ``presence_overrides``.""" - - id: int - child_id: int - start_date: str - end_date: str - is_present: bool - note: str | None - - -_SCHEDULE_COLUMNS = ( - "id, child_id, cycle_length_weeks, anchor_date, pattern" -) -_OVERRIDE_COLUMNS = ( - "id, child_id, start_date, end_date, is_present, note" -) - - -def _schedule_from_row(row: tuple) -> PresenceScheduleRecord: - return PresenceScheduleRecord( - id=row[0], - child_id=row[1], - cycle_length_weeks=row[2], - anchor_date=row[3], - pattern=row[4], - ) - - -def _override_from_row(row: tuple) -> PresenceOverrideRecord: - return PresenceOverrideRecord( - id=row[0], - child_id=row[1], - start_date=row[2], - end_date=row[3], - is_present=bool(row[4]), - note=row[5], - ) - - -class PresenceSchedulesDao: - """Typed async access to the ``presence_schedules`` table.""" - - def __init__(self, database: NestQuestDatabase) -> None: - self._database = database - - async def upsert_by_child( - self, - child_id: int, - cycle_length_weeks: int, - anchor_date: str, - pattern: str, - ) -> PresenceScheduleRecord: - """Set the child's presence schedule, replacing any existing one. - - Upsert semantics are the done-condition: a second schedule for - the same child updates the existing row rather than raising. - Runs under the connection lock so a concurrent upsert/delete - for the same child serializes instead of colliding on the - schema's UNIQUE(child_id). Pattern validity (segment shape, - segment count = cycle_length_weeks) is enforced by the schema - CHECKs; the child must exist (FK) — validated atomically here - so a deleted child cannot pass a stale check. - """ - _validate_date(anchor_date, "anchor_date") - async with _connection_lock(self._database): - async with self._database.transaction(): - child = await self._database.fetch_one( - "SELECT 1 FROM children WHERE id = ?", (child_id,) - ) - if child is None: - raise ValueError(f"child {child_id} does not exist") - await self._database.execute( - "INSERT INTO presence_schedules (child_id, " - "cycle_length_weeks, anchor_date, pattern) " - "VALUES (?, ?, ?, ?) " - "ON CONFLICT (child_id) DO UPDATE SET " - "cycle_length_weeks = excluded.cycle_length_weeks, " - "anchor_date = excluded.anchor_date, " - "pattern = excluded.pattern", - (child_id, cycle_length_weeks, anchor_date, pattern), - ) - schedule = await self.get_by_child(child_id) - assert schedule is not None - return schedule - - async def get_by_child( - self, child_id: int - ) -> PresenceScheduleRecord | None: - """Return the child's schedule, or None when the child has none. - - None is meaningful and UNAMBIGUOUS here: the child exists and - has no schedule row, i.e. the child is present every day - (Feature 05 guardrail). An unknown child id raises ValueError - instead of returning None, because "nonexistent child" must - never be silently read as "present every day". - """ - child = await self._database.fetch_one( - "SELECT 1 FROM children WHERE id = ?", (child_id,) - ) - if child is None: - raise ValueError(f"child {child_id} does not exist") - row = await self._database.fetch_one( - f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules " - "WHERE child_id = ?", - (child_id,), - ) - return _schedule_from_row(row) if row is not None else None - - async def delete(self, child_id: int) -> bool: - """Remove the child's schedule; True if a row was removed. - - After deletion the child is present every day (no row means - no restriction). Deleting a schedule the child never had - returns False. - """ - result = await self._database.execute( - "DELETE FROM presence_schedules WHERE child_id = ?", - (child_id,), - ) - return result.rowcount > 0 - - -class PresenceOverridesDao: - """Typed async access to the ``presence_overrides`` table.""" - - def __init__(self, database: NestQuestDatabase) -> None: - self._database = database - - async def create( - self, - child_id: int, - start_date: str, - end_date: str, - is_present: bool, - *, - note: str | None = None, - ) -> PresenceOverrideRecord: - """Insert one override and return the record as stored. - - The child must exist (validated atomically under the - connection lock). Date shape is validated strictly, and the - schema CHECK rejects end < start. - - Overlap policy (Feature 05): an override for a child may not - overlap another override of the SAME child — two different - present/absent answers for one date are unresolvable. The - overlap check (inclusive boundaries) runs inside the same - transaction as the INSERT, so a racing create cannot slip a - conflicting override between the check and the write; a - conflict raises ValueError naming the conflicting override's - id and range. - """ - _validate_date(start_date, "start_date") - _validate_date(end_date, "end_date") - async with _connection_lock(self._database): - async with self._database.transaction(): - child = await self._database.fetch_one( - "SELECT 1 FROM children WHERE id = ?", (child_id,) - ) - if child is None: - raise ValueError(f"child {child_id} does not exist") - # Only a well-ordered range has overlap semantics: - # inverted ranges fall through to the INSERT and hit - # the schema's end>=start CHECK, preserving the - # IntegrityError contract the matrix tests rely on. - if end_date >= start_date: - conflicting = await self._database.fetch_one( - f"SELECT {_OVERRIDE_COLUMNS} " - "FROM presence_overrides " - "WHERE child_id = ? AND end_date >= ? " - "AND start_date <= ? ORDER BY id LIMIT 1", - (child_id, start_date, end_date), - ) - else: - conflicting = None - if conflicting is not None: - conflict = _override_from_row(conflicting) - raise ValueError( - f"override for child {child_id} " - f"[{start_date}, {end_date}] overlaps existing " - f"override {conflict.id} " - f"[{conflict.start_date}, {conflict.end_date}] " - f"(is_present={conflict.is_present})" - ) - result = await self._database.execute( - "INSERT INTO presence_overrides (child_id, " - "start_date, end_date, is_present, note) " - "VALUES (?, ?, ?, ?, ?)", - (child_id, start_date, end_date, int(is_present), note), - ) - override = await self._database.fetch_one( - f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " - "WHERE id = ?", - (result.lastrowid,), - ) - assert override is not None - return _override_from_row(override) - - async def list_by_child_and_range( - self, - child_id: int, - range_start: str, - range_end: str, - ) -> list[PresenceOverrideRecord]: - """Return the child's overrides overlapping [range_start, range_end]. - - An override overlaps the range when it ends on or after the - range starts and starts on or before the range ends (inclusive - boundary dates, matching the storage model where overrides are - closed date ranges). Ordered by start date. - """ - _validate_date(range_start, "range_start") - _validate_date(range_end, "range_end") - if range_end < range_start: - raise ValueError( - "range_end must be on or after range_start, got " - f"{range_end!r} < {range_start!r}" - ) - rows = await self._database.fetch_all( - f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " - "WHERE child_id = ? AND end_date >= ? AND start_date <= ? " - "ORDER BY start_date", - (child_id, range_start, range_end), - ) - return [_override_from_row(row) for row in rows] - - async def get(self, override_id: int) -> PresenceOverrideRecord | None: - """Return one override by id, or None when it does not exist.""" - row = await self._database.fetch_one( - f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " - "WHERE id = ?", - (override_id,), - ) - return _override_from_row(row) if row is not None else None - - async def delete(self, override_id: int) -> bool: - """Remove one override by id; True if a row was removed.""" - result = await self._database.execute( - "DELETE FROM presence_overrides WHERE id = ?", - (override_id,), - ) - return result.rowcount > 0 - - -async def read_snapshot_unlocked( - database: NestQuestDatabase, - child_ids: list[int], - range_start: str, - range_end: str, -) -> tuple[ - dict[int, PresenceScheduleRecord], - dict[int, list[PresenceOverrideRecord]], -]: - """Read schedules and overrides for ``child_ids`` WITHOUT locking. - - MUST be called inside the connection lock and an open transaction, so - the schedules and overrides returned here stay coherent with every - other table the caller reads inside that same transaction (the - materialization walk's single input snapshot does exactly that). The - date range comes already validated by the caller. - - Returns ``(schedules, overrides)``: - - - ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord` - for every child that HAS a schedule row. A child absent from the - mapping has no schedule, i.e. is present every day (Feature 05). - - ``overrides`` maps ``child_id`` to that child's overrides overlapping - ``[range_start, range_end]`` (inclusive boundaries), ordered by start - date; a child with no matching override is absent from the mapping. - - ``child_ids`` must name existing children (the caller derives them - from a trusted snapshot, within the same transaction). - """ - children = sorted(set(child_ids)) - if not children: - return {}, {} - placeholders = ",".join("?" for _ in children) - schedule_rows = await database.fetch_all( - f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules " - f"WHERE child_id IN ({placeholders})", - tuple(children), - ) - override_rows = await database.fetch_all( - f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " - f"WHERE child_id IN ({placeholders}) AND end_date >= ? " - "AND start_date <= ? ORDER BY start_date", - tuple(children) + (range_start, range_end), - ) - schedules = { - record.child_id: record - for record in map(_schedule_from_row, schedule_rows) - } - overrides: dict[int, list[PresenceOverrideRecord]] = {} - for row in override_rows: - record = _override_from_row(row) - overrides.setdefault(record.child_id, []).append(record) - return schedules, overrides \ No newline at end of file +from .core.dao_presence import * # noqa: F401,F403 diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index ec3f6d9..31c4ab9 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -1,1250 +1,17 @@ -"""Typed DAO layer for the schedule_rules, quest_definitions and -quest_definition_assignees tables. +"""Re-export of :mod:`custom_components.nestquest.core.dao_rules` for the integration package. -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. - -Guardrail mapping: - -- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still - referenced by any quest definition is rejected (the done-condition), - not cascaded. -- ``quest_definitions`` has no delete path at all: definitions are - deactivated, never hard-deleted, and history must survive. -- 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` (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`` 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. +The schedule-rules/quest-definitions DAO lives in +:mod:`custom_components.nestquest.core.dao_rules` (extracted in task +d0b691d8); this module re-exports it so existing +``from .dao_rules import ...`` references inside the integration keep +working. """ from __future__ import annotations -import datetime -from dataclasses import dataclass - -from .const import QUEST_WINDOWS -from .dao_children import ChildRecord, _child_from_row, _connection_lock -from .dao_children import _CHILD_COLUMNS -from .db import NestQuestDatabase -from .recurrence import RuleType, RuleValidationError, ScheduleRule - -#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL" -#: in update methods: passing ``None`` must mean clearing a nullable -#: column, not skipping it. -_UNSET = object() - -#: Date-shape policy: schedule-rule dates are ISO YYYY-MM-DD. The -#: schema's lexical end>=start CHECK cannot prove a value is a real -#: calendar date, so the DAO validates the shape it accepts. -_DATE_FORMAT = "%Y-%m-%d" - - -def _validate_date(value: str, field: str) -> None: - """Raise ValueError unless ``value`` is a YYYY-MM-DD calendar date. - - ``datetime.date.fromisoformat`` accepts ISO dates but is lenient on - some builds (e.g. '2026-9-4'); strptime with an exact format is - strict, and strptime round-trips the parsed value to reject zero - padding ('2026-9-4' parses but its isoformat differs). - """ - try: - parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date() - except (TypeError, ValueError): - raise ValueError( - f"{field} must be an ISO date (YYYY-MM-DD), got {value!r}" - ) from None - if parsed.isoformat() != value: - raise ValueError( - f"{field} must be a strict YYYY-MM-DD date, got {value!r}" - ) - - -_TIME_FORMAT = "%H:%M" - - -def _validate_time(value: str, field: str) -> None: - """Raise ValueError unless ``value`` is a strict 24-hour HH:MM. - - Same strictness policy as _validate_date: parse with the exact - format, then round-trip so '9:30' (non-padded) is rejected. Time - shapes are caller-side policy (the schema stores TEXT): windows' - optional ``due_time`` and definition ``due_time`` both use this. - """ - try: - parsed = datetime.datetime.strptime(value, _TIME_FORMAT).time() - except (TypeError, ValueError): - raise ValueError( - f"{field} must be a 24-hour HH:MM time, got {value!r}" - ) from None - if parsed.strftime(_TIME_FORMAT) != value: - raise ValueError( - f"{field} must be a strict HH:MM time, got {value!r}" - ) - - -def _validate_window(window: str) -> None: - """Raise ValueError unless ``window`` is a ``const`` window name.""" - if window not in QUEST_WINDOWS: - raise ValueError( - f"window must be one of {', '.join(QUEST_WINDOWS)}, " - f"got {window!r}" - ) - - -@dataclass(frozen=True) -class ScheduleRuleRecord: - """One row of ``schedule_rules``.""" - - id: int - rule_type: str - interval: int - weekday_set: str | None - day_of_month: int | None - nth_weekday: int | None - month: int | None - start_date: str - end_date: str | None - - -@dataclass(frozen=True) -class ScheduleRuleStorage: - """The eight value columns a ScheduleRule maps to (no id column). - - ``id`` is assigned by the database on insert, so the model<->storage - mapping concerns only the value columns. ``nth_weekday_weekday`` is - deliberately absent here: for MONTHLY_WEEKDAY its value is folded - into ``weekday_set`` as that column's single CSV element. - """ - - rule_type: str - interval: int - weekday_set: str | None - day_of_month: int | None - nth_weekday: int | None - month: int | None - start_date: str - end_date: str | None - - -@dataclass(frozen=True) -class QuestDefinitionRecord: - """One row of ``quest_definitions`` (without its assignees).""" - - id: int - title: str - description: str | None - icon: str | None - schedule_rule_id: int - due_time: str | None - is_active: bool - created_at: str - - -@dataclass(frozen=True) -class QuestDefinitionWindowRecord: - """One row of ``quest_definition_windows``.""" - - definition_id: int - window: str - due_time: str | None - - -@dataclass(frozen=True) -class QuestDefinitionSnapshot: - """A definition plus its rule, assignees and windows, read atomically. - - Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows` - and :meth:`QuestDefinitionsDao.edit_definition` with the rule, - assignees and windows read inside the SAME transaction that wrote - them, so the snapshot is a consistent creation/edit-time view — a - concurrent rule, assignee or window mutation cannot change it - between the write and the read. - """ - - definition: QuestDefinitionRecord - rule: ScheduleRuleRecord - assignees: list[ChildRecord] - windows: list[QuestDefinitionWindowRecord] - - -_RULE_COLUMNS = ( - "id, rule_type, interval, weekday_set, day_of_month, nth_weekday, " - "month, start_date, end_date" +from .core.dao_rules import * # noqa: F401,F403 +from .core.dao_rules import ( # noqa: F401 + _UNSET, + _validate_date, + _validate_time, + _validate_window, ) -_DEFINITION_COLUMNS = ( - "id, title, description, icon, schedule_rule_id, due_time, " - "is_active, created_at" -) -_WINDOW_COLUMNS = "definition_id, window, due_time" - - -def _rule_from_row(row: tuple) -> ScheduleRuleRecord: - return ScheduleRuleRecord( - id=row[0], - rule_type=row[1], - interval=row[2], - weekday_set=row[3], - day_of_month=row[4], - nth_weekday=row[5], - month=row[6], - start_date=row[7], - end_date=row[8], - ) - - -def _definition_from_row(row: tuple) -> QuestDefinitionRecord: - return QuestDefinitionRecord( - id=row[0], - title=row[1], - description=row[2], - icon=row[3], - schedule_rule_id=row[4], - due_time=row[5], - is_active=bool(row[6]), - created_at=row[7], - ) - - -def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord: - return QuestDefinitionWindowRecord( - definition_id=row[0], - window=row[1], - due_time=row[2], - ) - - -#: Storage columns that are forbidden for each rule type. A populated -#: forbidden column means the row's shape is invalid and must raise on -#: read, never be silently dropped. ``weekday_set`` is absent for -#: DAILY/MONTHLY_DAY/YEARLY; MONTHLY_WEEKDAY is the only shape allowed a -#: non-NULL ``nth_weekday``; YEARLY is the only shape allowed ``month``. -_FORBIDDEN_STORAGE_FIELDS: dict[str, tuple[RuleType, ...]] = { - "weekday_set": (RuleType.DAILY, RuleType.MONTHLY_DAY, RuleType.YEARLY), - "day_of_month": ( - RuleType.DAILY, - RuleType.WEEKLY, - RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_WEEKDAY, - ), - "nth_weekday": ( - RuleType.DAILY, - RuleType.WEEKLY, - RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_DAY, - RuleType.YEARLY, - ), - "month": ( - RuleType.DAILY, - RuleType.WEEKLY, - RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_DAY, - RuleType.MONTHLY_WEEKDAY, - ), -} - - -def schedule_rule_to_storage(rule: ScheduleRule) -> ScheduleRuleStorage: - """Map a validated ScheduleRule onto its storage columns. - - ``rule_type`` stores as ``RuleType.storage_value`` (MONTHLY_DAY and - MONTHLY_WEEKDAY both store ``'monthly'``); ``weekday_set`` stores as - a sorted CSV list. MONTHLY_WEEKDAY folds its model-only - ``nth_weekday_weekday`` into ``weekday_set`` as that list's single - element; every other type leaves the column as its own set - (WEEKLY/CUSTOM_DAYS) or NULL. - """ - shape = rule.rule_type - if shape is RuleType.MONTHLY_WEEKDAY: - weekday_set = str(rule.nth_weekday_weekday) - elif shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: - weekday_set = ",".join(str(w) for w in sorted(rule.weekday_set)) - else: - weekday_set = None - return ScheduleRuleStorage( - rule_type=shape.storage_value, - interval=rule.interval, - weekday_set=weekday_set, - day_of_month=rule.day_of_month, - nth_weekday=rule.nth_weekday, - month=rule.month, - start_date=rule.start_date, - end_date=rule.end_date, - ) - - -def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule: - """Reconstruct a ScheduleRule from storage columns. - - Disambiguates MONTHLY_DAY from MONTHLY_WEEKDAY by which field is - populated, and raises RuleValidationError rather than silently - mis-decoding an ambiguous or invalid shape. - """ - rule_type = storage.rule_type - if rule_type == "daily": - shape = RuleType.DAILY - elif rule_type == "weekly": - shape = RuleType.WEEKLY - elif rule_type == "monthly": - shape = _disambiguate_monthly(storage) - elif rule_type == "yearly": - shape = RuleType.YEARLY - elif rule_type == "custom": - shape = RuleType.CUSTOM_DAYS - else: - raise RuleValidationError( - f"unknown storage rule_type {rule_type!r}" - ) - - _reject_forbidden_storage_fields(storage, shape) - - weekday_set = None - nth_weekday_weekday = None - if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: - weekday_set = _weekdays_from_csv(storage.weekday_set) - elif shape is RuleType.MONTHLY_WEEKDAY: - nth_weekday_weekday = _single_weekday_from_csv(storage.weekday_set) - - return ScheduleRule( - rule_type=shape, - interval=storage.interval, - weekday_set=weekday_set, - day_of_month=storage.day_of_month, - nth_weekday=storage.nth_weekday, - nth_weekday_weekday=nth_weekday_weekday, - month=storage.month, - start_date=storage.start_date, - end_date=storage.end_date, - ) - - -def schedule_rule_storage_from_record( - record: ScheduleRuleRecord, -) -> ScheduleRuleStorage: - """Copy a rule row's value columns into a storage mapping. - - ``ScheduleRuleRecord`` is the read shape (it carries ``id``); - :func:`schedule_rule_from_storage` consumes the value-only - :class:`ScheduleRuleStorage` shape, so callers re-reading a stored - rule convert through this helper. - """ - return ScheduleRuleStorage( - rule_type=record.rule_type, - interval=record.interval, - weekday_set=record.weekday_set, - day_of_month=record.day_of_month, - nth_weekday=record.nth_weekday, - month=record.month, - start_date=record.start_date, - end_date=record.end_date, - ) - - -def _reject_forbidden_storage_fields( - storage: ScheduleRuleStorage, shape: RuleType -) -> None: - """Raise unless every populated column is permitted for ``shape``. - - The model constructor rejects a forbidden ``day_of_month``, - ``nth_weekday``, or ``month``, but ``weekday_set`` never reaches it - (it is folded for MONTHLY_WEEKDAY and dropped otherwise), so a daily - row carrying ``weekday_set="0"`` would otherwise decode as a valid - daily rule and silently lose the stored data. This validates every - shape column explicitly so a populated-forbidden column raises - instead of being discarded. - """ - for field, forbidden_shapes in _FORBIDDEN_STORAGE_FIELDS.items(): - value = getattr(storage, field) - if value is not None and shape in forbidden_shapes: - raise RuleValidationError( - f"{shape.value} storage rows must not set {field}, " - f"got {value!r}" - ) - - -def _disambiguate_monthly(storage: ScheduleRuleStorage) -> RuleType: - """Resolve the shared 'monthly' storage value to a model type. - - MONTHLY_DAY names ``day_of_month`` only; MONTHLY_WEEKDAY names - ``nth_weekday`` only. Neither, or both, is ambiguous and raises. - """ - day_of_month = storage.day_of_month - nth_weekday = storage.nth_weekday - if day_of_month is not None and nth_weekday is None: - return RuleType.MONTHLY_DAY - if nth_weekday is not None and day_of_month is None: - return RuleType.MONTHLY_WEEKDAY - raise RuleValidationError( - "storage rule_type 'monthly' is ambiguous: must name exactly one " - "of day_of_month or nth_weekday, got " - f"day_of_month={day_of_month!r}, nth_weekday={nth_weekday!r}" - ) - - -def _weekdays_from_csv(value: str | None) -> frozenset[int]: - """Parse a weekday CSV column into a frozenset of ints. - - ``None`` and ``""`` both yield an empty frozenset (the caller decides - whether empty is legal); a non-integer token raises - RuleValidationError rather than leaking a raw ValueError. - """ - if value is None or value == "": - return frozenset() - try: - entries = [int(token) for token in value.split(",")] - except ValueError: - raise RuleValidationError( - f"weekday_set must be a CSV of integers, got {value!r}" - ) from None - return frozenset(entries) - - -def _single_weekday_from_csv(value: str | None) -> int: - """The one weekday a MONTHLY_WEEKDAY stores in weekday_set. - - Parses the CSV into tokens FIRST and requires exactly one token, - so a duplicate pair like ``"1,1"`` is rejected rather than silently - canonicalized to a single weekday on re-storage. An empty or - multi-element column raises RuleValidationError. - """ - if value is None or value == "": - raise RuleValidationError( - "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " - f"weekday, got {value!r}" - ) - tokens = value.split(",") - if len(tokens) != 1: - raise RuleValidationError( - "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " - f"weekday, got {value!r}" - ) - try: - return int(tokens[0]) - except ValueError: - raise RuleValidationError( - "MONTHLY_WEEKDAY storage weekday_set must be an integer, " - f"got {value!r}" - ) from None - - -async def _insert_rule_row( - database: NestQuestDatabase, storage: ScheduleRuleStorage -) -> int: - """Insert one ``schedule_rules`` row and return its new id. - - Runs inside the caller's transaction (no lock or transaction of its - own) so a multi-table write can persist the rule together with the - rows that reference it. The caller is responsible for validating - the storage fields — :meth:`ScheduleRulesDao.create` validates the - date shape, and :meth:`QuestDefinitionsDao.create_with_rule_and_windows` - receives storage already mapped from a validated - :class:`~.recurrence.ScheduleRule`. - """ - result = await database.execute( - "INSERT INTO schedule_rules (rule_type, interval, weekday_set, " - "day_of_month, nth_weekday, month, start_date, end_date) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ( - storage.rule_type, - storage.interval, - storage.weekday_set, - storage.day_of_month, - storage.nth_weekday, - storage.month, - storage.start_date, - storage.end_date, - ), - ) - return result.lastrowid - - -class ScheduleRulesDao: - """Typed async access to the ``schedule_rules`` table.""" - - def __init__(self, database: NestQuestDatabase) -> None: - self._database = database - - async def create( - self, - rule_type: str, - start_date: str, - *, - interval: int = 1, - weekday_set: str | None = None, - day_of_month: int | None = None, - nth_weekday: int | None = None, - month: int | None = None, - end_date: str | None = None, - ) -> ScheduleRuleRecord: - """Insert one rule and return the record as stored. - - Rule-type coherence (a weekly rule needs ``weekday_set``, a - yearly rule needs ``month``, etc.) is enforced by the schema - CHECKs and surfaces as :class:`sqlite3.IntegrityError`. Date - shape (strict YYYY-MM-DD) is validated here because the - schema's lexical end>=start CHECK cannot prove a real calendar - date. - """ - _validate_date(start_date, "start_date") - if end_date is not None: - _validate_date(end_date, "end_date") - rule_id = await _insert_rule_row( - self._database, - ScheduleRuleStorage( - rule_type=rule_type, - interval=interval, - weekday_set=weekday_set, - day_of_month=day_of_month, - nth_weekday=nth_weekday, - month=month, - start_date=start_date, - end_date=end_date, - ), - ) - rule = await self.get(rule_id) - assert rule is not None - return rule - - async def get(self, rule_id: int) -> ScheduleRuleRecord | None: - """Return the rule with ``rule_id``, or None.""" - row = await self._database.fetch_one( - f"SELECT {_RULE_COLUMNS} FROM schedule_rules WHERE id = ?", - (rule_id,), - ) - return _rule_from_row(row) if row is not None else None - - async def update( - self, - rule_id: int, - *, - rule_type: str | None | object = _UNSET, - interval: int | None | object = _UNSET, - weekday_set: str | None | object = _UNSET, - day_of_month: int | None | object = _UNSET, - nth_weekday: int | None | object = _UNSET, - month: int | None | object = _UNSET, - start_date: str | None | object = _UNSET, - end_date: str | None | object = _UNSET, - ) -> int: - """Update the given fields; returns rows updated (0 if absent). - - Arguments default to the module sentinel ``_UNSET`` meaning - "leave this column alone"; passing ``None`` explicitly writes - SQL NULL, so callers can reopen an ended rule (``end_date`` - back to NULL) or clear nullable shape fields. Passing - ``None``/a new value for a date field validates its shape. - Editing a rule that a definition references changes future - materializations only — this method touches no instances - (Feature 06/07 separation). - """ - date_args = ( - ("start_date", start_date), - ("end_date", end_date), - ) - for field, value in date_args: - if value is not _UNSET and value is not None: - _validate_date(value, field) - assignments: list[str] = [] - parameters: list[object] = [] - for column, value in ( - ("rule_type", rule_type), - ("interval", interval), - ("weekday_set", weekday_set), - ("day_of_month", day_of_month), - ("nth_weekday", nth_weekday), - ("month", month), - ("start_date", start_date), - ("end_date", end_date), - ): - if value is not _UNSET: - assignments.append(f"{column} = ?") - parameters.append(value) - if not assignments: - return 0 - parameters.append(rule_id) - result = await self._database.execute( - f"UPDATE schedule_rules SET {', '.join(assignments)} " - "WHERE id = ?", - tuple(parameters), - ) - return result.rowcount - - async def delete_if_unreferenced(self, rule_id: int) -> bool: - """Delete the rule only if no definition references it. - - Returns True if the rule was deleted, False if it is still - referenced (or does not exist — distinguish via :meth:`get`). - The reference check and the DELETE run inside one transaction - under the connection lock, so a definition cannot start - referencing the rule between the check and the delete. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - row = await self._database.fetch_one( - "SELECT 1 FROM quest_definitions " - "WHERE schedule_rule_id = ? LIMIT 1", - (rule_id,), - ) - if row is not None: - return False - result = await self._database.execute( - "DELETE FROM schedule_rules WHERE id = ?", - (rule_id,), - ) - return result.rowcount > 0 - - -class QuestDefinitionsDao: - """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). 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: - self._database = database - - async def create( - self, - title: str, - schedule_rule_id: int, - created_at: str, - *, - description: str | None = None, - 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 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_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, schedule_rule_id, due_time, is_active, " - "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - ( - title, - description, - icon, - schedule_rule_id, - due_time, - int(is_active), - created_at, - ), - ) - 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 create_with_rule_and_windows( - self, - title: str, - schedule_rule: ScheduleRuleStorage, - created_at: str, - assignee_child_ids: list[int], - windows: list[tuple[str, str | None]], - *, - description: str | None = None, - icon: str | None = None, - ) -> QuestDefinitionSnapshot: - """Insert a rule, definition, assignees and windows atomically. - - The whole write — the schedule rule row, the definition row, - every assignee link and every window declaration — runs inside - ONE transaction under the connection lock, so a validation or - write failure rolls the lot back together (all-or-nothing): the - caller can never observe a definition with a missing rule, - assignee or window, and a rejected create leaves no partial - rows. - - Returns a :class:`QuestDefinitionSnapshot` whose assignees and - windows are read back inside the same transaction, so the - returned view cannot be raced by a concurrent assignee/window - mutation after the write. - - The rule arrives as pre-validated storage fields (mapped from a - :class:`~.recurrence.ScheduleRule` via - :func:`schedule_rule_to_storage`); its dates are re-validated - here so a hand-built storage can never persist a malformed date. - The definition-level ``due_time`` column is deliberately left - NULL — per-window due times supersede it (D-008). Assignees are - validated as existing-and-active at insert time, and windows are - validated for name and strict HH:MM due time, matching - :meth:`upsert_window`. - """ - _validate_date(schedule_rule.start_date, "start_date") - if schedule_rule.end_date is not None: - _validate_date(schedule_rule.end_date, "end_date") - async with _connection_lock(self._database): - async with self._database.transaction(): - for child_id in assignee_child_ids: - await self._validate_assignable_child(child_id) - rule_id = await _insert_rule_row(self._database, schedule_rule) - result = await self._database.execute( - "INSERT INTO quest_definitions (title, description, " - "icon, schedule_rule_id, due_time, is_active, " - "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - (title, description, icon, rule_id, None, 1, created_at), - ) - definition_id = result.lastrowid - for child_id in assignee_child_ids: - await self._database.execute( - "INSERT INTO quest_definition_assignees " - "(definition_id, child_id) VALUES (?, ?)", - (definition_id, child_id), - ) - for window, due_time in windows: - _validate_window(window) - if due_time is not None: - _validate_time(due_time, "due_time") - await self._database.execute( - "INSERT INTO quest_definition_windows " - "(definition_id, window, due_time) VALUES (?, ?, ?)", - (definition_id, window, due_time), - ) - definition = await self.get(definition_id) - assignees = await self.list_assignees(definition_id) - window_records = await self.list_windows(definition_id) - rule_record = await ScheduleRulesDao(self._database).get( - rule_id - ) - assert definition is not None - assert rule_record is not None - return QuestDefinitionSnapshot( - definition=definition, - rule=rule_record, - assignees=assignees, - windows=window_records, - ) - - async def edit_definition( - self, - definition_id: int, - *, - title: str | None | object = _UNSET, - description: str | None | object = _UNSET, - icon: str | None | object = _UNSET, - rule: ScheduleRuleStorage | object = _UNSET, - windows: list[tuple[str, str | None]] | object = _UNSET, - ) -> QuestDefinitionSnapshot: - """Edit a definition's metadata, rule and windows atomically. - - Mirrors :meth:`create_with_rule_and_windows`: everything runs - inside ONE transaction under the connection lock, and the - returned snapshot's rule, assignees and windows are read back - inside that transaction. Arguments default to the module sentinel - ``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL - NULL so optional metadata (description, icon) can be cleared. - - - ``title``/``description``/``icon`` update the definition row - in place; the definition-level ``due_time`` column is never - written (per-window due times supersede it, D-008). - - ``rule`` (a pre-validated :class:`ScheduleRuleStorage`) - overwrites the referenced ``schedule_rules`` row in place: the - definition keeps its ``schedule_rule_id``, so no new or - orphaned rule row appears. - - ``windows`` REPLACES the whole window set: the current rows - are deleted and the given set re-inserted, so missing windows - are added, changed due times updated and removed windows - dropped. - - Assignment is deliberately not settable here (multi-assignee is - managed by :meth:`add_assignee`/:meth:`remove_assignee`), and - activation is untouched. Raises ValueError when the definition - does not exist. Edits change future instances only: no - ``quest_instances`` or ``completion_events`` row is touched. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - definition = await self.get(definition_id) - if definition is None: - raise ValueError( - f"quest definition {definition_id} does not exist" - ) - assignments: list[str] = [] - parameters: list[object] = [] - for column, value in ( - ("title", title), - ("description", description), - ("icon", icon), - ): - if value is not _UNSET: - assignments.append(f"{column} = ?") - parameters.append(value) - if assignments: - parameters.append(definition_id) - await self._database.execute( - f"UPDATE quest_definitions SET " - f"{', '.join(assignments)} WHERE id = ?", - tuple(parameters), - ) - if rule is not _UNSET: - await ScheduleRulesDao(self._database).update( - definition.schedule_rule_id, - rule_type=rule.rule_type, - interval=rule.interval, - weekday_set=rule.weekday_set, - day_of_month=rule.day_of_month, - nth_weekday=rule.nth_weekday, - month=rule.month, - start_date=rule.start_date, - end_date=rule.end_date, - ) - if windows is not _UNSET: - await self._database.execute( - "DELETE FROM quest_definition_windows " - "WHERE definition_id = ?", - (definition_id,), - ) - for window, due_time in windows: - _validate_window(window) - if due_time is not None: - _validate_time(due_time, "due_time") - await self._database.execute( - "INSERT INTO quest_definition_windows " - "(definition_id, window, due_time) " - "VALUES (?, ?, ?)", - (definition_id, window, due_time), - ) - updated = await self.get(definition_id) - assignees = await self.list_assignees(definition_id) - window_records = await self.list_windows(definition_id) - rule_record = await ScheduleRulesDao(self._database).get( - updated.schedule_rule_id - ) - assert updated is not None - assert rule_record is not None - return QuestDefinitionSnapshot( - definition=updated, - rule=rule_record, - assignees=assignees, - windows=window_records, - ) - - 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. - """ - rule = await self._database.fetch_one( - "SELECT 1 FROM schedule_rules WHERE id = ?", - (schedule_rule_id,), - ) - if rule is None: - raise ValueError( - f"schedule rule {schedule_rule_id} does not exist" - ) - - 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 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[QuestDefinitionRecord]: - """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 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] - - 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 quest_definitions " - "WHERE is_active = 1 ORDER BY id" - ) - return [_definition_from_row(row) for row in rows] - - async def _snapshots_for( - self, definitions: list[QuestDefinitionRecord] - ) -> list[QuestDefinitionSnapshot]: - """Read each definition's rule, assignees and windows. - - MUST be called inside the connection lock (and ideally a - transaction) so the definitions handed in and the rows this - reads back cannot be raced by a concurrent edit between list - and bundle. Each rule is read through - :class:`ScheduleRulesDao` on the same connection, so the - snapshot's rule record is the one the definition references AT - the moment of the locked read. - """ - rules_dao = ScheduleRulesDao(self._database) - snapshots: list[QuestDefinitionSnapshot] = [] - for definition in definitions: - rule_record = await rules_dao.get(definition.schedule_rule_id) - assert rule_record is not None - snapshots.append( - QuestDefinitionSnapshot( - definition=definition, - rule=rule_record, - assignees=await self.list_assignees(definition.id), - windows=await self.list_windows(definition.id), - ) - ) - return snapshots - - async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]: - """Return every active definition with its rule, assignees and - windows, all read inside one locked transaction. - - The definitive list and each definition's linked rows are read - in a single BEGIN..COMMIT span under the connection lock, so a - concurrent atomic edit cannot interleave between the definition - list and the bundle read: the caller receives a coherent - snapshot (each definition's rule, assignees and windows as they - were at one instant), never a mixed old-rule/new-windows state. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - definitions = await self.list_active() - return await self._snapshots_for(definitions) - - async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]: - """Return every active definition's snapshot WITHOUT locking. - - Mirrors :meth:`list_snapshots_active` minus the lock and - transaction: the definitions and their rules, assignees and - windows are read on the caller's connection under whatever - transaction the caller is already holding, so they stay coherent - with every OTHER table the caller reads inside that same - transaction. MUST be called inside the connection lock and an - open transaction (the single-transaction input snapshot the - materialization walk uses is that caller). - """ - definitions = await self.list_active() - return await self._snapshots_for(definitions) - - async def list_snapshots_by_child( - self, child_id: int - ) -> list[QuestDefinitionSnapshot]: - """Return each definition assigned to ``child_id`` with its rule, - assignees and windows, all read inside one locked transaction. - - Mirrors :meth:`list_snapshots_active` but selects by assignment - (``list_by_child`` order: newest first). Locks read-and-bundle - into one transaction so the bundle never mixes one edit's - definition with another's windows. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - definitions = await self.list_by_child(child_id) - return await self._snapshots_for(definitions) - - async def update( - self, - definition_id: int, - *, - title: str | None | object = _UNSET, - description: str | None | object = _UNSET, - icon: str | None | object = _UNSET, - due_time: str | None | object = _UNSET, - ) -> int: - """Update the given fields; returns rows updated (0 if absent). - - Arguments default to the module sentinel ``_UNSET`` meaning - "leave this column alone"; passing ``None`` explicitly writes - 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:`add_assignee`, :meth:`remove_assignee`, - :meth:`set_active`) so they stay separately loggable and - permission-gateable. - """ - assignments: list[str] = [] - parameters: list[object] = [] - for column, value in ( - ("title", title), - ("description", description), - ("icon", icon), - ("due_time", due_time), - ): - if value is not _UNSET: - assignments.append(f"{column} = ?") - parameters.append(value) - if not assignments: - return 0 - parameters.append(definition_id) - result = await self._database.execute( - f"UPDATE quest_definitions SET {', '.join(assignments)} " - "WHERE id = ?", - tuple(parameters), - ) - return result.rowcount - - async def set_active(self, definition_id: int, is_active: bool) -> int: - """Deactivate/reactivate; returns rows updated (0 if absent). - - Deactivating stops future instance generation but leaves - existing instances and completion history untouched. - """ - result = await self._database.execute( - "UPDATE quest_definitions SET is_active = ? WHERE id = ?", - (int(is_active), definition_id), - ) - return result.rowcount - - async def add_assignee( - self, definition_id: int, child_id: int - ) -> list[ChildRecord]: - """Assign ``child_id`` to the definition (idempotent). - - 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. Returns the - definition's assignees read back INSIDE the same transaction - that performed the write, so the returned roster is a - consistent post-assignment snapshot — a concurrent assign or - unassign cannot race it after the write commits. 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) - definition = await self._database.fetch_one( - "SELECT 1 FROM quest_definitions WHERE id = ?", - (definition_id,), - ) - 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), - ) - return await self.list_assignees(definition_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 upsert_window( - self, - definition_id: int, - window: str, - *, - due_time: str | None = None, - ) -> QuestDefinitionWindowRecord: - """Declare (or re-declare) ``window`` on the definition. - - The composite primary key makes the write idempotent per - (definition, window): re-declaring updates ``due_time`` in - place instead of duplicating. The window name must be one of - ``const.QUEST_WINDOWS`` (the schema CHECK backs this up) and a - provided ``due_time`` must be strict 24-hour HH:MM. The - definition must exist — validated under the connection lock so - the verdict cannot go stale before the write. - """ - _validate_window(window) - if due_time is not None: - _validate_time(due_time, "due_time") - async with _connection_lock(self._database): - async with self._database.transaction(): - definition = await self._database.fetch_one( - "SELECT 1 FROM quest_definitions WHERE id = ?", - (definition_id,), - ) - if definition is None: - raise ValueError( - f"quest definition {definition_id} does not exist" - ) - await self._database.execute( - "INSERT INTO quest_definition_windows " - "(definition_id, window, due_time) VALUES (?, ?, ?) " - "ON CONFLICT (definition_id, window) DO UPDATE SET " - "due_time = excluded.due_time", - (definition_id, window, due_time), - ) - row = await self._database.fetch_one( - f"SELECT {_WINDOW_COLUMNS} " - "FROM quest_definition_windows " - "WHERE definition_id = ? AND window = ?", - (definition_id, window), - ) - assert row is not None - return _window_from_row(row) - - async def list_windows( - self, definition_id: int - ) -> list[QuestDefinitionWindowRecord]: - """Return the definition's windows in ``const`` order. - - Ordered by the canonical window sequence (morning, afternoon, - evening) rather than insertion order, so callers always render - the Quest Log's three columns consistently. A definition with - no windows returns []. - """ - rows = await self._database.fetch_all( - f"SELECT {_WINDOW_COLUMNS} FROM quest_definition_windows " - "WHERE definition_id = ?", - (definition_id,), - ) - by_name = {row[1]: _window_from_row(row) for row in rows} - return [ - by_name[name] - for name in QUEST_WINDOWS - if name in by_name - ] - - async def remove_window( - self, definition_id: int, window: str - ) -> bool: - """Remove the window declaration; True when a row was removed. - - Future materialization stops producing instances for this - (definition, window); existing instances and completion history - are never touched here (Feature 06 guardrail). - """ - _validate_window(window) - async with _connection_lock(self._database): - result = await self._database.execute( - "DELETE FROM quest_definition_windows " - "WHERE definition_id = ? AND window = ?", - (definition_id, window), - ) - return result.rowcount > 0 - - 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 INSERT commits. - """ - child = await self._database.fetch_one( - "SELECT is_active FROM children WHERE id = ?", (child_id,) - ) - if child is None: - raise ValueError(f"child {child_id} does not exist") - if not child[0]: - raise ValueError( - f"child {child_id} is inactive and cannot be assigned" - ) - - -async def load_materialization_input( - database: NestQuestDatabase, - range_start: str, - range_end: str, -): - """Read every materialization input in ONE locked transaction. - - Returns ``(snapshots, schedules, overrides)``: - - - ``snapshots``: every active definition with its rule, assignees and - windows (the :class:`QuestDefinitionSnapshot` shape). - - ``schedules`` / ``overrides``: the presence schedules and - range-scoped overrides of every assignee child, in - :mod:`.dao_presence`'s record shapes. - - The whole read — definitions, rules, assignees, windows, and the - assignees' presence schedules and overrides — runs inside a single - BEGIN..COMMIT span under the connection lock, so a concurrent edit - (definition, assignment, window, or presence) cannot interleave - between the parts and the walk never sees a mixed-time view. Each - table's SQL stays in its own DAO module: the presence portion is - delegated to :mod:`.dao_presence` (a lazy import here, since that - module imports this one). - """ - definitions_dao = QuestDefinitionsDao(database) - async with _connection_lock(database): - async with database.transaction(): - snapshots = await definitions_dao.read_snapshots_active() - child_ids = sorted( - { - child.id - for snapshot in snapshots - for child in snapshot.assignees - } - ) - from . import dao_presence # lazy: dao_presence imports us - - schedules, overrides = await dao_presence.read_snapshot_unlocked( - database, child_ids, range_start, range_end - ) - return snapshots, schedules, overrides \ No newline at end of file diff --git a/custom_components/nestquest/db.py b/custom_components/nestquest/db.py index c05af6b..b13a2e0 100644 --- a/custom_components/nestquest/db.py +++ b/custom_components/nestquest/db.py @@ -1,422 +1,31 @@ -"""Async SQLite connection wrapper for the NestQuest integration.""" +"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package. + +The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task +d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes +an executor callable rather than a ``hass`` object. This module +re-exports it and provides :func:`make_database`, a thin adapter that +constructs a wrapper backed by ``hass.async_add_executor_job``, so the +integration constructs the database the same way it always did. +""" from __future__ import annotations -import asyncio -import sqlite3 -from collections.abc import AsyncIterator, Awaitable, Sequence -from contextlib import asynccontextmanager -from dataclasses import dataclass -from pathlib import Path -from typing import Any +from typing import Any, TypeVar from homeassistant.core import HomeAssistant +from .core.db import ExecutionResult, NestQuestDatabase -@dataclass(frozen=True) -class ExecutionResult: - """Plain-data outcome of one statement. +__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"] - ``rowcount`` and ``lastrowid`` mirror the corresponding - :class:`sqlite3.Cursor` attributes; both are captured on the executor - thread so no sqlite3 object ever crosses back to the event loop. +T = TypeVar("T") + + +def make_database(hass: HomeAssistant) -> NestQuestDatabase: + """Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor. + + Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the + wrapper's executor is ``hass.async_add_executor_job``, so every + sqlite3 call runs on the HA executor thread and the event loop + never blocks. """ - - rowcount: int - lastrowid: int | None - - -class NestQuestDatabase: - """Async wrapper around a single SQLite connection. - - Every sqlite3 call is delegated to the Home Assistant executor through - ``hass.async_add_executor_job`` so the event loop is never blocked, and - only plain Python data (row tuples and :class:`ExecutionResult`) is - returned to the event loop. - - Every operation is serialized through one asyncio.Lock per wrapper. A - ``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so - interleaved coroutines cannot corrupt the transaction and concurrent - BEGINs are impossible; statements issued while a transaction is open - queue until it completes. Concurrent transactions on one wrapper are - not supported: a second ``transaction()`` entered while one is active - raises :class:`RuntimeError`. ``close()`` serializes on the same lock - and is idempotent. - - Every connection-affecting executor job (connect, pragmas, statements, - transaction lifecycle and close) is awaited through the settlement - helper :meth:`_await_settled`: if the awaiting task is cancelled while - a job is in flight, the job still settles on the worker thread before - the cancellation propagates, so the wrapper lock is never released - while the executor thread is mid-job. - """ - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the wrapper around a Home Assistant instance.""" - self._hass = hass - self._conn: sqlite3.Connection | None = None - self._rowcount: int = -1 - self._lastrowid: int | None = None - self._lock: asyncio.Lock | None = None - self._lock_loop: asyncio.AbstractEventLoop | None = None - self._in_transaction = False - self._tx_task: asyncio.Task | None = None - - def _get_lock(self) -> asyncio.Lock: - """Return the per-wrapper lock, (re)bound to the running loop. - - HA drives every wrapper call from one event loop, so the lock is - created once and reused; re-binding only when the running loop - differs keeps the wrapper usable from plain synchronous drivers - that run each call on a fresh loop. - """ - loop = asyncio.get_running_loop() - if self._lock is None or self._lock_loop is not loop: - self._lock = asyncio.Lock() - self._lock_loop = loop - return self._lock - - @property - def connected(self) -> bool: - """Return True while the underlying connection is open.""" - return self._conn is not None - - @property - def rowcount(self) -> int: - """Row count of the most recent statement, or -1 when unset.""" - return self._rowcount - - @property - def lastrowid(self) -> int | None: - """Row id of the most recent insert, or None when unset.""" - return self._lastrowid - - @asynccontextmanager - async def _serialized(self) -> AsyncIterator[None]: - """Hold the wrapper lock unless the caller owns the active transaction. - - A transaction holds the lock for its whole BEGIN..COMMIT span; - statements issued by the transaction's own task are already - serialized by it and must not re-acquire the non-reentrant lock. - Any other task queues on the lock until the transaction ends. - """ - if ( - self._in_transaction - and self._tx_task is not None - and asyncio.current_task() is self._tx_task - ): - yield - return - async with self._get_lock(): - yield - - async def open(self, path: Path | str) -> NestQuestDatabase: - """Open the SQLite file with WAL journal mode and foreign keys on. - - Cancellation-safe: if this call is cancelled while the connect job - is in flight, the job still settles (see :meth:`_await_settled`). - A connection the settled job created is never adopted by the - cancelled call; it is closed through a settled executor job instead - of being abandoned to garbage collection, and the cancellation - propagates afterwards, so a follow-up ``open()`` starts from a - clean state. - """ - if self._conn is not None: - return self - async with self._get_lock(): - if self._conn is not None: - return self - - def _open() -> sqlite3.Connection: - conn = sqlite3.connect( - path, check_same_thread=False, isolation_level=None - ) - try: - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA foreign_keys=ON") - except BaseException: - conn.close() - raise - return conn - - settled, cancelled = await self._await_settled_state( - self._hass.async_add_executor_job(_open) - ) - if cancelled: - if isinstance(settled, sqlite3.Connection): - - def _close_abandoned() -> None: - settled.close() - - await self._await_settled( - self._hass.async_add_executor_job(_close_abandoned) - ) - raise asyncio.CancelledError() - self._conn = settled - return self - - async def execute( - self, sql: str, parameters: Sequence[Any] = () - ) -> ExecutionResult: - """Run one statement in the executor and return its plain result.""" - async with self._serialized(): - conn = self._require_conn() - - def _execute() -> ExecutionResult: - cursor = conn.execute(sql, parameters) - return ExecutionResult(cursor.rowcount, cursor.lastrowid) - - result = await self._await_settled( - self._hass.async_add_executor_job(_execute) - ) - self._rowcount = result.rowcount - self._lastrowid = result.lastrowid - return result - - async def execute_many( - self, sql: str, parameters: Sequence[Sequence[Any]] - ) -> ExecutionResult: - """Run executemany in the executor and return its plain result.""" - async with self._serialized(): - conn = self._require_conn() - - def _execute_many() -> ExecutionResult: - cursor = conn.executemany(sql, parameters) - return ExecutionResult(cursor.rowcount, cursor.lastrowid) - - result = await self._await_settled( - self._hass.async_add_executor_job(_execute_many) - ) - self._rowcount = result.rowcount - self._lastrowid = result.lastrowid - return result - - async def fetch_one( - self, sql: str, parameters: Sequence[Any] = () - ) -> tuple | None: - """Run a query in the executor and return its first row, or None.""" - async with self._serialized(): - conn = self._require_conn() - - def _fetch_one() -> tuple[tuple | None, int, int | None]: - cursor = conn.execute(sql, parameters) - return cursor.fetchone(), cursor.rowcount, cursor.lastrowid - - row, rowcount, lastrowid = await self._await_settled( - self._hass.async_add_executor_job(_fetch_one) - ) - self._rowcount = rowcount - self._lastrowid = lastrowid - return row - - async def fetch_all( - self, sql: str, parameters: Sequence[Any] = () - ) -> list[tuple]: - """Run a query in the executor and return all of its rows.""" - async with self._serialized(): - conn = self._require_conn() - - def _fetch_all() -> tuple[list[tuple], int, int | None]: - cursor = conn.execute(sql, parameters) - return cursor.fetchall(), cursor.rowcount, cursor.lastrowid - - rows, rowcount, lastrowid = await self._await_settled( - self._hass.async_add_executor_job(_fetch_all) - ) - self._rowcount = rowcount - self._lastrowid = lastrowid - return rows - - @asynccontextmanager - async def transaction(self) -> AsyncIterator[None]: - """Run a block inside BEGIN/COMMIT, rolling back on any exception. - - The wrapper lock is held for the entire BEGIN..COMMIT span, so no - other statement can interleave inside the transaction. Entering a - second transaction while one is active raises RuntimeError. A - failing COMMIT (e.g. a deferred foreign-key violation) also triggers - a ROLLBACK before the commit error propagates, so the real - connection is never left inside a stray transaction. - - The lifecycle is cancellation-safe. Transaction ownership and the - wrapper lock are only released after the executor work has fully - settled, even when the owning task is cancelled mid-flight: if a - cancelled BEGIN or COMMIT still completes on the worker thread, - the corresponding ROLLBACK is shielded and awaited before any state - is cleared. A lifecycle statement that fails plus a failing - follow-up cleanup surface both errors in an ExceptionGroup. - """ - if self._in_transaction: - raise RuntimeError( - "A NestQuest transaction is already active on this " - "connection; concurrent transactions are not supported" - ) - async with self._get_lock(): - conn = self._require_conn() - self._in_transaction = True - self._tx_task = asyncio.current_task() - try: - - def _begin() -> None: - conn.execute("BEGIN") - - await self._settle_lifecycle( - conn, - self._hass.async_add_executor_job(_begin), - "BEGIN did not settle cleanly and the follow-up " - "cleanup also raised; the transaction may need manual " - "cleanup", - ) - try: - yield - except BaseException: - - def _rollback() -> None: - conn.execute("ROLLBACK") - - await self._settle_lifecycle( - conn, - self._hass.async_add_executor_job(_rollback), - "ROLLBACK failed and the follow-up cleanup also " - "raised; the transaction may need manual cleanup", - ) - raise - - def _commit() -> None: - conn.execute("COMMIT") - - await self._settle_lifecycle( - conn, - self._hass.async_add_executor_job(_commit), - "COMMIT failed and the follow-up cleanup also raised; " - "the transaction may need manual cleanup", - ) - finally: - self._in_transaction = False - self._tx_task = None - - async def _settle_lifecycle( - self, conn: sqlite3.Connection, job: Awaitable[Any], message: str - ) -> None: - """Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean. - - The job always settles first (see :meth:`_await_settled`), so a - cancelled statement that the worker thread completed anyway is - never abandoned mid-flight. If the job raised, a follow-up - ROLLBACK — itself settled — guarantees the connection ends outside - any SQLite transaction before the error propagates. When both the - job and the cleanup raise, both errors are preserved in an - ExceptionGroup instead of the original being lost. - """ - try: - await self._await_settled(job) - except BaseException as settle_error: - errors: list[BaseException] = [settle_error] - try: - await self._ensure_no_stray_transaction(conn) - except BaseException as cleanup_error: - errors.append(cleanup_error) - if len(errors) == 2: - raise BaseExceptionGroup(message, errors) from settle_error - raise - - async def _ensure_no_stray_transaction( - self, conn: sqlite3.Connection - ) -> None: - """Force the connection out of any transaction via ROLLBACK. - - Runs after a failed or cancelled lifecycle statement. The worker - thread may still be finishing (or may already have completed) the - very statement whose await was cancelled, so both the check and - the ROLLBACK settle in the executor before the caller releases - transaction ownership. - """ - - def _cleanup() -> bool: - if conn.in_transaction: - conn.execute("ROLLBACK") - return conn.in_transaction - - stray = await self._await_settled( - self._hass.async_add_executor_job(_cleanup) - ) - if stray: - raise RuntimeError( - "The SQLite connection is still inside a transaction after " - "cleanup; the transaction may need manual cleanup" - ) - - async def _await_settled(self, job: Awaitable[Any]) -> Any: - """Await ``job``, letting it settle even if this task is cancelled. - - Cancellation is observed, not swallowed: it is re-raised only once - the job has fully settled, so the caller can finalize against a - known connection state instead of abandoning in-flight work. The - job is wrapped in a task because a bare coroutine dies un-awaited - once its await is cancelled. A second cancellation during - settlement is absorbed the same way; if the job itself fails after - a cancellation, both errors surface in an ExceptionGroup. - """ - result, cancelled = await self._await_settled_state(job) - if cancelled: - raise asyncio.CancelledError() - return result - - async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]: - """Await ``job`` to settlement and report ``(result, cancelled)``. - - Same settlement guarantees as :meth:`_await_settled`, but returns - the settled outcome together with whether the awaiting task was - cancelled, so callers that must act on a settled result before - re-raising cancellation (e.g. closing an abandoned connection) - can do so without losing the result. - """ - cancelled = False - inner = asyncio.ensure_future(job) - while not inner.done(): - try: - await asyncio.shield(inner) - except asyncio.CancelledError: - cancelled = True - try: - result = inner.result() - except BaseException as job_error: - if cancelled: - raise BaseExceptionGroup( - "executor job failed while settling after cancellation", - [asyncio.CancelledError(), job_error], - ) from job_error - raise - return result, cancelled - - async def close(self) -> None: - """Close the connection in the executor; safe to call repeatedly. - - Serialized on the wrapper lock, so an in-flight operation or open - transaction always completes before the connection is closed. - """ - if ( - self._in_transaction - and self._tx_task is not None - and asyncio.current_task() is self._tx_task - ): - raise RuntimeError( - "Cannot close the NestQuest database from inside its own " - "transaction; exit the transaction first" - ) - async with self._get_lock(): - conn, self._conn = self._conn, None - self._rowcount, self._lastrowid = -1, None - if conn is None: - return - - def _close() -> None: - conn.close() - - await self._await_settled(self._hass.async_add_executor_job(_close)) - - def _require_conn(self) -> sqlite3.Connection: - if self._conn is None: - raise RuntimeError( - "The NestQuest database connection is closed; call open() first" - ) - return self._conn \ No newline at end of file + return NestQuestDatabase(hass.async_add_executor_job) diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index cf40a7a..d51d3c7 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -1,593 +1,11 @@ -"""Versioned schema migration runner for the NestQuest SQLite database. +"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package. -The runner owns one metadata table, ``nestquest_schema_version``, holding -a single row with the schema version the database file currently -reflects. Migrations are declared as an ordered list: entry ``n`` -upgrades version ``n-1`` to version ``n``. Applying a migration means -running its DDL inside one transaction and stamping the new version in -that same transaction, so a database never persists a half-applied -migration: any failure rolls the statements and the version stamp back -together, leaving the file at its prior version. - -Version 0 means "the file exists but carries no NestQuest schema yet" -— a fresh file, or a file this integration has never written. Starting -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. 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. Migration 4 adds the -windows table, migration 5 rebuilds ``quest_instances`` onto the -widened (definition_id, child_id, due_date, window) key, and migration -6 rebuilds ``completion_events`` with the D-008 ``actor_child_id`` -column. Migration 7 adds the generic ``nestquest_meta_state`` -key/value table for integration-owned bookkeeping (Feature 11's -missed-sweep watermark). 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 ``(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 -UPDATE because a version-0 database has no version row to update yet. -An UPDATE form would silently match zero rows there and leave the -database migrated but unstamped — reporting version 0 forever, and -re-applying migration 1 on every startup. The DELETE+INSERT shape is -also what makes the stamp a replace, so the row never duplicates. - -Idempotence across restarts: a database already at the latest version -has no pending migrations, so the runner opens a read-only check, finds -nothing to do, and leaves the file byte-untouched (no write transaction -is opened, so no WAL churn). - -Migration list policy: entries are append-only. Once shipped, a -migration's SQL must never be edited — an old database applying it for -the first time and a fresh database would then diverge. Fix-forward -with a new migration instead. The version stamp lives in the same -SQLite file as the data it describes, so restoring a backup restores -its matching schema version with it. - -Corrupt or missing files are not this module's concern: startup -handling of those cases is task d5bca74b, which decides whether to -open fresh or raise ConfigEntryNotReady. This runner assumes it is -handed an open, working connection. +The versioned migration runner lives in +:mod:`custom_components.nestquest.core.migrations` +(extracted in task d0b691d8); this module re-exports it so existing +``from .migrations import ...`` references inside the integration keep +working. """ from __future__ import annotations -import asyncio -import logging -from collections.abc import Awaitable, Callable, Sequence -from typing import Union - -from .db import NestQuestDatabase -from .schema import ( - COMPLETION_EVENTS_TABLE_SQL, - QUEST_DEFINITIONS_TABLE_SQL, - QUEST_INSTANCES_TABLE_SQL, - SCHEMA_V1_STATEMENTS, - SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, - SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, - SCHEMA_V7_META_STATE_DDL, -) - -LOGGER = logging.getLogger(__name__) - -#: One migration entry: either an ordered sequence of SQL statements or -#: 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 -#: never collide with a foreign ``schema_version`` table. -VERSION_TABLE = "nestquest_schema_version" - -#: DDL for the version metadata table. The singleton-row invariant is -#: enforced by the schema itself: ``id`` is fixed at 1 by CHECK, keyed -#: PRIMARY KEY, so the table can never hold a second row, and the -#: stamp write is an UPDATE (no DELETE+INSERT needed). ``version`` is -#: a positive integer: version 0 means "no row" and is never stored, -#: so a negative or zero stored value is corruption, not a state the -#: runner can act on. -VERSION_TABLE_DDL = f""" - CREATE TABLE IF NOT EXISTS {VERSION_TABLE} ( - id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), - version INTEGER NOT NULL CHECK (version >= 1) - ) -""" - -#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades -#: 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. -LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = ( - ("task_definitions", "quest_definitions"), - ("task_instances", "quest_instances"), -) - - -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) - - -#: Window assigned to instances migrated from the pre-window instance -#: shape (D-008). Pre-window rows were generated for whole-day -#: obligations; the morning window is the documented default landing -#: spot, and future dates are regenerated with real window declarations -#: by materialization (delete_future_uncompleted + re-run). -LEGACY_MIGRATED_WINDOW = "morning" - - -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 - 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 in its own transaction together - with the version stamp, so a crash rolls both back. - """ - 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") - - -async def _widen_instance_key( - database: NestQuestDatabase, target_version: int -) -> None: - """Bring ``quest_instances`` to the D-008 instance key. - - Databases whose instance table still carries the pre-window shape - (no ``window`` column, UNIQUE on (definition_id, due_date)) are - rebuilt via SQLite's canonical procedure into the widened shape — - UNIQUE (definition_id, child_id, due_date, window) — with every - migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing - ids (and the completion events referencing them) survive intact. - The old rows were unique per (definition, due_date), so pinning one - window cannot collide with the widened key. Fresh databases whose - table already matches the canonical shape only stamp. - - The rebuild runs with ``foreign_keys = OFF`` (dropping the old - table under enforcement would fire the implicit DELETE and violate - the ``completion_events`` foreign key) and verifies - ``PRAGMA foreign_key_check`` inside the transaction before the - stamp, so a corrupt copy aborts the whole migration. - """ - legacy_shape = not await _table_has_column( - database, "quest_instances", "window" - ) - await database.execute("PRAGMA foreign_keys = OFF") - try: - async with database.transaction(): - if legacy_shape: - await database.execute( - "CREATE TABLE quest_instances_rebuilt " - f"{QUEST_INSTANCES_TABLE_SQL}" - ) - await database.execute( - "INSERT INTO quest_instances_rebuilt " - "(id, definition_id, child_id, window, due_date, " - "due_time, generated_at) " - "SELECT id, definition_id, child_id, ?, due_date, " - "due_time, generated_at " - "FROM quest_instances ORDER BY id", - (LEGACY_MIGRATED_WINDOW,), - ) - await database.execute("DROP TABLE quest_instances") - await database.execute( - "ALTER TABLE quest_instances_rebuilt " - "RENAME TO quest_instances" - ) - violations = await database.fetch_all( - "PRAGMA foreign_key_check" - ) - if violations: - raise RuntimeError( - "instance rebuild produced foreign-key " - f"violations: {violations!r}" - ) - LOGGER.info( - "Widened quest_instances key; migrated rows pinned " - "to the %r window", - LEGACY_MIGRATED_WINDOW, - ) - await stamp_schema_version(database, target_version) - finally: - await database.execute("PRAGMA foreign_keys = ON") - - -async def _add_actor_child_id( - database: NestQuestDatabase, target_version: int -) -> None: - """Bring ``completion_events`` to the D-008 actor-child shape. - - The ``actor_child_id`` column records which panel profile was - tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone - because the canonical shape also carries an actor-pair coherence - CHECK (panel events require it, user events forbid it), and CHECK - constraints cannot be added to an existing table. Databases with - the legacy shape are therefore rebuilt via SQLite's canonical - procedure, backfilling each legacy panel row's ``actor_child_id`` - to ``child_id`` — pre-D-008 panel completions were always recorded - against the tapped child themselves — while admin rows stay NULL. - Row ids are preserved so ``quest_instances`` references and the - append-only history stay intact. Fresh databases already match - the canonical shape and only stamp. - - The rebuild runs with ``foreign_keys = OFF`` and verifies - ``PRAGMA foreign_key_check`` inside the transaction before the - stamp. The copy is an INSERT..SELECT, not an UPDATE: the - completion_events table keeps its append-only guarantee. - """ - legacy_shape = not await _table_has_column( - database, "completion_events", "actor_child_id" - ) - await database.execute("PRAGMA foreign_keys = OFF") - try: - async with database.transaction(): - if legacy_shape: - await database.execute( - "CREATE TABLE completion_events_rebuilt " - f"{COMPLETION_EVENTS_TABLE_SQL}" - ) - await database.execute( - "INSERT INTO completion_events_rebuilt " - "(id, instance_id, child_id, event_type, actor_source, " - "actor_user_id, actor_child_id, occurred_at, " - "was_on_time) " - "SELECT id, instance_id, child_id, event_type, " - "actor_source, actor_user_id, " - "CASE WHEN actor_source = 'panel' THEN child_id " - "ELSE NULL END, " - "occurred_at, was_on_time " - "FROM completion_events ORDER BY id" - ) - await database.execute("DROP TABLE completion_events") - await database.execute( - "ALTER TABLE completion_events_rebuilt " - "RENAME TO completion_events" - ) - violations = await database.fetch_all( - "PRAGMA foreign_key_check" - ) - if violations: - raise RuntimeError( - "completion_events rebuild produced foreign-key " - f"violations: {violations!r}" - ) - LOGGER.info( - "Added actor_child_id to completion_events; legacy " - "panel rows backfilled to their own child_id" - ) - 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 -#: 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, - _add_definition_assignees, - # Migration 4 (D-008): the windows table is a plain new table — a - # CREATE IF NOT EXISTS suffices for both fresh databases (which - # already made it in migration 1) and pre-window databases. - list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL), - # Migration 5 (D-008): rebuild quest_instances onto the widened - # (definition_id, child_id, due_date, window) key. - _widen_instance_key, - # Migration 6 (D-008): rebuild completion_events with the - # actor_child_id column and its actor-pair coherence CHECK, - # backfilling legacy panel rows to their own child_id. - _add_actor_child_id, - # Migration 7 (Feature 11): the generic meta_state key/value table - # — a plain new table, so CREATE IF NOT EXISTS suffices for both - # fresh databases (which already made it in migration 1) and - # pre-v7 databases. Strictly additive; no existing table changes. - list(SCHEMA_V7_META_STATE_DDL), -] - - -async def _create_version_table(database: NestQuestDatabase) -> None: - """Create the version metadata table if it does not exist. - - The table is created before any version check: it is the runner's - own bookkeeping, and creating it is idempotent. The DDL enforces - the singleton-row invariant (``id`` fixed at 1) and a positive - integer version at the database level. - """ - await database.execute(VERSION_TABLE_DDL) - - -async def read_schema_version(database: NestQuestDatabase) -> int: - """Return the schema version recorded in the database. - - Returns 0 when the version table or its row is missing — the - "fresh file" case. A stored version higher than the runner knows - is surfaced as-is; the caller decides how to treat a future version. - - Reading is deliberately passive: a missing table raises, it is not - swallowed into 0, because :func:`apply_migrations` always creates - the table before reading and a direct reader hitting "no such - table" is a caller bug worth surfacing, not a fresh file. - """ - row = await database.fetch_one( - f"SELECT version FROM {VERSION_TABLE} LIMIT 1" - ) - if row is None: - return 0 - version = row[0] - if not isinstance(version, int) or isinstance(version, bool): - raise RuntimeError( - f"Schema version metadata is not an integer: {version!r}" - ) - if version < 1: - raise RuntimeError( - f"Schema version metadata is invalid (must be >= 1): {version!r}" - ) - return version - - -#: One asyncio.Lock per connection wrapper, created lazily and bound to -#: the running loop, serializing whole apply_migrations runs so two -#: concurrent callers on one connection queue instead of colliding on -#: the wrapper's single-transaction rule. -_MIGRATION_LOCKS: dict[int, asyncio.Lock] = {} - - -def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock: - """Return the migration lock bound to ``database``'s running loop.""" - loop = asyncio.get_running_loop() - key = (id(database), id(loop)) - lock = _MIGRATION_LOCKS.get(key) - if lock is None: - lock = asyncio.Lock() - _MIGRATION_LOCKS[key] = lock - return lock - - -async def apply_migrations( - database: NestQuestDatabase, - migrations: Sequence[MigrationStep] = MIGRATIONS, -) -> int: - """Bring the database schema up to the latest version. - - Reads the current version, applies every pending migration in order - — each inside one transaction that also stamps the new version — - and returns the resulting version. Applying zero pending - migrations (database already current) is a no-op that logs at debug - level and opens no write transaction. - - The version is re-read inside each migration transaction before - deciding to apply it: the outer read is advisory only (loop bounds, - no-op fast path), and on one shared connection two interleaved - runners would otherwise both read the same version and both apply - a non-idempotent migration. Under the transaction's lock the - second runner sees the first runner's stamp and skips. - - Runners on one connection are serialized by an asyncio lock held - for the whole decide-and-apply span: NestQuestDatabase rejects a - second transaction while one is active (RuntimeError), so - concurrent callers must queue, and the lock makes that queueing - explicit. The loser of the race re-reads the version once it - acquires the lock, sees the winner's stamp, and applies nothing. - - Raises whatever the failing migration raised, with the database - rolled back to its prior version and prior rows untouched. - """ - async with _migration_lock(database): - await _create_version_table(database) - current = await read_schema_version(database) - latest = len(migrations) - - if current > latest: - raise RuntimeError( - f"Database schema version {current} is newer than this " - f"integration understands (latest known: {latest}); " - "downgrades are not supported" - ) - - if current == latest: - LOGGER.debug( - "NestQuest schema already at version %d; " - "no migrations to apply", - current, - ) - return current - - for target in range(current + 1, latest + 1): - applied = False - 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( - f"Database schema version {in_tx_version} is newer " - f"than this integration understands (latest known: " - f"{latest}); downgrades are not supported" - ) - if in_tx_version < target: - 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) - if applied: - LOGGER.info( - "NestQuest schema migrated to version %d", - target, - ) - - return latest \ No newline at end of file +from .core.migrations import * # noqa: F401,F403 diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index c114568..7e72994 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -1,621 +1,10 @@ -"""The ScheduleRule model: rule types, validation, and serialization. +"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package. -A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure -frozen dataclass with no Home Assistant and no database imports. The -evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02) -serializes its dict form into the ``schedule_rules`` table. - -Type model: one enum member per recurrence shape. ``MONTHLY_DAY`` -("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second -Tuesday of every month") are distinct types rather than one monthly -type with optional fields, so validation is total: a rule either -constructs, or raises :class:`RuleValidationError` naming the offending -field. Every type carries ONLY the fields its shape needs; carrying -any other shape's field is a construction error. - -Storage mapping (Feature 02, ``schedule_rules`` table): the enum's -``storage_value`` maps model types onto the schema's CHECK values — -MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are -disambiguated by which fields are populated; CUSTOM_DAYS stores as -``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the -table stores (one element for MONTHLY_WEEKDAY, which names the -position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field -carried through from_dict/to_dict for losslessness and folded into -``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the -model-side lossless pair; the DB mapping lives in the DAO layer and is -exercised there. - -Validation policy (fail fast at construction — the schema's CHECK -constraints are a second line of defense, but invalid rules must never -reach storage): - -- ``interval`` >= 1 for every rule. -- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden - elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans. -- ``day_of_month`` 1..31 is required for MONTHLY_DAY. -- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is - rejected) with a weekday 0..6 for MONTHLY_WEEKDAY. -- ``month`` 1..12 is required for YEARLY. -- ``start_date`` is a strict ISO date; ``end_date`` is optional and, - when present, on or after ``start_date``. - -Month-end policy (implemented in :func:`_occurs_monthly_day` and -documented for Feature 04): in each interval-eligible month, a -MONTHLY_DAY rule for a day that does not exist in that month — the -31st in April, the 30th or 29th in February, February 29th in a -non-leap year — fires on the LAST day of the month instead of being -skipped. A monthly chore still happens in every eligible month, -pinned to the month's end when its nominal date is absent; exactly one -day per eligible month matches the rule (nominal day when it exists, -month end when it does not). The interval anchors on nominal months -elapsed from start_date's month, so clamping never shifts the cadence. +The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence` +(extracted in task d0b691d8); this module re-exports it so existing +``from .recurrence import ...`` references inside the integration keep +working. """ from __future__ import annotations -import datetime -import enum -from dataclasses import dataclass, field - -#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers. -_DATE_FORMAT = "%Y-%m-%d" - -#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order). - - -class RuleType(enum.Enum): - """The six recurrence shapes the engine supports. - - ``storage_value`` is the string written to the ``schedule_rules`` - table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY - and MONTHLY_WEEKDAY are distinct shapes sharing one storage value, - disambiguated by which fields are populated. - """ - - DAILY = "daily" - WEEKLY = "weekly" - MONTHLY_DAY = "monthly_day" - MONTHLY_WEEKDAY = "monthly_weekday" - YEARLY = "yearly" - CUSTOM_DAYS = "custom_days" - - @property - def storage_value(self) -> str: - """The schema-level rule_type string this model type maps to.""" - return { - RuleType.DAILY: "daily", - RuleType.WEEKLY: "weekly", - RuleType.MONTHLY_DAY: "monthly", - RuleType.MONTHLY_WEEKDAY: "monthly", - RuleType.YEARLY: "yearly", - RuleType.CUSTOM_DAYS: "custom", - }[self] - - -class RuleValidationError(ValueError): - """Raised when a ScheduleRule field combination is invalid.""" - - -def _validate_date(value: str, field_name: str) -> None: - """Raise unless ``value`` is a strict YYYY-MM-DD calendar date.""" - try: - parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date() - except (TypeError, ValueError): - raise RuleValidationError( - f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}" - ) from None - if parsed.isoformat() != value: - raise RuleValidationError( - f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}" - ) - - -def _validate_weekday_set(weekday_set) -> frozenset[int]: - """Return a normalized frozenset or raise with a clear message. - - Sequence entries are validated BEFORE set() coercion: a list like - [0, False] must not collapse the boolean into 0, and unhashable - entries must raise RuleValidationError, never a raw TypeError. - """ - if isinstance(weekday_set, (list, tuple)): - for entry in weekday_set: - # _is_plain_int: bool and int subclasses (potentially - # unhashable) are rejected before set() coercion. - if not _is_plain_int(entry): - raise RuleValidationError( - f"weekday_set entries must be plain integers, got " - f"{entry!r}" - ) - weekday_set = set(weekday_set) - if not isinstance(weekday_set, (set, frozenset)): - raise RuleValidationError( - f"weekday_set must be a set of weekday integers, got " - f"{type(weekday_set).__name__}" - ) - if not weekday_set: - raise RuleValidationError( - "weekday_set must not be empty for weekly/custom rules" - ) - for entry in weekday_set: - if not _is_plain_int(entry): - raise RuleValidationError( - f"weekday_set entries must be plain integers, got " - f"{entry!r}" - ) - if not 0 <= entry <= 6: - raise RuleValidationError( - f"weekday_set entries must be 0 (Monday)..6 (Sunday), " - f"got {entry!r}" - ) - return frozenset(weekday_set) - - -def _is_plain_int(value) -> bool: - """True only for real ints: bool excluded AND int subclasses too - (an unhashable int subclass would break the frozen-hashable model).""" - return type(value) is int - - -def _validate_monthly_weekday(nth_weekday, weekday) -> None: - """Validate the MONTHLY_WEEKDAY position fields.""" - if nth_weekday is None or not _is_plain_int(nth_weekday): - raise RuleValidationError( - "nth_weekday must be an integer for monthly_weekday rules, " - f"got {nth_weekday!r}" - ) - if not (-1 <= nth_weekday <= 5) or nth_weekday == 0: - raise RuleValidationError( - f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}" - ) - if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6: - raise RuleValidationError( - f"monthly_weekday rules require a weekday 0 (Monday)..6 " - f"(Sunday), got {weekday!r}" - ) - - - - - -@dataclass(frozen=True) -class ScheduleRule: - """An immutable, validated recurrence rule (see module docstring). - - Construct with keyword arguments; invalid combinations raise - :class:`RuleValidationError` at construction time. Fields map - one-to-one onto the ``schedule_rules`` table columns, with - ``nth_weekday_weekday`` as the model-only weekday companion to - ``nth_weekday`` (serialized inside ``weekday_set`` at the DAO - boundary). - """ - - rule_type: RuleType - interval: int = 1 - weekday_set: frozenset[int] | None = None - day_of_month: int | None = None - nth_weekday: int | None = None - nth_weekday_weekday: int | None = None - month: int | None = None - start_date: str = "1970-01-01" - end_date: str | None = None - - def __post_init__(self) -> None: - # Normalize a string rule_type into the enum; anything else - # that is not already a RuleType is rejected. - if isinstance(self.rule_type, str): - # EXACT model names only: no whitespace stripping, no storage - # aliases. The DAO maps storage strings to model fields when - # reading rows; the model boundary stays unambiguous. - candidates = {rt.name.lower(): rt for rt in RuleType} - if self.rule_type in candidates: - object.__setattr__( - self, "rule_type", candidates[self.rule_type] - ) - else: - raise RuleValidationError( - f"rule_type must be a RuleType or one of " - f"{sorted(candidates)}, got {self.rule_type!r}" - ) - if not isinstance(self.rule_type, RuleType): - raise RuleValidationError( - f"rule_type must be a RuleType or one of its string " - f"values, got {self.rule_type!r}" - ) - if not _is_plain_int(self.interval): - raise RuleValidationError( - f"interval must be an integer >= 1, got {self.interval!r}" - ) - if self.interval < 1: - raise RuleValidationError( - f"interval must be >= 1, got {self.interval!r}" - ) - - _validate_date(self.start_date, "start_date") - if self.end_date is not None: - _validate_date(self.end_date, "end_date") - if self.end_date < self.start_date: - raise RuleValidationError( - f"end_date {self.end_date!r} must be on or after " - f"start_date {self.start_date!r}" - ) - - # Type-exclusive field policy, enforced for every type: build - # the dict of present fields, then reject every field the shape - # must not carry (weekday_set present is detectable via None vs - # set even after normalization below). - shape = self.rule_type - if shape is RuleType.DAILY: - forbidden_fields = ( - "weekday_set", - "day_of_month", - "nth_weekday", - "nth_weekday_weekday", - "month", - ) - elif shape is RuleType.WEEKLY: - forbidden_fields = ( - "day_of_month", - "nth_weekday", - "nth_weekday_weekday", - "month", - ) - elif shape is RuleType.CUSTOM_DAYS: - forbidden_fields = ( - "day_of_month", - "nth_weekday", - "nth_weekday_weekday", - "month", - ) - elif shape is RuleType.MONTHLY_DAY: - forbidden_fields = ("weekday_set", "nth_weekday", - "nth_weekday_weekday", "month") - elif shape is RuleType.MONTHLY_WEEKDAY: - forbidden_fields = ("weekday_set", "day_of_month", "month") - elif shape is RuleType.YEARLY: - forbidden_fields = ( - "weekday_set", - "nth_weekday", - "nth_weekday_weekday", - ) - present_forbidden = [ - name - for name in forbidden_fields - if getattr(self, name) is not None - ] - if present_forbidden: - raise RuleValidationError( - f"{shape.value} rules must not set " - f"{', '.join(sorted(present_forbidden))}" - ) - - # Required fields per shape. - if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: - normalized = _validate_weekday_set(self.weekday_set) - object.__setattr__(self, "weekday_set", normalized) - elif shape is RuleType.MONTHLY_DAY: - if self.day_of_month is None or not _is_plain_int( - self.day_of_month - ): - raise RuleValidationError( - "monthly_day rules require day_of_month (1..31)" - ) - if not 1 <= self.day_of_month <= 31: - raise RuleValidationError( - f"day_of_month must be 1..31, got {self.day_of_month!r}" - ) - elif shape is RuleType.MONTHLY_WEEKDAY: - _validate_monthly_weekday( - self.nth_weekday, self.nth_weekday_weekday - ) - elif shape is RuleType.YEARLY: - if self.month is None or not _is_plain_int(self.month): - raise RuleValidationError( - "yearly rules require month (1..12)" - ) - if not 1 <= self.month <= 12: - raise RuleValidationError( - f"month must be 1..12, got {self.month!r}" - ) - if self.day_of_month is not None and not ( - _is_plain_int(self.day_of_month) - and 1 <= self.day_of_month <= 31 - ): - raise RuleValidationError( - f"day_of_month must be 1..31 for yearly rules, got " - f"{self.day_of_month!r}" - ) - - def to_dict(self) -> dict: - """Return the lossless dict form (model-side storage shape). - - ``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's - weekday travels inside it (appended) plus - ``nth_weekday_weekday`` so the pair round-trips without - ambiguity at the model layer. - """ - return { - "rule_type": self.rule_type.name.lower(), # model name - "interval": self.interval, - "weekday_set": ( - sorted(self.weekday_set) if self.weekday_set is not None - else None - ), - "day_of_month": self.day_of_month, - "nth_weekday": self.nth_weekday, - "nth_weekday_weekday": self.nth_weekday_weekday, - "month": self.month, - "start_date": self.start_date, - "end_date": self.end_date, - } - - @classmethod - def from_dict(cls, data: dict) -> "ScheduleRule": - """Rebuild a rule from :meth:`to_dict` output. - - Raises :class:`RuleValidationError` on unknown keys, missing - keys, or invalid combinations — never a raw TypeError. - """ - if not isinstance(data, dict): - raise RuleValidationError( - f"rule data must be a dict, got {type(data).__name__}" - ) - allowed = { - "rule_type", - "interval", - "weekday_set", - "day_of_month", - "nth_weekday", - "nth_weekday_weekday", - "month", - "start_date", - "end_date", - } - unknown = [key for key in data if key not in allowed] - if unknown: - raise RuleValidationError( - f"unknown rule fields: {sorted(unknown, key=repr)}" - ) - if "rule_type" not in data: - raise RuleValidationError("missing required rule field: rule_type") - weekday_set = data.get("weekday_set") - if isinstance(weekday_set, (list, tuple)): - # Validate entries as plain ints BEFORE set() coercion, so a - # list like [0, False] cannot collapse False into 0 and an - # unhashable/int-subclass entry cannot leak a raw TypeError. - for entry in weekday_set: - if not _is_plain_int(entry): - raise RuleValidationError( - f"weekday_set entries must be plain integers, got " - f"{entry!r}" - ) - weekday_set = set(weekday_set) - return cls( - rule_type=data["rule_type"], - interval=data.get("interval", 1), - weekday_set=weekday_set, - day_of_month=data.get("day_of_month"), - nth_weekday=data.get("nth_weekday"), - nth_weekday_weekday=data.get("nth_weekday_weekday"), - month=data.get("month"), - start_date=data.get("start_date", "1970-01-01"), - end_date=data.get("end_date"), - ) - - -# --------------------------------------------------------------------------- -# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule -# --------------------------------------------------------------------------- - - -def _parse_date(value: str) -> datetime.date: - """Parse a strict ISO date already validated by the model.""" - return datetime.datetime.strptime(value, _DATE_FORMAT).date() - - -def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int: - """Day offset from the rule's anchor date to ``target``. - - The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the - first day the rule CAN fire. A negative offset means the target - precedes the rule's window entirely. - """ - return (target - _parse_date(rule.start_date)).days - - -def _within_window(rule: ScheduleRule, target: datetime.date) -> bool: - """True when ``target`` is inside the rule's [start, end] window.""" - if target < _parse_date(rule.start_date): - return False - if rule.end_date is not None and target > _parse_date(rule.end_date): - return False - return True - - -def _month_end(year: int, month: int) -> int: - """The last calendar day of the given month (leap-aware).""" - if month == 12: - return 31 - return (datetime.date(year, month + 1, 1) - - datetime.timedelta(days=1)).day - - -def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool: - """MONTHLY_DAY evaluation with the documented month-end policy. - - Policy: in each interval-eligible month, a rule for a day-of-month - that does not exist in that month (the 31st in April, the 30th in - February, the 29th of February in a non-leap year) fires on the - LAST day of that month instead of being skipped — the chore still - happens that month, pinned to the month's end when its nominal date - is absent. - - The interval anchors on months ELAPSED from start_date's month: the - rule fires when the months between the anchor month and the target's - month form a multiple of the interval, keeping "every 2 months on - the 15th" aligned to the anchor month even when the firing lands on - a clamped month-end day. - """ - anchor = _parse_date(rule.start_date) - months_elapsed = ( - (target.year - anchor.year) * 12 + (target.month - anchor.month) - ) - if months_elapsed % rule.interval != 0: - return False - month_end = _month_end(target.year, target.month) - return target.day == min(rule.day_of_month, month_end) - - -def _nth_weekday_of_month( - year: int, month: int, weekday: int, nth: int -) -> int | None: - """The day-of-month of the ``nth`` ``weekday``, or None when the - month has no such occurrence (``nth`` 5 beyond four occurrences). - - Positive ``nth`` counts forward (1 = first); ``-1`` counts from the - end (last occurrence, whatever the total). Pure calendar math. - """ - if nth == -1: - last = datetime.date(year, month, _month_end(year, month)) - back = (last.weekday() - weekday) % 7 - return last.day - back - first = datetime.date(year, month, 1) - first_offset = (weekday - first.weekday()) % 7 - day = 1 + first_offset + (nth - 1) * 7 - if day > _month_end(year, month): - return None - return day - - -def _occurs_monthly_weekday( - rule: ScheduleRule, target: datetime.date -) -> bool: - """MONTHLY_WEEKDAY evaluation. - - Fires when the target is the ``nth`` occurrence of - ``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A - month with fewer occurrences than ``nth`` (5 on a four-occurrence - month) does not fire — n 5 is accepted by the model but skips such - months, matching the done-condition. The interval anchors on - nominal months elapsed from start_date's month. - """ - anchor = _parse_date(rule.start_date) - months_elapsed = ( - (target.year - anchor.year) * 12 + (target.month - anchor.month) - ) - if months_elapsed % rule.interval != 0: - return False - if target.weekday() != rule.nth_weekday_weekday: - return False - nth_day = _nth_weekday_of_month( - target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday - ) - return nth_day is not None and target.day == nth_day - - -def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool: - """YEARLY evaluation with the documented leap-day clamp policy. - - Policy: in each interval-eligible year, the rule fires on - ``day_of_month`` of ``month`` when that day exists. A YEARLY rule - for February 29th fires on FEBRUARY 28TH in a non-leap year (the - same clamp-as-month-end policy as MONTHLY_DAY: the annual chore - still happens every year, pinned to February's end when its - nominal date is absent) rather than silently vanishing. A YEARLY - rule with NO day_of_month fires on the first day of ``month``. - The interval anchors on years elapsed from start_date's year. - """ - anchor = _parse_date(rule.start_date) - years_elapsed = target.year - anchor.year - if years_elapsed % rule.interval != 0: - return False - if target.month != rule.month: - return False - if rule.day_of_month is None: - # Day-less yearly rules fire on the first of the month. - return target.day == 1 - month_end = _month_end(target.year, target.month) - return target.day == min(rule.day_of_month, month_end) - - -def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: - """Return True when ``rule`` fires on ``target``. - - Pure function: no I/O, no timezone awareness, no mutation. Dates - are plain calendar dates (Feature 04 guardrail); the caller decides - the timezone that produced them. - """ - # Dispatch FIRST: an unimplemented shape must raise loudly even for - # dates outside the window, never silently evaluate to False. - shape = rule.rule_type - if shape not in ( - RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY, - ): - raise NotImplementedError( - f"occurs_on for {shape} lands with its own engine task " - "(Features 04 task sequence)" - ) - if not _within_window(rule, target): - return False - if shape is RuleType.DAILY: - offset = (target - _parse_date(rule.start_date)).days - return offset % rule.interval == 0 - if shape is RuleType.MONTHLY_DAY: - return _occurs_monthly_day(rule, target) - if shape is RuleType.MONTHLY_WEEKDAY: - return _occurs_monthly_weekday(rule, target) - if shape is RuleType.YEARLY: - return _occurs_yearly(rule, target) - # WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never - # ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO - # parity on January 1st; anchor arithmetic does not). - anchor = _parse_date(rule.start_date) - if shape is RuleType.CUSTOM_DAYS: - interval = 1 - else: - interval = rule.interval - week_offset = (target - anchor).days // 7 - if week_offset % interval != 0: - return False - return target.weekday() in rule.weekday_set - - -def occurrences_between( - rule: ScheduleRule, start: str, end: str -) -> list[str]: - """Return every date string in [start, end] the rule fires on. - - Inclusive on both ends. Raises RuleValidationError on malformed or - inverted bounds; a window entirely outside [rule.start_date, - rule.end_date] yields an empty list. - """ - if rule.rule_type not in ( - RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY, - ): - raise NotImplementedError( - f"occurrences_between for {rule.rule_type} lands with its own " - "engine task (Features 04 task sequence)" - ) - _validate_date(start, "start") - _validate_date(end, "end") - start_date = _parse_date(start) - end_date = _parse_date(end) - if end_date < start_date: - raise RuleValidationError( - f"end {end!r} must be on or after start {start!r}" - ) - rule_start = _parse_date(rule.start_date) - if end_date < rule_start: - return [] - # Clamp the walk to the rule's own window and the queried range. - effective_start = max(start_date, rule_start) - effective_end = end_date - if rule.end_date is not None: - effective_end = min(effective_end, _parse_date(rule.end_date)) - results: list[str] = [] - cursor = effective_start - while cursor <= effective_end: - if occurs_on(rule, cursor): - results.append(cursor.isoformat()) - cursor += datetime.timedelta(days=1) - return results +from .core.recurrence import * # noqa: F401,F403 diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index b3c3223..6ed278a 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -1,427 +1,11 @@ -"""Declarative schema DDL for the NestQuest SQLite database. +"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package. -Every v1 table is declared here as plain SQL statements so later schema -tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without -touching this module's tables. All statements use -``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned -migration runner is a separate later concern. - -Timestamp policy: all timestamps are UTC ISO-8601 strings, set by the -caller/DAO rather than a DB default, per the feature guardrails. No -``length(created_at) = 25`` CHECK is added: the exact string shape -produced by callers is not fixed yet (``datetime.isoformat()`` may -include microseconds, changing the length), so enforcing a fixed length -here would reject valid values. Only non-null is enforced for now. - -Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD, -no time component) compared lexically by the ``end_date >= start_date`` -CHECK; due_date/due_time string shapes are caller-validated by the rule -engine and DAO layers, not by DDL. - -``weekday_set`` format: comma-separated weekday integers (CSV), 0 = -Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON -because it sorts, compares and parses trivially in SQL. The format is -enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB -patterns for element counts 1..7 of single digits 0-6 (GLOB has no -repetition quantifier, so the element count is enumerated; more than 7 -elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and -``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules. -This keeps the schema layer storage-only — the DAO layer (tasks -8c72300c+) does not need its own validator, it can rely on the CHECK. - -Type affinity: SQLite applies column affinity on insert, but CHECKs -still observe text and fractional values ('abc' stays text, '1.5' -becomes REAL), so every INTEGER column carrying rule semantics carries -a ``typeof(...) = 'integer'`` guard in its CHECK. - -Rule-type coherence: two CHECKs enforce that ``weekly`` carries a -non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an -``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom`` -carry none of the month-specific fields. Relaxed deliberately: the DDL -does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks" -rule or a monthly rule could later be pinned to a month) nor extra -fields on weekly/monthly/yearly rules generally, so the CHECKs only -reject what the rule engine definitively cannot express now. - -Presence pattern format: ``presence_schedules.pattern`` encodes the -whole N-week repeating cycle in one TEXT column as pipe-separated -segments, one per week of the cycle: segment ``i`` is a ``weekday_set`` -CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``) -for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in -week 1. An empty segment means the child is absent every day of that -week; a wholly empty pattern with ``cycle_length_weeks = 1`` means -absent every day of every week, which is distinct from having no -schedule row at all (a child with no schedule is present every day, per -the feature guardrails). Chosen over JSON or per-week rows to stay -consistent with the CSV ``weekday_set`` convention and fully checkable -by plain CHECKs. DB-level validation is pure CHECK, no triggers or -custom functions: char-class GLOBs reject every character outside -digits 0-6, comma and pipe; adjacency GLOBs reject empty elements -(double comma, comma next to a pipe, leading/trailing comma) and -two-digit runs; a further GLOB rejects contiguous runs of 8+ comma -separated digits, which caps a segment at 7 elements — the GLOB cannot -match across a ``|``, so cross-segment boundaries stay safe; and the -segment count is pinned to ``cycle_length_weeks`` by -``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per -weekday_set this is not full CSV-set normalisation (duplicates like -``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``); -the engine treats the segment as a set. A -one-row-per-``week_index`` normalization was considered and rejected: -the flat single-column encoding keeps the done-condition's table shape -and proved fully checkable with plain CHECKs. - -``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the -common case, and a small cap keeps anchor arithmetic and the encoded -pattern human-readable. Revisit the cap — and re-run the pattern -validation matrix in tests — if a longer cycle is ever needed. -``anchor_date`` is an ISO date string pinning the cycle: the week index -of a date is (days since anchor) // 7 mod ``cycle_length_weeks``, -computed by the presence engine (Feature 05) from anchor-date -arithmetic, never ISO week parity. Like other date columns it carries -no shape CHECK, matching the established date policy. - -Presence overrides: ``presence_overrides.end_date`` is NOT NULL, -unlike ``schedule_rules.end_date`` — an override is always a concrete -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 windows (D-008): ``quest_definition_windows`` declares which day -windows a definition spans — one row per (definition, window), the -composite primary key making a window idempotent per definition. -``window`` is CHECK-constrained to the three spellings ``const`` owns; -``due_time`` optionally pins a due time inside the window and is -caller-validated (strict HH:MM) by the DAO layer, matching the date -policy above. The window clock ranges live in ``const.py``, not here: -this layer stores what the caller declares and never interprets clocks. - -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 -append-only log stays the single source of truth. ``definition_id``, -``child_id``, ``due_date`` and ``window`` snapshot the -materialization-time facts ("why this instance exists"): assignment -edits and rule edits change future instances only and never rewrite -these rows. ``window`` records which declared day window produced the -instance (D-008), CHECK-constrained to the same three spellings -``const`` owns. ``due_time`` snapshots the window's optional due time -at generation for the same reason — the instance keeps its -generation-time value even if the definition is edited later. -``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module -timestamp policy (no DB default, so a generation batch stamps one -coherent time). - -Uniqueness: the (definition_id, child_id, due_date, window) tuple 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_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 tuple: the twice-daily case (one definition, two -windows, one child, one date) yields two rows and the shared case (one -definition, three assignees, one window) yields three. No secondary -indexes beyond it are declared yet: history and board queries (Features -08/10/13) should justify their indexes as a later migration once their -query shapes exist. - -No ``ON DELETE`` clauses are declared on any foreign key in this -module: children and definitions are deactivated, never deleted, so a -deletion that would orphan instances or events is a bug and must fail -loudly rather than cascade. - -Completion events: ``completion_events`` is strictly append-only. That -property is enforced at the DAO layer — the completion-events DAO -exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT -by database triggers, matching the module's no-trigger policy: a RAISE -trigger would be the one exception to "pure CHECK, no triggers", -complicate the later migration runner, and still prove nothing (any -writer can run raw SQL). ``event_type`` is constrained to -``completed``/``uncompleted``; a ``missed`` row is intentionally not -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 -``quest_instances``): history reporting (Feature 13) filters and rates -per child directly against the log, and the log should stay -self-describing without joins. - -Actor policy: ``actor_source`` distinguishes an authenticated HA user -(``'user'``, with ``actor_user_id`` holding the HA user id) from a -panel tap with no user context (``'panel'``, with ``actor_user_id`` -NULL — the tapped child profile is ``actor_child_id``, D-008). Two -coherence CHECKs make both shapes non-negotiable: ``'user'`` requires -``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires -``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``. -Other actor sources are rejected; if an unattended system actor is -ever needed, that is a schema migration, not a silent widening. - -On-time policy: ``was_on_time`` is required (integer 0 or 1) for -``completed`` events — at completion time the due date is known and the -flag is decidable. For ``uncompleted`` events the flag is optional: -Feature 08 decides whether a reversal carries the reversed completion's -on-time flag or NULL, and the DDL deliberately accepts both rather than -guessing. +The declarative schema DDL lives in +:mod:`custom_components.nestquest.core.schema` (extracted in +task d0b691d8); this module re-exports it so existing +``from .schema import ...`` references inside the integration keep +working. """ from __future__ import annotations -from .db import NestQuestDatabase - -#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per -#: possible element count 1..7 (a weekday set cannot exceed 7 elements). -#: Each element is a single digit 0-6, separated by a literal comma. -#: GLOB has no repetition quantifier, so the element count is enumerated. -#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for -#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid -#: because weekly rules need at least one weekday. -WEEKDAY_SET_GLOB_PATTERNS: list[str] = [ - "[0-6]", - "[0-6],[0-6]", - "[0-6],[0-6],[0-6]", - "[0-6],[0-6],[0-6],[0-6]", - "[0-6],[0-6],[0-6],[0-6],[0-6]", - "[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]", - "[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]", -] - -SCHEMA_V1_CHILDREN_DDL: list[str] = [ - """ - CREATE TABLE IF NOT EXISTS children ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - display_name TEXT NOT NULL, - colour TEXT, - avatar_ref TEXT, - sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'), - 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_ADMIN_USERS_DDL: list[str] = [ - """ - CREATE TABLE IF NOT EXISTS admin_users ( - ha_user_id TEXT PRIMARY KEY NOT NULL, - added_at TEXT NOT NULL - ) - """, -] - -_WEEKDAY_SET_CHECK = " OR ".join( - f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS -) - -SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [ - f""" - CREATE TABLE IF NOT EXISTS schedule_rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')), - interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1), - weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})), - day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)), - nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))), - month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)), - start_date TEXT NOT NULL, - end_date TEXT, - CHECK (end_date IS NULL OR end_date >= start_date), - CHECK ( - (rule_type = 'weekly' AND weekday_set IS NOT NULL) - OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL)) - OR (rule_type = 'yearly' AND month IS NOT NULL) - OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL) - ) - ) - """, -] - -#: 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_definition_assignees ( - definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), - child_id INTEGER NOT NULL REFERENCES children(id), - PRIMARY KEY (definition_id, child_id) - ) - """, -] - -SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [ - """ - CREATE TABLE IF NOT EXISTS quest_definition_windows ( - definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), - window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')), - due_time TEXT, - PRIMARY KEY (definition_id, window) - ) - """, -] - -SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [ - f""" - CREATE TABLE IF NOT EXISTS presence_schedules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - child_id INTEGER NOT NULL UNIQUE REFERENCES children(id), - cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4), - anchor_date TEXT NOT NULL, - pattern TEXT NOT NULL CHECK ( - NOT pattern GLOB '*[^0-6,|]*' - AND NOT pattern GLOB '*[0-6][0-6]*' - AND NOT pattern GLOB '*,,*' - AND NOT pattern GLOB '*,|*' - AND NOT pattern GLOB '*|,*' - AND NOT pattern GLOB ',*' - AND NOT pattern GLOB '*,' - AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*' - AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks - ) - ) - """, -] - -SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [ - """ - CREATE TABLE IF NOT EXISTS presence_overrides ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - child_id INTEGER NOT NULL REFERENCES children(id), - start_date TEXT NOT NULL, - end_date TEXT NOT NULL, - is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)), - note TEXT, - CHECK (end_date >= start_date) - ) - """, -] - -#: Column body of ``quest_instances``, shared between the v1 DDL -#: below and the instance-key rebuild migration, which must recreate -#: the table in exactly this shape (D-008: the instance key is -#: (definition_id, child_id, due_date, window) — one instance per -#: assigned child per declared window per firing date). -QUEST_INSTANCES_TABLE_SQL = """( - id INTEGER PRIMARY KEY AUTOINCREMENT, - definition_id INTEGER NOT NULL REFERENCES quest_definitions(id), - child_id INTEGER NOT NULL REFERENCES children(id), - window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')), - due_date TEXT NOT NULL, - due_time TEXT, - generated_at TEXT NOT NULL, - UNIQUE (definition_id, child_id, due_date, window) -)""" - -SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [ - f""" - CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL} - """, -] - -#: Column body of ``completion_events``, shared between the v1 DDL -#: below and the actor-child rebuild migration, which must recreate the -#: table in exactly this shape (D-008: ``actor_child_id`` records which -#: panel profile was tapped — set for panel events, NULL for admin -#: ones; legacy panel rows backfill to ``child_id``, the child they -#: always were). -COMPLETION_EVENTS_TABLE_SQL = """( - id INTEGER PRIMARY KEY AUTOINCREMENT, - 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')), - actor_user_id TEXT, - actor_child_id INTEGER REFERENCES children(id), - occurred_at TEXT NOT NULL, - was_on_time INTEGER CHECK ( - was_on_time IS NULL - OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1)) - ), - CHECK ( - (actor_source = 'user' AND actor_user_id IS NOT NULL) - OR (actor_source = 'panel' AND actor_user_id IS NULL) - ), - CHECK ( - (actor_source = 'user' AND actor_child_id IS NULL) - OR (actor_source = 'panel' AND actor_child_id IS NOT NULL) - ), - CHECK ( - (event_type = 'completed' AND was_on_time IS NOT NULL) - OR (event_type = 'uncompleted') - ) -)""" - -SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [ - f""" - CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL} - """, -] - -#: Generic integration-owned key/value state (schema version 7, -#: Feature 11): persistent bookkeeping that belongs to the integration -#: rather than any domain table — currently the missed-sweep -#: watermark. ``value`` is a plain string; callers interpret it. -META_STATE_TABLE_SQL = """( - key TEXT NOT NULL PRIMARY KEY, - value TEXT NOT NULL -)""" - -SCHEMA_V7_META_STATE_DDL: list[str] = [ - f""" - CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL} - """, -] - -SCHEMA_V1_STATEMENTS: list[str] = [ - *SCHEMA_V1_CHILDREN_DDL, - *SCHEMA_V1_ADMIN_USERS_DDL, - *SCHEMA_V1_SCHEDULE_RULES_DDL, - *SCHEMA_V1_QUEST_DEFINITIONS_DDL, - *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, - *SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, - *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, - *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, - *SCHEMA_V1_QUEST_INSTANCES_DDL, - *SCHEMA_V1_COMPLETION_EVENTS_DDL, - *SCHEMA_V7_META_STATE_DDL, -] - - -async def async_apply_ddl( - database: NestQuestDatabase, statements: list[str] -) -> None: - """Apply each DDL statement inside one transaction on ``database``. - - Idempotent: every statement is ``CREATE TABLE IF NOT EXISTS``, so - applying the same list twice leaves the schema and its rows intact. - """ - async with database.transaction(): - for sql in statements: - await database.execute(sql) \ No newline at end of file +from .core.schema import * # noqa: F401,F403 diff --git a/custom_components/nestquest/store.py b/custom_components/nestquest/store.py index 8198d79..e87a7d7 100644 --- a/custom_components/nestquest/store.py +++ b/custom_components/nestquest/store.py @@ -1,20 +1,32 @@ -"""SQLite storage path resolution for the NestQuest integration.""" +"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package. + +The path resolver lives in +:mod:`custom_components.nestquest.core.store` (extracted in task +d0b691d8) and is Home-Assistant-free: +:func:`custom_components.nestquest.core.store.async_get_db_path` +takes the full database path. This module re-exports it and provides +the original ``async_get_db_path(hass)`` signature so the integration +resolves the path from ``hass.config.path`` exactly as before. +""" from __future__ import annotations from pathlib import Path from homeassistant.core import HomeAssistant -from .const import SQLITE_DB_FILENAME +from .core.const import SQLITE_DB_FILENAME +from .core.store import async_get_db_path as _core_async_get_db_path +from .core.store import _ensure_dir - -def _ensure_dir(path: Path) -> None: - """Create the given directory and any missing parents.""" - path.mkdir(parents=True, exist_ok=True) +__all__ = ["async_get_db_path", "_ensure_dir"] async def async_get_db_path(hass: HomeAssistant) -> Path: - """Return the absolute path to the NestQuest SQLite database file.""" - db_path = Path(hass.config.path(SQLITE_DB_FILENAME)).absolute() - await hass.async_add_executor_job(_ensure_dir, db_path.parent) - return db_path \ No newline at end of file + """Return the absolute path to the NestQuest SQLite database file. + + Resolves the database file from ``hass.config.path`` and delegates to + :func:`custom_components.nestquest.core.store.async_get_db_path`, + which ensures the parent directory exists off the event loop via + :func:`asyncio.to_thread`. + """ + return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME)) diff --git a/tests/conftest.py b/tests/conftest.py index 6d6c9c4..8fdf59f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -829,6 +829,24 @@ def make_hass() -> tuple: return hass, registry +def executor_for(hass): + """Return a dynamic executor callable backed by ``hass.async_add_executor_job``. + + The wrapper resolves ``hass.async_add_executor_job`` on every call, so a + test that swaps it after constructing the database (to gate or fail + specific jobs) is observed without rebuilding the wrapper — mirroring + the pre-extraction db.py, which read the attribute dynamically off + ``hass``. Use this for cancellation/gating tests that reassign + ``hass.async_add_executor_job``; plain call sites can pass + ``hass.async_add_executor_job`` directly. + """ + + async def _executor(fn, *args, **kwargs): + return await hass.async_add_executor_job(fn, *args, **kwargs) + + return _executor + + @pytest.fixture async def hass(): """Provide a standard-shaped hass fixture bound to the running test loop. diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py index 5640d2d..1ca1224 100644 --- a/tests/test_admin_allowlist.py +++ b/tests/test_admin_allowlist.py @@ -31,7 +31,7 @@ def _make_hass_mock(): def _with_db(tmp_path, name): def _run_test(body): async def _main(): - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(tmp_path / name) try: await apply_migrations(database) diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index 6b84a18..a3b554c 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -33,7 +33,7 @@ def _make_hass_mock(): def _with_db(tmp_path, name): def _run_test(body): async def _main(): - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(tmp_path / name) try: await apply_migrations(database) @@ -360,7 +360,7 @@ def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None: gate_open = asyncio.Event() a_started = asyncio.Event() hass = _gated_hass(armed, gate_open, a_started) - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "create-race.db") try: await apply_migrations(database) @@ -633,7 +633,7 @@ def test_edit_child_concurrent_opposite_edits_return_own_values( gate_open = asyncio.Event() a_started = asyncio.Event() hass = _gated_hass(armed, gate_open, a_started) - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "edit-race.db") try: await apply_migrations(database) @@ -685,7 +685,7 @@ def test_set_child_active_concurrent_opposite_transitions_return_own( gate_open = asyncio.Event() a_started = asyncio.Event() hass = _gated_hass(armed, gate_open, a_started) - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "active-race.db") try: await apply_migrations(database) diff --git a/tests/test_completion.py b/tests/test_completion.py index 808096f..2b3cd02 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -61,7 +61,7 @@ D1 = _future_day(1) async def _prepare(path) -> tuple: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(path) await apply_migrations(database) children = ChildrenDao(database) @@ -630,7 +630,7 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None: gate_open = asyncio.Event() a_started = asyncio.Event() hass = _gated_hass(armed, gate_open, a_started) - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "complete-race.db") try: await apply_migrations(database) @@ -1060,7 +1060,7 @@ def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None: gate_open = asyncio.Event() a_started = asyncio.Event() hass = _gated_hass(armed, gate_open, a_started) - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "uncomplete-race.db") try: await apply_migrations(database) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 246e59b..a6ecc2d 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -140,7 +140,7 @@ def test_flow_strings_match_translations( def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None: """Ensure no module other than const.py hardcodes the domain or db filename.""" pkg_dir = Path(config_flow.__file__).parent - py_files = [f for f in pkg_dir.glob("*.py") if f.name not in ("const.py",)] + py_files = [f for f in pkg_dir.rglob("*.py") if f.name not in ("const.py",)] assert len(py_files) > 0, "No python modules found in package to check" diff --git a/tests/test_const.py b/tests/test_const.py index 361ca83..4d8592a 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -106,7 +106,7 @@ def test_window_clock_ranges_cover_every_window() -> None: def test_no_hardcoded_domain_or_db_filename_in_modules() -> None: """Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME.""" pkg_dir = Path(const.__file__).parent - py_files = [f for f in pkg_dir.glob("*.py") if f.name != "const.py"] + py_files = [f for f in pkg_dir.rglob("*.py") if f.name != "const.py"] assert len(py_files) > 0, "No python modules found in package to check" diff --git a/tests/test_core_isolation.py b/tests/test_core_isolation.py new file mode 100644 index 0000000..3ff816d --- /dev/null +++ b/tests/test_core_isolation.py @@ -0,0 +1,119 @@ +"""Invariant: the bundled core package is self-contained. + +Every module under ``custom_components/nestquest/core/`` must be free of +Home Assistant and must not climb out of the core package: no +``homeassistant`` import, no relative import with level > 1, no absolute +import of ``custom_components.nestquest.``, +and no bare ``import core`` / ``from core``. This guards the +storage-layer extraction (task d0b691d8) from regressing into a +HA-coupled or integration-coupled bundle. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +CORE_PKG = Path( + __import__( + "custom_components.nestquest.core", fromlist=["__file__"] + ).__file__ +).parent + + +def _module_chain(base: str, name: str) -> str: + """Combined dotted path for ``from import ``.""" + return f"{base}.{name}" if base else name + + +def _is_homeassistant(module: str) -> bool: + return module == "homeassistant" or module.startswith("homeassistant.") + + +def _climbs_out_of_core(module: str) -> bool: + """An absolute path that reaches the integration package but not core.""" + if module == "custom_components.nestquest": + return True + if module.startswith("custom_components.nestquest."): + rest = module[len("custom_components.nestquest."):] + head = rest.split(".", 1)[0] + return head != "core" + return False + + +def _is_bare_core(module: str) -> bool: + """A bare ``core`` / ``core.*`` absolute reference (not the bundle).""" + return module == "core" or module.startswith("core.") + + +def _scan(path: Path) -> list[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + offenders: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + mod = alias.name + if _is_homeassistant(mod): + offenders.append( + f"{path.name}:{node.lineno} homeassistant import: import {mod}" + ) + if _is_bare_core(mod): + offenders.append( + f"{path.name}:{node.lineno} bare core import: import {mod}" + ) + if _climbs_out_of_core(mod): + offenders.append( + f"{path.name}:{node.lineno} climbs out of core: import {mod}" + ) + elif isinstance(node, ast.ImportFrom): + if node.level and node.level > 1: + offenders.append( + f"{path.name}:{node.lineno} relative import climbs out " + f"of core (level {node.level})" + ) + continue + if node.level: + # relative level 1 stays inside core; nothing to check. + continue + base = node.module or "" + if _is_homeassistant(base): + offenders.append( + f"{path.name}:{node.lineno} homeassistant import: from {base}" + ) + if _is_bare_core(base): + offenders.append( + f"{path.name}:{node.lineno} bare core import: from {base}" + ) + if _climbs_out_of_core(base): + offenders.append( + f"{path.name}:{node.lineno} climbs out of core: from {base}" + ) + for alias in node.names: + combined = _module_chain(base, alias.name) + if _is_homeassistant(combined): + offenders.append( + f"{path.name}:{node.lineno} homeassistant import: " + f"from {base} import {alias.name}" + ) + if _is_bare_core(combined): + offenders.append( + f"{path.name}:{node.lineno} bare core import: " + f"from {base} import {alias.name}" + ) + if _climbs_out_of_core(combined): + offenders.append( + f"{path.name}:{node.lineno} climbs out of core: " + f"from {base} import {alias.name}" + ) + return offenders + + +def test_core_package_is_self_contained() -> None: + """No core module imports homeassistant or climbs out of the core package.""" + py_files = sorted(CORE_PKG.rglob("*.py")) + assert py_files, "core package not found under custom_components/nestquest/core" + all_offenders: list[str] = [] + for py_file in py_files: + all_offenders.extend(_scan(py_file)) + assert all_offenders == [], ( + "core package isolation violated:\n" + "\n".join(all_offenders) + ) diff --git a/tests/test_dao_children.py b/tests/test_dao_children.py index 893b855..0426169 100644 --- a/tests/test_dao_children.py +++ b/tests/test_dao_children.py @@ -30,7 +30,7 @@ def _make_hass_mock(): def _open_db(path) -> NestQuestDatabase: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) _run(database.open(path)) _run(apply_migrations(database)) return database @@ -319,7 +319,7 @@ def test_reorder_concurrent_calls_serialize(tmp_path) -> None: RuntimeError. """ async def _main() -> None: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(tmp_path / "reorder-concurrent.db") try: await apply_migrations(database) @@ -377,7 +377,7 @@ def test_admin_add_concurrent_calls_are_idempotent(tmp_path) -> None: 'see no row' and one of them raise IntegrityError. """ async def _main() -> None: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(tmp_path / "admin-race.db") try: await apply_migrations(database) @@ -487,17 +487,17 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None: scan_roots = [package, repo_root / "tests"] allowed = { - "custom_components/nestquest/dao_children.py", # the DAO itself - "custom_components/nestquest/dao_rules.py", # validates child + "custom_components/nestquest/core/dao_children.py", # the DAO itself + "custom_components/nestquest/core/dao_rules.py", # validates child # activeness on assignment (FK to children, done-condition # 'validates that the assigned child is active') - "custom_components/nestquest/dao_presence.py", # validates the + "custom_components/nestquest/core/dao_presence.py", # validates the # schedule/override child exists before writing (same FK shape # as dao_rules: one existence SELECT per write) - "custom_components/nestquest/dao_instances.py", # validates the + "custom_components/nestquest/core/dao_instances.py", # validates the # instance/event child exists before writing (same FK shape) - "custom_components/nestquest/schema.py", # declares the DDL - "custom_components/nestquest/migrations.py", # applies the DDL + "custom_components/nestquest/core/schema.py", # declares the DDL + "custom_components/nestquest/core/migrations.py", # applies the DDL "tests/test_schema.py", # tests the DDL "tests/test_migrations.py", # tests migration application "tests/test_dao_children.py", # this file, scanned separately diff --git a/tests/test_dao_instances.py b/tests/test_dao_instances.py index 5894b38..1aef2bf 100644 --- a/tests/test_dao_instances.py +++ b/tests/test_dao_instances.py @@ -71,7 +71,7 @@ D20 = _future_day(20) async def _prepare(path) -> tuple: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(path) await apply_migrations(database) children = ChildrenDao(database) @@ -352,7 +352,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date( monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr( - "custom_components.nestquest.dao_instances.datetime", + "custom_components.nestquest.core.dao_instances.datetime", _ShiftingModule(), ) try: @@ -362,7 +362,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date( # the lock, the IN-LOCK re-check sees tomorrow and must # reject the insert — the stale call-date pre-check alone # would have let it through. - import custom_components.nestquest.dao_instances as mod + import custom_components.nestquest.core.dao_instances as mod async def _lock_holder(): # Hold the CONNECTION lock across a transaction until @@ -402,7 +402,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date( return None async def _main(): - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(tmp_path / "upsert-rollover.db") try: await apply_migrations(database) @@ -1030,8 +1030,8 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None: # INSERT statements are stripped before scanning so only genuine # UPDATE/DELETE/non-DAO-INSERT paths hit the pattern. insert_exempt = { - "custom_components/nestquest/dao_instances.py", - "custom_components/nestquest/migrations.py", # the rebuild + "custom_components/nestquest/core/dao_instances.py", + "custom_components/nestquest/core/migrations.py", # the rebuild # migrations re-create the table via INSERT..SELECT (append- # order preserved); UPDATE/DELETE stays forbidden everywhere "tests/test_schema.py", @@ -1141,7 +1141,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability( def test_append_concurrent_events_serialize(tmp_path) -> None: """Concurrent appends for the same instance all land, in order.""" async def _main(): - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(tmp_path / "append-race.db") try: await apply_migrations(database) diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index a2ab3ba..945471c 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -118,7 +118,7 @@ class World: async def _world(path) -> World: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(path) await apply_migrations(database) world = World(database) @@ -441,7 +441,7 @@ def test_completion_events_absolutely_no_mutation_path() -> None: ) offenders = [ py.name - for py in sorted(package.glob("*.py")) + for py in sorted(package.rglob("*.py")) if py.name != "__pycache__" and mutation.search(py.read_text()) ] assert offenders == [] \ No newline at end of file diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index b1ee155..1b96116 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -33,7 +33,7 @@ NOW = "2026-09-14T12:00:00+00:00" async def _prepare(path) -> tuple: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(path) await apply_migrations(database) schedules = PresenceSchedulesDao(database) @@ -196,7 +196,7 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None: return fn(*args) hass.async_add_executor_job = _gated_executor - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "upsert-race.db") try: await apply_migrations(database) @@ -553,9 +553,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None: scan_roots = [package, repo_root / "tests"] allowed = { - "custom_components/nestquest/dao_presence.py", # this DAO - "custom_components/nestquest/schema.py", # declares the DDL - "custom_components/nestquest/migrations.py", # applies the DDL + "custom_components/nestquest/core/dao_presence.py", # this DAO + "custom_components/nestquest/core/schema.py", # declares the DDL + "custom_components/nestquest/core/migrations.py", # applies the DDL "tests/test_schema.py", # tests the DDL "tests/test_migrations.py", # tests migration application "tests/test_dao_presence.py", # this file, scanned separately @@ -766,7 +766,7 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None: return fn(*args) hass.async_add_executor_job = _gated_executor - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "override-race.db") try: await apply_migrations(database) diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index 34565b3..ad8bb7a 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -42,7 +42,7 @@ NOW = "2026-09-14T12:00:00+00:00" async def _prepare(path) -> tuple: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(path) await apply_migrations(database) rules = ScheduleRulesDao(database) @@ -325,7 +325,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( create's validation pauses mid-flight before the delete starts. """ async def _main(order: str): - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(tmp_path / f"rule-delete-race-{order}.db") try: await apply_migrations(database) @@ -1218,12 +1218,12 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None: scan_roots = [package, repo_root / "tests"] allowed = { - "custom_components/nestquest/dao_rules.py", # this DAO - "custom_components/nestquest/dao_instances.py", # validates the + "custom_components/nestquest/core/dao_rules.py", # this DAO + "custom_components/nestquest/core/dao_instances.py", # validates the # definition exists + fetches its assignee before generating an # instance (the instance must snapshot the definition's child) - "custom_components/nestquest/schema.py", # declares the DDL - "custom_components/nestquest/migrations.py", # applies the DDL + "custom_components/nestquest/core/schema.py", # declares the DDL + "custom_components/nestquest/core/migrations.py", # applies the DDL "tests/test_schema.py", # tests the DDL "tests/test_migrations.py", # tests migration application "tests/test_dao_rules.py", # this file, scanned separately diff --git a/tests/test_db.py b/tests/test_db.py index 7640c57..c31ceb4 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -11,9 +11,9 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from conftest import make_hass +from conftest import executor_for, make_hass -from custom_components.nestquest import db as db_module +import custom_components.nestquest.core.db as db_module from custom_components.nestquest.db import NestQuestDatabase @@ -29,7 +29,7 @@ def _make_hass_mock(): def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]: hass = _make_hass_mock() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) _run(database.open(path)) return database, hass @@ -55,7 +55,7 @@ def test_open_enables_foreign_key_enforcement(tmp_path) -> None: def test_open_delegates_connection_to_executor(tmp_path) -> None: hass = _make_hass_mock() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) _run(database.open(tmp_path / "exec.db")) first_call = hass.async_add_executor_job.call_args_list[0] assert callable(first_call.args[0]) @@ -65,7 +65,7 @@ def test_open_delegates_connection_to_executor(tmp_path) -> None: def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None: path = tmp_path / "once.db" hass = _make_hass_mock() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) first = _run(database.open(path)) second = _run(database.open(path)) assert first is database @@ -111,7 +111,7 @@ def test_every_method_routes_sqlite3_through_executor(tmp_path) -> None: async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None: """The executor surface is awaited per wrapper call on a live loop.""" hass = _make_hass_mock() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "loop.db") await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)") @@ -129,7 +129,7 @@ async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None: async def test_public_methods_return_only_plain_data(tmp_path) -> None: """execute/execute_many return plain dataclasses, never sqlite3 objects.""" hass = _make_hass_mock() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "plain.db") result = await database.execute( "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)" @@ -156,7 +156,7 @@ async def test_public_methods_return_only_plain_data(tmp_path) -> None: async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None: """With the conftest hass (real run_in_executor), queries still succeed.""" hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "real.db") await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)") assert await database.fetch_one("SELECT 1") == (1,) @@ -210,7 +210,7 @@ async def test_real_executor_never_touches_loop_thread(tmp_path, monkeypatch) -> monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect) hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "threads.db") await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)") await database.execute("INSERT INTO t (id) VALUES (?)", (1,)) @@ -250,7 +250,9 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None: """Every sqlite3.* call in db.py sits inside an executor-delegating function. A function is executor-delegating when it (or, for nested closures, any of - its enclosing functions) passes work to ``hass.async_add_executor_job``. + its enclosing functions) passes work to the wrapper's ``self._executor`` + callable (the Home-Assistant-free contract that mirrors + ``hass.async_add_executor_job``). """ source = Path(db_module.__file__).read_text(encoding="utf-8") tree = ast.parse(source, filename=str(db_module.__file__)) @@ -262,7 +264,7 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None: and any( isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) - and sub.func.attr == "async_add_executor_job" + and sub.func.attr == "_executor" for sub in ast.walk(node) ) } @@ -375,7 +377,7 @@ async def test_commit_failure_rolls_back_and_next_transaction_works( must run cleanly. """ hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "deferred.db") await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)") await database.execute( @@ -416,7 +418,7 @@ async def test_cancel_during_open_closes_abandoned_connection(tmp_path) -> None: propagates, and a follow-up open() must succeed cleanly. """ hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(executor_for(hass)) open_started = asyncio.Event() release_open = asyncio.Event() @@ -481,7 +483,7 @@ async def test_cancel_during_begin_settles_then_rolls_back(tmp_path) -> None: clear its state, and stay usable for the next transaction. """ hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(executor_for(hass)) await database.open(tmp_path / "cancel-begin.db") await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)") await database.execute("INSERT INTO t (name) VALUES (?)", ("before",)) @@ -543,7 +545,7 @@ async def test_cancel_during_commit_settles_then_propagates_cancellation( transaction, and the wrapper must stay usable. """ hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(executor_for(hass)) await database.open(tmp_path / "cancel-commit.db") await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)") @@ -603,7 +605,7 @@ async def test_cancel_during_in_transaction_execute_settles_then_rolls_back( not persist. """ hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(executor_for(hass)) await database.open(tmp_path / "cancel-execute.db") await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)") await database.execute("INSERT INTO t (name) VALUES (?)", ("before",)) @@ -666,7 +668,7 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None commit failure behind the rollback failure. """ hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(executor_for(hass)) await database.open(tmp_path / "both-fail.db") await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)") @@ -698,14 +700,14 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None async def test_transaction_persists_across_connections(tmp_path) -> None: """A committed transaction is visible to a brand-new connection.""" hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "persist.db") await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)") async with database.transaction(): await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",)) await database.close() - other = NestQuestDatabase(hass) + other = NestQuestDatabase(hass.async_add_executor_job) await other.open(tmp_path / "persist.db") assert await other.fetch_all("SELECT name FROM t") == [("kept",)] await other.close() @@ -726,7 +728,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None: while a transaction is open queue behind it on the wrapper lock. """ hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "serial.db") await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)") tx_rounds, writers, writer_rounds = 6, 5, 8 @@ -766,7 +768,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None: async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None: """A second transaction caller is rejected with a clear error.""" hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "reject.db") await database.execute("CREATE TABLE t (id INTEGER)") @@ -791,7 +793,7 @@ async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> Non async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None: """A statement issued during an open transaction queues behind it.""" hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "block.db") await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)") @@ -830,7 +832,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error( ) -> None: """A second concurrent transaction() gets a clear RuntimeError.""" hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "concurrent-tx.db") await database.execute("CREATE TABLE t (id INTEGER)") @@ -853,7 +855,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error( async def test_close_waits_for_in_flight_transaction(tmp_path) -> None: """close() serializes: an open transaction finishes before shutdown.""" hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "closerace.db") await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)") @@ -875,7 +877,7 @@ async def test_close_waits_for_in_flight_transaction(tmp_path) -> None: await asyncio.gather(txn_task, close_task) assert database.connected is False - witness = NestQuestDatabase(hass) + witness = NestQuestDatabase(hass.async_add_executor_job) await witness.open(tmp_path / "closerace.db") assert await witness.fetch_all("SELECT name FROM t") == [("kept",)] await witness.close() @@ -886,7 +888,7 @@ async def test_close_during_transaction_from_same_task_is_rejected( ) -> None: """close() inside its own transaction body raises instead of deadlocking.""" hass, _registry = make_hass() - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "selfclose.db") await database.execute("CREATE TABLE t (id INTEGER)") with pytest.raises(RuntimeError, match="transaction"): diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index b0db61e..179a4b9 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -472,7 +472,7 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None: # Seed the database BEFORE setup: a restart reopens an existing file that # already holds definitions. The startup backfill must pick them up. db_path = await async_get_db_path(hass) - pre = NestQuestDatabase(hass) + pre = NestQuestDatabase(hass.async_add_executor_job) await pre.open(db_path) await apply_migrations(pre) child_id = await _seed_daily_child_and_definition(pre, _local_today(hass)) @@ -499,7 +499,7 @@ async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None from custom_components.nestquest.store import async_get_db_path db_path = await async_get_db_path(hass) - pre = NestQuestDatabase(hass) + pre = NestQuestDatabase(hass.async_add_executor_job) await pre.open(db_path) await apply_migrations(pre) child_id = await _seed_daily_child_and_definition(pre, _local_today(hass)) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 3b1ea2f..0c60076 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -51,7 +51,7 @@ NOW = "2026-09-14T12:00:00+00:00" async def _prepare(path): - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(path) await apply_migrations(database) return database @@ -1039,7 +1039,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight( clock["reads"] += 1 return day0 if clock["reads"] == 1 else day1 - import custom_components.nestquest.dao_instances as dao_instances_module + import custom_components.nestquest.core.dao_instances as dao_instances_module monkeypatch.setattr(dao_instances_module, "_today", _fake_today) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 89195e5..b49d14a 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -37,7 +37,7 @@ def _make_hass_mock(): def _open_db(path) -> NestQuestDatabase: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) _run(database.open(path)) return database @@ -707,7 +707,7 @@ def test_noop_run_logs_no_migration(caplog, tmp_path) -> None: try: _migrate(database) caplog.clear() - with caplog.at_level(logging.INFO, logger="custom_components.nestquest.migrations"): + with caplog.at_level(logging.INFO, logger="custom_components.nestquest.core.migrations"): _migrate(database) migrated = [ r for r in caplog.records if "migrated" in r.getMessage().lower() @@ -722,7 +722,7 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None: try: _migrate(database) caplog.clear() - with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"): + with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.core.migrations"): _migrate(database) assert any( f"already at version {len(MIGRATIONS)}" in r.getMessage() @@ -983,7 +983,7 @@ def test_concurrent_runners_serialize_and_apply_once(tmp_path) -> None: results: dict[str, int] = {} async def _main(tmp_name: str) -> None: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(tmp_name) try: # Start A, yield so it acquires the lock and enters its diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index f0c8385..1c8bf88 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -113,7 +113,7 @@ def _open_allowlist_db(tmp_path, name): hass = MagicMock() hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) _run(database.open(tmp_path / name)) _run(apply_migrations(database)) return database diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index a755670..d1d01da 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -50,7 +50,7 @@ NOW = "2026-09-14T12:00:00+00:00" async def _prepare(path) -> tuple: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(path) await apply_migrations(database) children = ChildrenDao(database) @@ -1396,7 +1396,7 @@ def test_set_quest_definition_active_concurrent_opposite_transitions_return_own( gate_open = asyncio.Event() a_started = asyncio.Event() hass = _gated_hass(armed, gate_open, a_started) - database = NestQuestDatabase(hass) + database = NestQuestDatabase(hass.async_add_executor_job) await database.open(tmp_path / "active-race.db") try: await apply_migrations(database) diff --git a/tests/test_recurrence_model.py b/tests/test_recurrence_model.py index 8e12098..095ac03 100644 --- a/tests/test_recurrence_model.py +++ b/tests/test_recurrence_model.py @@ -276,7 +276,7 @@ def test_recurrence_module_imports_only_stdlib() -> None: from pathlib import Path source = Path( - __import__("custom_components.nestquest.recurrence", + __import__("custom_components.nestquest.core.recurrence", fromlist=["__file__"]).__file__ ).read_text() offenders = _scan_forbidden_imports(source) diff --git a/tests/test_recurrence_monthly_day.py b/tests/test_recurrence_monthly_day.py index aec763c..8830523 100644 --- a/tests/test_recurrence_monthly_day.py +++ b/tests/test_recurrence_monthly_day.py @@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import ( RuleType, RuleValidationError, ScheduleRule, - _month_end, occurs_on, occurrences_between, ) +from custom_components.nestquest.core.recurrence import _month_end def _d(date: str) -> datetime.date: diff --git a/tests/test_recurrence_monthly_weekday.py b/tests/test_recurrence_monthly_weekday.py index 1d08be7..2e040e9 100644 --- a/tests/test_recurrence_monthly_weekday.py +++ b/tests/test_recurrence_monthly_weekday.py @@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import ( RuleType, RuleValidationError, ScheduleRule, - _nth_weekday_of_month, occurs_on, occurrences_between, ) +from custom_components.nestquest.core.recurrence import _nth_weekday_of_month def _d(date: str) -> datetime.date: diff --git a/tests/test_schema.py b/tests/test_schema.py index 50a26a2..0067134 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -37,7 +37,7 @@ def _make_hass_mock(): def _open_db(path) -> NestQuestDatabase: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) _run(database.open(path)) return database diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py index da77d61..d0f1ea8 100644 --- a/tests/test_startup_db.py +++ b/tests/test_startup_db.py @@ -36,7 +36,7 @@ def _make_hass_mock(): def _open_db(path) -> NestQuestDatabase: - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) _run(database.open(path)) return database @@ -44,7 +44,7 @@ def _open_db(path) -> NestQuestDatabase: async def _avalid_database(path) -> None: """Create a valid, migrated NestQuest database file (async, on the running loop, for use inside pytest-asyncio tests).""" - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(path) try: await apply_migrations(database) diff --git a/tests/test_store.py b/tests/test_store.py index 1934508..ae773c7 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -23,7 +23,11 @@ def _make_hass(config_dir: Path, execute: bool = True): return hass -def test_returns_absolute_path_from_config_path() -> None: +def test_returns_absolute_path_from_config_path(monkeypatch) -> None: + # Path resolution only: the parent-dir mkdir is a side effect of + # core.store and is exercised elsewhere; suppress it here so the + # absolute-path assertion can run against /config (non-writable). + monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None) hass = _make_hass(Path("/config"), execute=False) db_path = _run(store.async_get_db_path(hass)) hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME) @@ -75,18 +79,36 @@ def test_existing_directory_does_not_fail(tmp_path) -> None: assert not db_path.exists() -def test_mkdir_runs_via_executor(tmp_path) -> None: +def test_mkdir_runs_off_event_loop(tmp_path, monkeypatch) -> None: + """The parent-directory mkdir runs off the event loop via asyncio.to_thread. + + core.store is Home-Assistant-free, so it schedules the blocking + ``mkdir`` through :func:`asyncio.to_thread` rather than + ``hass.async_add_executor_job``; the integration shim delegates to it + unchanged. The intent is preserved: the mkdir still runs off the + event loop, with ``_ensure_dir`` on the parent directory, and the + directory exists afterwards. + """ nested = tmp_path / "deep" / "nested" hass = _make_hass(nested) + to_thread_calls: list = [] + real_to_thread = asyncio.to_thread + + def _trace_to_thread(fn, *args, **kwargs): + to_thread_calls.append((fn, args, kwargs)) + return real_to_thread(fn, *args, **kwargs) + + monkeypatch.setattr(asyncio, "to_thread", _trace_to_thread) db_path = _run(store.async_get_db_path(hass)) assert nested.is_dir() - hass.async_add_executor_job.assert_called_once() - args, kwargs = hass.async_add_executor_job.call_args - fn, fn_args = args[0], args[1:] + assert len(to_thread_calls) == 1, ( + f"expected one asyncio.to_thread call, got {len(to_thread_calls)}" + ) + fn, fn_args, fn_kwargs = to_thread_calls[0] assert callable(fn) assert fn is store._ensure_dir assert fn_args == (nested,) - assert kwargs == {} + assert fn_kwargs == {} assert db_path.parent == nested assert db_path.parent.is_dir() assert not db_path.exists() @@ -180,7 +202,8 @@ def test_no_file_creation_calls_in_store() -> None: assert "open(" not in source -def test_container_style_path_resolves() -> None: +def test_container_style_path_resolves(monkeypatch) -> None: + monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None) hass = _make_hass(Path("/config"), execute=False) assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME diff --git a/tests/test_timezones.py b/tests/test_timezones.py index 273b588..e775429 100644 --- a/tests/test_timezones.py +++ b/tests/test_timezones.py @@ -38,7 +38,7 @@ def _make_hass_mock(): async def _prepare(path): - database = NestQuestDatabase(_make_hass_mock()) + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(path) await apply_migrations(database) return database @@ -313,7 +313,7 @@ def test_definition_business_regeneration_uses_pinned_today_not_host( business operations must regenerate against the pinned date and keep that first local day, proving the threaded ``today`` wins. """ - import custom_components.nestquest.dao_instances as dao_instances_module + import custom_components.nestquest.core.dao_instances as dao_instances_module pinned = datetime.date(2026, 1, 14) host_today = datetime.date(2026, 1, 15)