Extract the storage layer into core/ (bundled in the integration)
Extract the storage layer into core/ (bundled in the integration)
This commit was merged in pull request #142.
This commit is contained in:
@@ -1,5 +1,19 @@
|
|||||||
# NestQuest Release Notes
|
# 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
|
## Version 0.5.0 — 2026-09-18
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .frontend import async_register_frontend
|
from .frontend import async_register_frontend
|
||||||
from .coordinator import NestQuestCoordinator
|
from .coordinator import NestQuestCoordinator
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase, make_database
|
||||||
from .materialize import materialize as _materialize_run
|
from .materialize import materialize as _materialize_run
|
||||||
from .migrations import apply_migrations
|
from .migrations import apply_migrations
|
||||||
from .services import async_deregister_services, async_register_services
|
from .services import async_deregister_services, async_register_services
|
||||||
@@ -174,7 +174,7 @@ async def _async_open_database(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await _preflight_existing_file(hass, db_path)
|
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:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except sqlite3.DatabaseError as err:
|
except sqlite3.DatabaseError as err:
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from .core.const import * # noqa: F401,F403
|
||||||
|
|
||||||
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"),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 children/admin DAO lives in
|
||||||
the feature guardrail: callers get typed dataclasses back and never see
|
:mod:`custom_components.nestquest.core.dao_children` (extracted in
|
||||||
raw rows or SQL. Every method is async and runs through
|
task d0b691d8); this module re-exports it so existing
|
||||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
``from .dao_children import ...`` references inside the integration keep
|
||||||
executor and the event loop never blocks.
|
working.
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from .core.dao_children import * # noqa: F401,F403
|
||||||
from dataclasses import dataclass
|
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
The instances/completion-events DAO lives in
|
||||||
guardrails: callers get typed dataclasses back and never see raw rows
|
:mod:`custom_components.nestquest.core.dao_instances`
|
||||||
or SQL. Every method is async and runs through
|
(extracted in task d0b691d8); this module re-exports it so existing
|
||||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
``from .dao_instances import ...`` references inside the integration keep
|
||||||
executor and the event loop never blocks.
|
working.
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
from .core.dao_instances import * # noqa: F401,F403
|
||||||
from dataclasses import dataclass
|
from .core.dao_instances import _resolve_today # noqa: F401
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
The meta-state DAO lives in
|
||||||
integration itself rather than any domain table — currently the
|
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
|
||||||
Feature 11 missed-sweep watermark. Values are plain strings; callers
|
d0b691d8); this module re-exports it so existing
|
||||||
interpret them. All SQL for this table lives here per the feature
|
``from .dao_meta import ...`` references inside the integration keep
|
||||||
guardrails, and every method runs through
|
working.
|
||||||
:class:`~.db.NestQuestDatabase` so nothing blocks the event loop.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .dao_children import _connection_lock
|
from .core.dao_meta import * # noqa: F401,F403
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
The presence DAOs live in
|
||||||
guardrails: callers get typed dataclasses back and never see raw rows
|
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
|
||||||
or SQL. Every method is async and runs through
|
task d0b691d8); this module re-exports them so existing
|
||||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
``from .dao_presence import ...`` references inside the integration keep
|
||||||
executor and the event loop never blocks.
|
working.
|
||||||
|
|
||||||
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from .core.dao_presence import * # noqa: F401,F403
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from typing import Any, TypeVar
|
||||||
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 homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .core.db import ExecutionResult, NestQuestDatabase
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
|
||||||
class ExecutionResult:
|
|
||||||
"""Plain-data outcome of one statement.
|
|
||||||
|
|
||||||
``rowcount`` and ``lastrowid`` mirror the corresponding
|
T = TypeVar("T")
|
||||||
:class:`sqlite3.Cursor` attributes; both are captured on the executor
|
|
||||||
thread so no sqlite3 object ever crosses back to the event loop.
|
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
|
return NestQuestDatabase(hass.async_add_executor_job)
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
The versioned migration runner lives in
|
||||||
a single row with the schema version the database file currently
|
:mod:`custom_components.nestquest.core.migrations`
|
||||||
reflects. Migrations are declared as an ordered list: entry ``n``
|
(extracted in task d0b691d8); this module re-exports it so existing
|
||||||
upgrades version ``n-1`` to version ``n``. Applying a migration means
|
``from .migrations import ...`` references inside the integration keep
|
||||||
running its DDL inside one transaction and stamping the new version in
|
working.
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from .core.migrations import * # noqa: F401,F403
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
|
||||||
frozen dataclass with no Home Assistant and no database imports. The
|
(extracted in task d0b691d8); this module re-exports it so existing
|
||||||
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
|
``from .recurrence import ...`` references inside the integration keep
|
||||||
serializes its dict form into the ``schedule_rules`` table.
|
working.
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
from .core.recurrence import * # noqa: F401,F403
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
The declarative schema DDL lives in
|
||||||
tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without
|
:mod:`custom_components.nestquest.core.schema` (extracted in
|
||||||
touching this module's tables. All statements use
|
task d0b691d8); this module re-exports it so existing
|
||||||
``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned
|
``from .schema import ...`` references inside the integration keep
|
||||||
migration runner is a separate later concern.
|
working.
|
||||||
|
|
||||||
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 __future__ import annotations
|
||||||
|
|
||||||
from .db import NestQuestDatabase
|
from .core.schema import * # noqa: F401,F403
|
||||||
|
|
||||||
#: 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)
|
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
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
|
||||||
|
|
||||||
|
__all__ = ["async_get_db_path", "_ensure_dir"]
|
||||||
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(hass: HomeAssistant) -> Path:
|
async def async_get_db_path(hass: HomeAssistant) -> Path:
|
||||||
"""Return the absolute path to the NestQuest SQLite database file."""
|
"""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)
|
Resolves the database file from ``hass.config.path`` and delegates to
|
||||||
return db_path
|
: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))
|
||||||
|
|||||||
@@ -829,6 +829,24 @@ def make_hass() -> tuple:
|
|||||||
return hass, registry
|
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
|
@pytest.fixture
|
||||||
async def hass():
|
async def hass():
|
||||||
"""Provide a standard-shaped hass fixture bound to the running test loop.
|
"""Provide a standard-shaped hass fixture bound to the running test loop.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def _make_hass_mock():
|
|||||||
def _with_db(tmp_path, name):
|
def _with_db(tmp_path, name):
|
||||||
def _run_test(body):
|
def _run_test(body):
|
||||||
async def _main():
|
async def _main():
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(tmp_path / name)
|
await database.open(tmp_path / name)
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def _make_hass_mock():
|
|||||||
def _with_db(tmp_path, name):
|
def _with_db(tmp_path, name):
|
||||||
def _run_test(body):
|
def _run_test(body):
|
||||||
async def _main():
|
async def _main():
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(tmp_path / name)
|
await database.open(tmp_path / name)
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
@@ -360,7 +360,7 @@ def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None:
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "create-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
@@ -633,7 +633,7 @@ def test_edit_child_concurrent_opposite_edits_return_own_values(
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "edit-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
@@ -685,7 +685,7 @@ def test_set_child_active_concurrent_opposite_transitions_return_own(
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "active-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ D1 = _future_day(1)
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path) -> tuple:
|
async def _prepare(path) -> tuple:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
children = ChildrenDao(database)
|
children = ChildrenDao(database)
|
||||||
@@ -630,7 +630,7 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "complete-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
@@ -1060,7 +1060,7 @@ def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "uncomplete-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ def test_flow_strings_match_translations(
|
|||||||
def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
|
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."""
|
"""Ensure no module other than const.py hardcodes the domain or db filename."""
|
||||||
pkg_dir = Path(config_flow.__file__).parent
|
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"
|
assert len(py_files) > 0, "No python modules found in package to check"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -106,7 +106,7 @@ def test_window_clock_ranges_cover_every_window() -> None:
|
|||||||
def test_no_hardcoded_domain_or_db_filename_in_modules() -> 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."""
|
"""Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME."""
|
||||||
pkg_dir = Path(const.__file__).parent
|
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"
|
assert len(py_files) > 0, "No python modules found in package to check"
|
||||||
|
|
||||||
|
|||||||
@@ -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.<something other than core>``,
|
||||||
|
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 <base> import <name>``."""
|
||||||
|
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)
|
||||||
|
)
|
||||||
@@ -30,7 +30,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
|
|
||||||
def _open_db(path) -> NestQuestDatabase:
|
def _open_db(path) -> NestQuestDatabase:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
_run(database.open(path))
|
_run(database.open(path))
|
||||||
_run(apply_migrations(database))
|
_run(apply_migrations(database))
|
||||||
return database
|
return database
|
||||||
@@ -319,7 +319,7 @@ def test_reorder_concurrent_calls_serialize(tmp_path) -> None:
|
|||||||
RuntimeError.
|
RuntimeError.
|
||||||
"""
|
"""
|
||||||
async def _main() -> None:
|
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")
|
await database.open(tmp_path / "reorder-concurrent.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
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.
|
'see no row' and one of them raise IntegrityError.
|
||||||
"""
|
"""
|
||||||
async def _main() -> None:
|
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")
|
await database.open(tmp_path / "admin-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
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"]
|
scan_roots = [package, repo_root / "tests"]
|
||||||
|
|
||||||
allowed = {
|
allowed = {
|
||||||
"custom_components/nestquest/dao_children.py", # the DAO itself
|
"custom_components/nestquest/core/dao_children.py", # the DAO itself
|
||||||
"custom_components/nestquest/dao_rules.py", # validates child
|
"custom_components/nestquest/core/dao_rules.py", # validates child
|
||||||
# activeness on assignment (FK to children, done-condition
|
# activeness on assignment (FK to children, done-condition
|
||||||
# 'validates that the assigned child is active')
|
# '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
|
# schedule/override child exists before writing (same FK shape
|
||||||
# as dao_rules: one existence SELECT per write)
|
# 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)
|
# instance/event child exists before writing (same FK shape)
|
||||||
"custom_components/nestquest/schema.py", # declares the DDL
|
"custom_components/nestquest/core/schema.py", # declares the DDL
|
||||||
"custom_components/nestquest/migrations.py", # applies the DDL
|
"custom_components/nestquest/core/migrations.py", # applies the DDL
|
||||||
"tests/test_schema.py", # tests the DDL
|
"tests/test_schema.py", # tests the DDL
|
||||||
"tests/test_migrations.py", # tests migration application
|
"tests/test_migrations.py", # tests migration application
|
||||||
"tests/test_dao_children.py", # this file, scanned separately
|
"tests/test_dao_children.py", # this file, scanned separately
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ D20 = _future_day(20)
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path) -> tuple:
|
async def _prepare(path) -> tuple:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
children = ChildrenDao(database)
|
children = ChildrenDao(database)
|
||||||
@@ -352,7 +352,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
|
|||||||
|
|
||||||
monkeypatch = pytest.MonkeyPatch()
|
monkeypatch = pytest.MonkeyPatch()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.nestquest.dao_instances.datetime",
|
"custom_components.nestquest.core.dao_instances.datetime",
|
||||||
_ShiftingModule(),
|
_ShiftingModule(),
|
||||||
)
|
)
|
||||||
try:
|
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
|
# the lock, the IN-LOCK re-check sees tomorrow and must
|
||||||
# reject the insert — the stale call-date pre-check alone
|
# reject the insert — the stale call-date pre-check alone
|
||||||
# would have let it through.
|
# 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():
|
async def _lock_holder():
|
||||||
# Hold the CONNECTION lock across a transaction until
|
# 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
|
return None
|
||||||
|
|
||||||
async def _main():
|
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")
|
await database.open(tmp_path / "upsert-rollover.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
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
|
# INSERT statements are stripped before scanning so only genuine
|
||||||
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
|
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
|
||||||
insert_exempt = {
|
insert_exempt = {
|
||||||
"custom_components/nestquest/dao_instances.py",
|
"custom_components/nestquest/core/dao_instances.py",
|
||||||
"custom_components/nestquest/migrations.py", # the rebuild
|
"custom_components/nestquest/core/migrations.py", # the rebuild
|
||||||
# migrations re-create the table via INSERT..SELECT (append-
|
# migrations re-create the table via INSERT..SELECT (append-
|
||||||
# order preserved); UPDATE/DELETE stays forbidden everywhere
|
# order preserved); UPDATE/DELETE stays forbidden everywhere
|
||||||
"tests/test_schema.py",
|
"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:
|
def test_append_concurrent_events_serialize(tmp_path) -> None:
|
||||||
"""Concurrent appends for the same instance all land, in order."""
|
"""Concurrent appends for the same instance all land, in order."""
|
||||||
async def _main():
|
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")
|
await database.open(tmp_path / "append-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class World:
|
|||||||
|
|
||||||
|
|
||||||
async def _world(path) -> 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 database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
world = World(database)
|
world = World(database)
|
||||||
@@ -441,7 +441,7 @@ def test_completion_events_absolutely_no_mutation_path() -> None:
|
|||||||
)
|
)
|
||||||
offenders = [
|
offenders = [
|
||||||
py.name
|
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())
|
if py.name != "__pycache__" and mutation.search(py.read_text())
|
||||||
]
|
]
|
||||||
assert offenders == []
|
assert offenders == []
|
||||||
@@ -33,7 +33,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path) -> tuple:
|
async def _prepare(path) -> tuple:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
schedules = PresenceSchedulesDao(database)
|
schedules = PresenceSchedulesDao(database)
|
||||||
@@ -196,7 +196,7 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
|
|||||||
return fn(*args)
|
return fn(*args)
|
||||||
|
|
||||||
hass.async_add_executor_job = _gated_executor
|
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")
|
await database.open(tmp_path / "upsert-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
@@ -553,9 +553,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
|
|||||||
scan_roots = [package, repo_root / "tests"]
|
scan_roots = [package, repo_root / "tests"]
|
||||||
|
|
||||||
allowed = {
|
allowed = {
|
||||||
"custom_components/nestquest/dao_presence.py", # this DAO
|
"custom_components/nestquest/core/dao_presence.py", # this DAO
|
||||||
"custom_components/nestquest/schema.py", # declares the DDL
|
"custom_components/nestquest/core/schema.py", # declares the DDL
|
||||||
"custom_components/nestquest/migrations.py", # applies the DDL
|
"custom_components/nestquest/core/migrations.py", # applies the DDL
|
||||||
"tests/test_schema.py", # tests the DDL
|
"tests/test_schema.py", # tests the DDL
|
||||||
"tests/test_migrations.py", # tests migration application
|
"tests/test_migrations.py", # tests migration application
|
||||||
"tests/test_dao_presence.py", # this file, scanned separately
|
"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)
|
return fn(*args)
|
||||||
|
|
||||||
hass.async_add_executor_job = _gated_executor
|
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")
|
await database.open(tmp_path / "override-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path) -> tuple:
|
async def _prepare(path) -> tuple:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
rules = ScheduleRulesDao(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.
|
create's validation pauses mid-flight before the delete starts.
|
||||||
"""
|
"""
|
||||||
async def _main(order: str):
|
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")
|
await database.open(tmp_path / f"rule-delete-race-{order}.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
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"]
|
scan_roots = [package, repo_root / "tests"]
|
||||||
|
|
||||||
allowed = {
|
allowed = {
|
||||||
"custom_components/nestquest/dao_rules.py", # this DAO
|
"custom_components/nestquest/core/dao_rules.py", # this DAO
|
||||||
"custom_components/nestquest/dao_instances.py", # validates the
|
"custom_components/nestquest/core/dao_instances.py", # validates the
|
||||||
# definition exists + fetches its assignee before generating an
|
# definition exists + fetches its assignee before generating an
|
||||||
# instance (the instance must snapshot the definition's child)
|
# instance (the instance must snapshot the definition's child)
|
||||||
"custom_components/nestquest/schema.py", # declares the DDL
|
"custom_components/nestquest/core/schema.py", # declares the DDL
|
||||||
"custom_components/nestquest/migrations.py", # applies the DDL
|
"custom_components/nestquest/core/migrations.py", # applies the DDL
|
||||||
"tests/test_schema.py", # tests the DDL
|
"tests/test_schema.py", # tests the DDL
|
||||||
"tests/test_migrations.py", # tests migration application
|
"tests/test_migrations.py", # tests migration application
|
||||||
"tests/test_dao_rules.py", # this file, scanned separately
|
"tests/test_dao_rules.py", # this file, scanned separately
|
||||||
|
|||||||
+28
-26
@@ -11,9 +11,9 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
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
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
|
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
|
||||||
hass = _make_hass_mock()
|
hass = _make_hass_mock()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
_run(database.open(path))
|
_run(database.open(path))
|
||||||
return database, hass
|
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:
|
def test_open_delegates_connection_to_executor(tmp_path) -> None:
|
||||||
hass = _make_hass_mock()
|
hass = _make_hass_mock()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
_run(database.open(tmp_path / "exec.db"))
|
_run(database.open(tmp_path / "exec.db"))
|
||||||
first_call = hass.async_add_executor_job.call_args_list[0]
|
first_call = hass.async_add_executor_job.call_args_list[0]
|
||||||
assert callable(first_call.args[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:
|
def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None:
|
||||||
path = tmp_path / "once.db"
|
path = tmp_path / "once.db"
|
||||||
hass = _make_hass_mock()
|
hass = _make_hass_mock()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
first = _run(database.open(path))
|
first = _run(database.open(path))
|
||||||
second = _run(database.open(path))
|
second = _run(database.open(path))
|
||||||
assert first is database
|
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:
|
async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
|
||||||
"""The executor surface is awaited per wrapper call on a live loop."""
|
"""The executor surface is awaited per wrapper call on a live loop."""
|
||||||
hass = _make_hass_mock()
|
hass = _make_hass_mock()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "loop.db")
|
await database.open(tmp_path / "loop.db")
|
||||||
|
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
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:
|
async def test_public_methods_return_only_plain_data(tmp_path) -> None:
|
||||||
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
|
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
|
||||||
hass = _make_hass_mock()
|
hass = _make_hass_mock()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "plain.db")
|
await database.open(tmp_path / "plain.db")
|
||||||
result = await database.execute(
|
result = await database.execute(
|
||||||
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
|
"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:
|
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."""
|
"""With the conftest hass (real run_in_executor), queries still succeed."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "real.db")
|
await database.open(tmp_path / "real.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||||
assert await database.fetch_one("SELECT 1") == (1,)
|
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)
|
monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect)
|
||||||
|
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "threads.db")
|
await database.open(tmp_path / "threads.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||||
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
|
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.
|
"""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
|
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")
|
source = Path(db_module.__file__).read_text(encoding="utf-8")
|
||||||
tree = ast.parse(source, filename=str(db_module.__file__))
|
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(
|
and any(
|
||||||
isinstance(sub, ast.Call)
|
isinstance(sub, ast.Call)
|
||||||
and isinstance(sub.func, ast.Attribute)
|
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)
|
for sub in ast.walk(node)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -375,7 +377,7 @@ async def test_commit_failure_rolls_back_and_next_transaction_works(
|
|||||||
must run cleanly.
|
must run cleanly.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "deferred.db")
|
await database.open(tmp_path / "deferred.db")
|
||||||
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
|
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
|
||||||
await database.execute(
|
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.
|
propagates, and a follow-up open() must succeed cleanly.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(executor_for(hass))
|
||||||
|
|
||||||
open_started = asyncio.Event()
|
open_started = asyncio.Event()
|
||||||
release_open = 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.
|
clear its state, and stay usable for the next transaction.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(executor_for(hass))
|
||||||
await database.open(tmp_path / "cancel-begin.db")
|
await database.open(tmp_path / "cancel-begin.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
|
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.
|
transaction, and the wrapper must stay usable.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(executor_for(hass))
|
||||||
await database.open(tmp_path / "cancel-commit.db")
|
await database.open(tmp_path / "cancel-commit.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
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.
|
not persist.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(executor_for(hass))
|
||||||
await database.open(tmp_path / "cancel-execute.db")
|
await database.open(tmp_path / "cancel-execute.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
|
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.
|
commit failure behind the rollback failure.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(executor_for(hass))
|
||||||
await database.open(tmp_path / "both-fail.db")
|
await database.open(tmp_path / "both-fail.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
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:
|
async def test_transaction_persists_across_connections(tmp_path) -> None:
|
||||||
"""A committed transaction is visible to a brand-new connection."""
|
"""A committed transaction is visible to a brand-new connection."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "persist.db")
|
await database.open(tmp_path / "persist.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
async with database.transaction():
|
async with database.transaction():
|
||||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
|
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
|
||||||
await database.close()
|
await database.close()
|
||||||
|
|
||||||
other = NestQuestDatabase(hass)
|
other = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await other.open(tmp_path / "persist.db")
|
await other.open(tmp_path / "persist.db")
|
||||||
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
|
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
|
||||||
await other.close()
|
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.
|
while a transaction is open queue behind it on the wrapper lock.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "serial.db")
|
await database.open(tmp_path / "serial.db")
|
||||||
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
|
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
|
||||||
tx_rounds, writers, writer_rounds = 6, 5, 8
|
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:
|
async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None:
|
||||||
"""A second transaction caller is rejected with a clear error."""
|
"""A second transaction caller is rejected with a clear error."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "reject.db")
|
await database.open(tmp_path / "reject.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
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:
|
async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None:
|
||||||
"""A statement issued during an open transaction queues behind it."""
|
"""A statement issued during an open transaction queues behind it."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "block.db")
|
await database.open(tmp_path / "block.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
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:
|
) -> None:
|
||||||
"""A second concurrent transaction() gets a clear RuntimeError."""
|
"""A second concurrent transaction() gets a clear RuntimeError."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "concurrent-tx.db")
|
await database.open(tmp_path / "concurrent-tx.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
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:
|
async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
|
||||||
"""close() serializes: an open transaction finishes before shutdown."""
|
"""close() serializes: an open transaction finishes before shutdown."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "closerace.db")
|
await database.open(tmp_path / "closerace.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
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)
|
await asyncio.gather(txn_task, close_task)
|
||||||
assert database.connected is False
|
assert database.connected is False
|
||||||
|
|
||||||
witness = NestQuestDatabase(hass)
|
witness = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await witness.open(tmp_path / "closerace.db")
|
await witness.open(tmp_path / "closerace.db")
|
||||||
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
|
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
|
||||||
await witness.close()
|
await witness.close()
|
||||||
@@ -886,7 +888,7 @@ async def test_close_during_transaction_from_same_task_is_rejected(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""close() inside its own transaction body raises instead of deadlocking."""
|
"""close() inside its own transaction body raises instead of deadlocking."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "selfclose.db")
|
await database.open(tmp_path / "selfclose.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||||
with pytest.raises(RuntimeError, match="transaction"):
|
with pytest.raises(RuntimeError, match="transaction"):
|
||||||
|
|||||||
@@ -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
|
# Seed the database BEFORE setup: a restart reopens an existing file that
|
||||||
# already holds definitions. The startup backfill must pick them up.
|
# already holds definitions. The startup backfill must pick them up.
|
||||||
db_path = await async_get_db_path(hass)
|
db_path = await async_get_db_path(hass)
|
||||||
pre = NestQuestDatabase(hass)
|
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await pre.open(db_path)
|
await pre.open(db_path)
|
||||||
await apply_migrations(pre)
|
await apply_migrations(pre)
|
||||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
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
|
from custom_components.nestquest.store import async_get_db_path
|
||||||
|
|
||||||
db_path = await async_get_db_path(hass)
|
db_path = await async_get_db_path(hass)
|
||||||
pre = NestQuestDatabase(hass)
|
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await pre.open(db_path)
|
await pre.open(db_path)
|
||||||
await apply_migrations(pre)
|
await apply_migrations(pre)
|
||||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path):
|
async def _prepare(path):
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
return database
|
return database
|
||||||
@@ -1039,7 +1039,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
|
|||||||
clock["reads"] += 1
|
clock["reads"] += 1
|
||||||
return day0 if clock["reads"] == 1 else day1
|
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)
|
monkeypatch.setattr(dao_instances_module, "_today", _fake_today)
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
|
|
||||||
def _open_db(path) -> NestQuestDatabase:
|
def _open_db(path) -> NestQuestDatabase:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
_run(database.open(path))
|
_run(database.open(path))
|
||||||
return database
|
return database
|
||||||
|
|
||||||
@@ -707,7 +707,7 @@ def test_noop_run_logs_no_migration(caplog, tmp_path) -> None:
|
|||||||
try:
|
try:
|
||||||
_migrate(database)
|
_migrate(database)
|
||||||
caplog.clear()
|
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)
|
_migrate(database)
|
||||||
migrated = [
|
migrated = [
|
||||||
r for r in caplog.records if "migrated" in r.getMessage().lower()
|
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:
|
try:
|
||||||
_migrate(database)
|
_migrate(database)
|
||||||
caplog.clear()
|
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)
|
_migrate(database)
|
||||||
assert any(
|
assert any(
|
||||||
f"already at version {len(MIGRATIONS)}" in r.getMessage()
|
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] = {}
|
results: dict[str, int] = {}
|
||||||
|
|
||||||
async def _main(tmp_name: str) -> None:
|
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)
|
await database.open(tmp_name)
|
||||||
try:
|
try:
|
||||||
# Start A, yield so it acquires the lock and enters its
|
# Start A, yield so it acquires the lock and enters its
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ def _open_allowlist_db(tmp_path, name):
|
|||||||
|
|
||||||
hass = MagicMock()
|
hass = MagicMock()
|
||||||
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
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(database.open(tmp_path / name))
|
||||||
_run(apply_migrations(database))
|
_run(apply_migrations(database))
|
||||||
return database
|
return database
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path) -> tuple:
|
async def _prepare(path) -> tuple:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
children = ChildrenDao(database)
|
children = ChildrenDao(database)
|
||||||
@@ -1396,7 +1396,7 @@ def test_set_quest_definition_active_concurrent_opposite_transitions_return_own(
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "active-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ def test_recurrence_module_imports_only_stdlib() -> None:
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
source = Path(
|
source = Path(
|
||||||
__import__("custom_components.nestquest.recurrence",
|
__import__("custom_components.nestquest.core.recurrence",
|
||||||
fromlist=["__file__"]).__file__
|
fromlist=["__file__"]).__file__
|
||||||
).read_text()
|
).read_text()
|
||||||
offenders = _scan_forbidden_imports(source)
|
offenders = _scan_forbidden_imports(source)
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import (
|
|||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
_month_end,
|
|
||||||
occurs_on,
|
occurs_on,
|
||||||
occurrences_between,
|
occurrences_between,
|
||||||
)
|
)
|
||||||
|
from custom_components.nestquest.core.recurrence import _month_end
|
||||||
|
|
||||||
|
|
||||||
def _d(date: str) -> datetime.date:
|
def _d(date: str) -> datetime.date:
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import (
|
|||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
_nth_weekday_of_month,
|
|
||||||
occurs_on,
|
occurs_on,
|
||||||
occurrences_between,
|
occurrences_between,
|
||||||
)
|
)
|
||||||
|
from custom_components.nestquest.core.recurrence import _nth_weekday_of_month
|
||||||
|
|
||||||
|
|
||||||
def _d(date: str) -> datetime.date:
|
def _d(date: str) -> datetime.date:
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
|
|
||||||
def _open_db(path) -> NestQuestDatabase:
|
def _open_db(path) -> NestQuestDatabase:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
_run(database.open(path))
|
_run(database.open(path))
|
||||||
return database
|
return database
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
|
|
||||||
def _open_db(path) -> NestQuestDatabase:
|
def _open_db(path) -> NestQuestDatabase:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
_run(database.open(path))
|
_run(database.open(path))
|
||||||
return database
|
return database
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ def _open_db(path) -> NestQuestDatabase:
|
|||||||
async def _avalid_database(path) -> None:
|
async def _avalid_database(path) -> None:
|
||||||
"""Create a valid, migrated NestQuest database file (async, on the
|
"""Create a valid, migrated NestQuest database file (async, on the
|
||||||
running loop, for use inside pytest-asyncio tests)."""
|
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)
|
await database.open(path)
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
+30
-7
@@ -23,7 +23,11 @@ def _make_hass(config_dir: Path, execute: bool = True):
|
|||||||
return hass
|
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)
|
hass = _make_hass(Path("/config"), execute=False)
|
||||||
db_path = _run(store.async_get_db_path(hass))
|
db_path = _run(store.async_get_db_path(hass))
|
||||||
hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME)
|
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()
|
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"
|
nested = tmp_path / "deep" / "nested"
|
||||||
hass = _make_hass(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))
|
db_path = _run(store.async_get_db_path(hass))
|
||||||
assert nested.is_dir()
|
assert nested.is_dir()
|
||||||
hass.async_add_executor_job.assert_called_once()
|
assert len(to_thread_calls) == 1, (
|
||||||
args, kwargs = hass.async_add_executor_job.call_args
|
f"expected one asyncio.to_thread call, got {len(to_thread_calls)}"
|
||||||
fn, fn_args = args[0], args[1:]
|
)
|
||||||
|
fn, fn_args, fn_kwargs = to_thread_calls[0]
|
||||||
assert callable(fn)
|
assert callable(fn)
|
||||||
assert fn is store._ensure_dir
|
assert fn is store._ensure_dir
|
||||||
assert fn_args == (nested,)
|
assert fn_args == (nested,)
|
||||||
assert kwargs == {}
|
assert fn_kwargs == {}
|
||||||
assert db_path.parent == nested
|
assert db_path.parent == nested
|
||||||
assert db_path.parent.is_dir()
|
assert db_path.parent.is_dir()
|
||||||
assert not db_path.exists()
|
assert not db_path.exists()
|
||||||
@@ -180,7 +202,8 @@ def test_no_file_creation_calls_in_store() -> None:
|
|||||||
assert "open(" not in source
|
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)
|
hass = _make_hass(Path("/config"), execute=False)
|
||||||
assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME
|
assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path):
|
async def _prepare(path):
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
return 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
|
business operations must regenerate against the pinned date and keep
|
||||||
that first local day, proving the threaded ``today`` wins.
|
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)
|
pinned = datetime.date(2026, 1, 14)
|
||||||
host_today = datetime.date(2026, 1, 15)
|
host_today = datetime.date(2026, 1, 15)
|
||||||
|
|||||||
Reference in New Issue
Block a user