diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 8c9e852..c948aa8 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -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. diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index a5e5abf..342e626 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -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] + ) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index e114678..62523fd 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -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) \ No newline at end of file + _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) \ No newline at end of file