Compare commits

...
3 changed files with 432 additions and 9 deletions
+60 -5
View File
@@ -47,6 +47,18 @@ from .db import NestQuestDatabase
_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 _validate_utc_timestamp(value: str, field: str) -> None:
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
@@ -203,7 +215,7 @@ class QuestInstancesDao:
# 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.
today = datetime.date.today().isoformat()
today = _today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: due_date "
@@ -214,7 +226,7 @@ class QuestInstancesDao:
# Re-read "today" under the lock: this is the date the
# insert actually executes on, so the no-past rule
# holds across midnight rollovers.
today = datetime.date.today().isoformat()
today = _today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: "
@@ -326,7 +338,7 @@ class QuestInstancesDao:
_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 = datetime.date.today().isoformat()
today = _today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: due_date "
@@ -334,7 +346,7 @@ class QuestInstancesDao:
)
async with _connection_lock(self._database):
async with self._database.transaction():
today = datetime.date.today().isoformat()
today = _today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: "
@@ -475,7 +487,7 @@ class QuestInstancesDao:
# midnight cannot delete instances that became past
# while it waited (backdating is clamped to the
# execution date, not the call date).
today = datetime.date.today().isoformat()
today = _today().isoformat()
effective_cutoff = max(cutoff_date, today)
result = await self._database.execute(
"DELETE FROM quest_instances WHERE definition_id = ? "
@@ -487,6 +499,49 @@ class QuestInstancesDao:
)
return result.rowcount
async def delete_future_uncompleted_for_child(
self, child_id: int, cutoff_date: str
) -> 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). 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 = _today().isoformat()
effective_cutoff = max(cutoff_date, today)
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.
+103 -3
View File
@@ -37,13 +37,34 @@ window), so re-running the materialization over the same range never
duplicates a row; a tuple whose instance is already completed is SKIPPED
(never rewritten, never raised) by
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
Regeneration: a config or presence change must rebuild future instances
so they track the new state. :func:`regenerate_for_definition` covers
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
covers a single child's presence change (Feature 09). Both delete the
affected open instances at or after today — never a completed instance,
never the past — and re-run the walk over the rolling horizon
``[today, today + DEFAULT_HORIZON_DAYS]``.
HOOK (Feature 09 presence services): the presence business layer that
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
presence-state write so a child's future instances track its presence:
``set_presence_pattern`` (including clearing/setting an empty pattern),
deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
``create_presence_override``, and ``delete_presence_override``. A
schedule delete changes presence state too (the child becomes
always-present), so it must regenerate just like the others. This hook
is documented, not yet wired: no presence business layer exists yet, and
the presence DAO (``dao_presence.py``) must stay free of business rules
— never call :func:`regenerate_for_child` from inside the DAO layer.
"""
from __future__ import annotations
import datetime
from .const import DEFAULT_HORIZON_DAYS
from .dao_instances import QuestInstancesDao
from .dao_instances import QuestInstancesDao, _today
from .dao_rules import (
_validate_date,
load_materialization_input,
@@ -104,6 +125,8 @@ async def materialize(
database: NestQuestDatabase,
start_date: str,
end_date: str,
*,
child_ids: list[int] | None = None,
) -> int:
"""Generate quest instances for the closed range [start_date, end_date].
@@ -113,6 +136,12 @@ async def materialize(
of instances upserted for the range (idempotent — a re-run refreshes
the same rows, never duplicates them).
``child_ids`` optionally scopes the walk to exactly those children:
assignees outside the list are never written, so a caller that only
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before.
All inputs are read in ONE locked transaction; each tuple's write then
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
which re-validates the tuple and its current ``due_time`` in the same
@@ -132,6 +161,7 @@ async def materialize(
load_materialization_input(database, start_date, end_date)
)
engine = _build_engine(schedules_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids)
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
@@ -153,6 +183,8 @@ async def materialize(
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if not engine.is_present(child.id, cursor):
continue
for window in windows:
@@ -186,17 +218,85 @@ async def regenerate_for_definition(
assignee set and windows re-materialize against today's state.
"today" is computed the same way the walk computes it —
``datetime.date.today()`` — so the delete cutoff and the
:func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated
range. Returns the number of instances the re-materialization
upserted across the whole snapshot (idempotent — other active
definitions' tuples refresh in place, never duplicate).
"""
today = datetime.date.today()
today = _today()
start_date = today.isoformat()
end_date = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
instances = QuestInstancesDao(database)
await instances.delete_future_uncompleted(definition_id, start_date)
return await materialize(database, start_date, end_date)
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
# child-presence counterpart to ``regenerate_for_definition``. The
# presence business layer that lands in Feature 09 MUST call
# ``regenerate_for_child`` after EVERY presence-state write, so future
# instances track a child's changed presence: set_presence_pattern
# (including clearing/setting an empty pattern), deleting/clearing the
# presence schedule (PresenceSchedulesDao.delete),
# create_presence_override, and delete_presence_override. A schedule
# delete changes presence state too (the child becomes always-present),
# so it must regenerate just like the others. It is deliberately NOT
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
# business rules), and no presence business layer exists yet to host the
# call — see the module docstring note above.
async def regenerate_for_child(
database: NestQuestDatabase,
child_id: int,
) -> int:
"""Regenerate a child's future instances after a presence change.
The Feature 09 presence services call this after every
presence-state write — set_presence_pattern (including clearing or
setting an empty pattern), deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
create_presence_override, and delete_presence_override — so a
child's future instances track its new presence: the child's open
instances at or after today are deleted across ALL its definitions
(never a completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
then the materialization walk re-runs over the rolling horizon
``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only,
so the child's now-absent/present dates re-materialize against
today's presence WITHOUT touching any other child's rows.
``child_id`` must be a plain int — bool and float are rejected
(SQLite binds a bool as 0/1 and a float would round, so a malformed
id must never mutate the wrong profile).
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — and the re-materialization range uses the
delete's returned effective cutoff (see
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight.
Returns the number of instances the re-materialization upserted for
the child across the whole snapshot.
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
today = _today()
instances = QuestInstancesDao(database)
# Use the delete's returned effective cutoff as the materialize anchor,
# NOT the independently-computed "today" above. If the call is queued
# across midnight the delete clamps its cutoff to the execution-day;
# materializing from the stale "today" (now yesterday) would trip the
# walk's no-past guard and leave the child's future instances deleted
# but not rebuilt. One anchor for both keeps them consistent.
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
child_id, today.isoformat()
)
start = datetime.date.fromisoformat(effective_cutoff)
end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id]
)
+269 -1
View File
@@ -19,6 +19,7 @@ from custom_components.nestquest.dao_rules import (
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.materialize import (
materialize,
regenerate_for_child,
regenerate_for_definition,
)
from custom_components.nestquest.migrations import apply_migrations
@@ -772,4 +773,271 @@ def test_edit_quest_definition_regenerates_future_instances(tmp_path) -> None:
assert (day.isoformat() in by_due) == occurs_on(weekly, day)
return None
_with_db(tmp_path, "edit-regenerates.db")(_body)
_with_db(tmp_path, "edit-regenerates.db")(_body)
def test_regenerate_for_child_rebuilds_horizon_preserves_history(
tmp_path,
) -> None:
async def _body(database):
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
children = ChildrenDao(database)
overrides = PresenceOverridesDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
horizon_end = today + datetime.timedelta(days=14)
start_iso = today.isoformat()
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
[("morning", "09:00")],
)
dao = QuestInstancesDao(database)
events = CompletionEventsDao(database)
# Instances across the full horizon: 15 daily rows.
await materialize(database, start_iso, horizon_end.isoformat())
all_future = await dao.list_by_date_range(
child.id, start_iso, horizon_end.isoformat()
)
assert len(all_future) == 15
# A completed instance on tomorrow: immutable, must be spared.
completed_due = (today + datetime.timedelta(days=1)).isoformat()
completed = next(r for r in all_future if r.due_date == completed_due)
await events.append(
completed.id,
child.id,
"completed",
"user",
f"{completed_due}T08:00:00+00:00",
True,
actor_user_id="user-1",
)
# A past instance (yesterday): raw insert bypasses the walk's
# no-past guard so the child-scoped delete has history to spare.
past_due = (today - datetime.timedelta(days=1)).isoformat()
await database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, 'morning', ?, '09:00', ?)",
(created.definition.id, child.id, past_due, NOW),
)
# An ABSENT override covering three future dates that had
# instances: days 2..4 after today.
absent_start = (today + datetime.timedelta(days=2)).isoformat()
absent_end = (today + datetime.timedelta(days=4)).isoformat()
override = await overrides.create(
child.id, absent_start, absent_end, False, note="away"
)
await regenerate_for_child(database, child.id)
records = await dao.list_by_date_range(
child.id, past_due, horizon_end.isoformat()
)
by_due = {r.due_date: r for r in records}
# The now-absent days' instances are gone.
for offset in range(2, 5):
iso = (today + datetime.timedelta(days=offset)).isoformat()
assert iso not in by_due
# The completed instance survived untouched (same physical id).
assert by_due[completed_due].id == completed.id
# The past instance survived.
assert past_due in by_due
# Every non-absent future day still has its instance.
for offset in range(15):
iso = (today + datetime.timedelta(days=offset)).isoformat()
if 2 <= offset <= 4:
continue
assert iso in by_due
# Remove the override and regenerate again: the absent days return.
await overrides.delete(override.id)
await regenerate_for_child(database, child.id)
records = await dao.list_by_date_range(
child.id, past_due, horizon_end.isoformat()
)
by_due = {r.due_date: r for r in records}
for offset in range(15):
iso = (today + datetime.timedelta(days=offset)).isoformat()
assert iso in by_due
# The completed instance is still the same untouched row, and the
# past instance is still there.
assert by_due[completed_due].id == completed.id
assert by_due[completed_due].due_time == "09:00"
assert past_due in by_due
return None
_with_db(tmp_path, "regenerate-child.db")(_body)
def test_regenerate_for_child_uses_single_anchor_across_midnight(
tmp_path, monkeypatch
) -> None:
"""A call queued across midnight must not delete-then-fail-to-rebuild.
``regenerate_for_child`` reads "today" once as a cutoff hint, but the
delete clamps that cutoff to the execution-day under its own lock and
RETURNS the effective cutoff; the re-materialization window is then
anchored to the same returned day. This test fakes a midnight rollover
between the two reads: the caller's hint is day0 while the delete
executes on day1. The rebuild must start on day1 (so the walk's
no-past guard never fires) and the now-past day0 instance must survive.
"""
day0 = datetime.date.today() + datetime.timedelta(days=30)
day1 = day0 + datetime.timedelta(days=1)
clock = {"rolled": False, "reads": 0}
def _fake_today() -> datetime.date:
if not clock["rolled"]:
return day0
clock["reads"] += 1
return day0 if clock["reads"] == 1 else day1
import custom_components.nestquest.dao_instances as dao_instances_module
import custom_components.nestquest.materialize as materialize_module
monkeypatch.setattr(dao_instances_module, "_today", _fake_today)
monkeypatch.setattr(materialize_module, "_today", _fake_today)
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
day0_iso = day0.isoformat()
horizon_end = day0 + datetime.timedelta(days=14)
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=day0_iso),
[child.id],
[("morning", "09:00")],
)
dao = QuestInstancesDao(database)
# Seed the horizon while the clock reads day0.
await materialize(database, day0_iso, horizon_end.isoformat())
# Roll the clock forward: every read after the caller's single
# cutoff hint now returns day1 (the delete's execution-day).
clock["rolled"] = True
clock["reads"] = 0
rebuilt = await regenerate_for_child(database, child.id)
# No raise occurred; the rebuilt window is anchored on the delete's
# execution-day (day1), so day1..day1+14 all exist.
assert rebuilt == 15
day1_iso = day1.isoformat()
day1_horizon_end = day1 + datetime.timedelta(days=14)
assert len(await dao.list_by_date_range(
child.id, day1_iso, day1_horizon_end.isoformat()
)) == 15
# day0 was the caller's "today" but rolled over to the past by the
# time the delete ran; it must survive (never delete the past).
assert await dao.get(
created.definition.id, child.id, day0_iso, "morning"
) is not None
return rebuilt
_with_db(tmp_path, "regenerate-child-midnight.db")(_body)
def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
"""Regenerating one child must never touch another child's rows."""
async def _body(database):
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
children = ChildrenDao(database)
overrides = PresenceOverridesDao(database)
a = await children.create("Ada", NOW)
b = await children.create("Bo", NOW)
today = datetime.date.today()
horizon_end = today + datetime.timedelta(days=14)
start_iso = today.isoformat()
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[a.id, b.id],
[("morning", "09:00")],
)
dao = QuestInstancesDao(database)
await materialize(database, start_iso, horizon_end.isoformat())
# Baseline for child b, keyed by due_date, so we can prove its rows
# are byte-for-byte unchanged afterward.
baseline_b = {
r.due_date: (r.id, r.due_time, r.generated_at)
for r in await dao.list_by_date_range(
b.id, start_iso, horizon_end.isoformat()
)
}
assert len(baseline_b) == 15
# An ABSENT override for child a only, covering days 2..4.
absent_start = (today + datetime.timedelta(days=2)).isoformat()
absent_end = (today + datetime.timedelta(days=4)).isoformat()
await overrides.create(
a.id, absent_start, absent_end, False, note="away"
)
await regenerate_for_child(database, a.id)
# Child a's now-absent days vanished.
a_by_due = {
r.due_date: r
for r in await dao.list_by_date_range(
a.id, start_iso, horizon_end.isoformat()
)
}
for offset in range(2, 5):
iso = (today + datetime.timedelta(days=offset)).isoformat()
assert iso not in a_by_due
# Child b is untouched: same physical rows, same snapshot columns.
b_records = await dao.list_by_date_range(
b.id, start_iso, horizon_end.isoformat()
)
assert len(b_records) == 15
for r in b_records:
assert (r.id, r.due_time, r.generated_at) == baseline_b[r.due_date]
return None
_with_db(tmp_path, "regenerate-child-scoped.db")(_body)
def test_regenerate_for_child_rejects_non_int_child_id(tmp_path) -> None:
async def _body(database):
for bad in (True, False, 1.0, "1"):
with pytest.raises(ValueError, match="child_id must be an integer"):
await regenerate_for_child(database, bad)
return None
_with_db(tmp_path, "regenerate-child-badid.db")(_body)