Compare commits

..
7 changed files with 19 additions and 3653 deletions
+3 -173
View File
@@ -47,18 +47,6 @@ 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.
@@ -215,7 +203,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 = _today().isoformat()
today = datetime.date.today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: due_date "
@@ -226,7 +214,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 = _today().isoformat()
today = datetime.date.today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: "
@@ -289,121 +277,6 @@ class QuestInstancesDao:
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,
) -> 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 live checks above ONLY decide whether the tuple is still
valid; they never read the window's ``due_time``. The instance is
written with the caller-supplied ``due_time`` — the value snapshotted
from the coherent input read — so a window-time edit landing
mid-walk cannot leak a newer time into a batch that was already
snapshotted against older rule/presence/assignment decisions.
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. 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 = _today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: due_date "
f"{due_date!r} is before today {today!r}"
)
async with _connection_lock(self._database):
async with self._database.transaction():
today = _today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: "
f"due_date {due_date!r} is before today {today!r}"
)
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 LIMIT 1",
(definition_id, child_id, window),
)
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,
@@ -487,7 +360,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 = _today().isoformat()
today = datetime.date.today().isoformat()
effective_cutoff = max(cutoff_date, today)
result = await self._database.execute(
"DELETE FROM quest_instances WHERE definition_id = ? "
@@ -499,49 +372,6 @@ 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.
+1 -56
View File
@@ -276,59 +276,4 @@ class PresenceOverridesDao:
"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
return result.rowcount > 0
+8 -264
View File
@@ -165,18 +165,16 @@ class QuestDefinitionWindowRecord:
@dataclass(frozen=True)
class QuestDefinitionSnapshot:
"""A definition plus its rule, assignees and windows, read atomically.
"""A definition plus its assignees and windows, read atomically.
Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows`
and :meth:`QuestDefinitionsDao.edit_definition` with the rule,
assignees and windows read inside the SAME transaction that wrote
them, so the snapshot is a consistent creation/edit-time view — a
concurrent rule, assignee or window mutation cannot change it
between the write and the read.
with the assignees and windows read inside the SAME transaction that
inserted them, so the snapshot is a consistent creation-time view —
a concurrent assignee or window mutation cannot change it between
the write and the read.
"""
definition: QuestDefinitionRecord
rule: ScheduleRuleRecord
assignees: list[ChildRecord]
windows: list[QuestDefinitionWindowRecord]
@@ -331,28 +329,6 @@ def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule:
)
def schedule_rule_storage_from_record(
record: ScheduleRuleRecord,
) -> ScheduleRuleStorage:
"""Copy a rule row's value columns into a storage mapping.
``ScheduleRuleRecord`` is the read shape (it carries ``id``);
:func:`schedule_rule_from_storage` consumes the value-only
:class:`ScheduleRuleStorage` shape, so callers re-reading a stored
rule convert through this helper.
"""
return ScheduleRuleStorage(
rule_type=record.rule_type,
interval=record.interval,
weekday_set=record.weekday_set,
day_of_month=record.day_of_month,
nth_weekday=record.nth_weekday,
month=record.month,
start_date=record.start_date,
end_date=record.end_date,
)
def _reject_forbidden_storage_fields(
storage: ScheduleRuleStorage, shape: RuleType
) -> None:
@@ -743,118 +719,9 @@ class QuestDefinitionsDao:
definition = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
rule_record = await ScheduleRulesDao(self._database).get(
rule_id
)
assert definition is not None
assert rule_record is not None
return QuestDefinitionSnapshot(
definition=definition,
rule=rule_record,
assignees=assignees,
windows=window_records,
)
async def edit_definition(
self,
definition_id: int,
*,
title: str | None | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
rule: ScheduleRuleStorage | object = _UNSET,
windows: list[tuple[str, str | None]] | object = _UNSET,
) -> QuestDefinitionSnapshot:
"""Edit a definition's metadata, rule and windows atomically.
Mirrors :meth:`create_with_rule_and_windows`: everything runs
inside ONE transaction under the connection lock, and the
returned snapshot's rule, assignees and windows are read back
inside that transaction. Arguments default to the module sentinel
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
NULL so optional metadata (description, icon) can be cleared.
- ``title``/``description``/``icon`` update the definition row
in place; the definition-level ``due_time`` column is never
written (per-window due times supersede it, D-008).
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
overwrites the referenced ``schedule_rules`` row in place: the
definition keeps its ``schedule_rule_id``, so no new or
orphaned rule row appears.
- ``windows`` REPLACES the whole window set: the current rows
are deleted and the given set re-inserted, so missing windows
are added, changed due times updated and removed windows
dropped.
Assignment is deliberately not settable here (multi-assignee is
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
activation is untouched. Raises ValueError when the definition
does not exist. Edits change future instances only: no
``quest_instances`` or ``completion_events`` row is touched.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definition = await self.get(definition_id)
if definition is None:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
("title", title),
("description", description),
("icon", icon),
):
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if assignments:
parameters.append(definition_id)
await self._database.execute(
f"UPDATE quest_definitions SET "
f"{', '.join(assignments)} WHERE id = ?",
tuple(parameters),
)
if rule is not _UNSET:
await ScheduleRulesDao(self._database).update(
definition.schedule_rule_id,
rule_type=rule.rule_type,
interval=rule.interval,
weekday_set=rule.weekday_set,
day_of_month=rule.day_of_month,
nth_weekday=rule.nth_weekday,
month=rule.month,
start_date=rule.start_date,
end_date=rule.end_date,
)
if windows is not _UNSET:
await self._database.execute(
"DELETE FROM quest_definition_windows "
"WHERE definition_id = ?",
(definition_id,),
)
for window, due_time in windows:
_validate_window(window)
if due_time is not None:
_validate_time(due_time, "due_time")
await self._database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) "
"VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
updated = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
rule_record = await ScheduleRulesDao(self._database).get(
updated.schedule_rule_id
)
assert updated is not None
assert rule_record is not None
return QuestDefinitionSnapshot(
definition=updated,
rule=rule_record,
assignees=assignees,
windows=window_records,
)
@@ -907,81 +774,6 @@ class QuestDefinitionsDao:
)
return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
"""Read each definition's rule, assignees and windows.
MUST be called inside the connection lock (and ideally a
transaction) so the definitions handed in and the rows this
reads back cannot be raced by a concurrent edit between list
and bundle. Each rule is read through
:class:`ScheduleRulesDao` on the same connection, so the
snapshot's rule record is the one the definition references AT
the moment of the locked read.
"""
rules_dao = ScheduleRulesDao(self._database)
snapshots: list[QuestDefinitionSnapshot] = []
for definition in definitions:
rule_record = await rules_dao.get(definition.schedule_rule_id)
assert rule_record is not None
snapshots.append(
QuestDefinitionSnapshot(
definition=definition,
rule=rule_record,
assignees=await self.list_assignees(definition.id),
windows=await self.list_windows(definition.id),
)
)
return snapshots
async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition with its rule, assignees and
windows, all read inside one locked transaction.
The definitive list and each definition's linked rows are read
in a single BEGIN..COMMIT span under the connection lock, so a
concurrent atomic edit cannot interleave between the definition
list and the bundle read: the caller receives a coherent
snapshot (each definition's rule, assignees and windows as they
were at one instant), never a mixed old-rule/new-windows state.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition's snapshot WITHOUT locking.
Mirrors :meth:`list_snapshots_active` minus the lock and
transaction: the definitions and their rules, assignees and
windows are read on the caller's connection under whatever
transaction the caller is already holding, so they stay coherent
with every OTHER table the caller reads inside that same
transaction. MUST be called inside the connection lock and an
open transaction (the single-transaction input snapshot the
materialization walk uses is that caller).
"""
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_by_child(
self, child_id: int
) -> list[QuestDefinitionSnapshot]:
"""Return each definition assigned to ``child_id`` with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` but selects by assignment
(``list_by_child`` order: newest first). Locks read-and-bundle
into one transaction so the bundle never mixes one edit's
definition with another's windows.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_by_child(child_id)
return await self._snapshots_for(definitions)
async def update(
self,
definition_id: int,
@@ -1037,18 +829,14 @@ class QuestDefinitionsDao:
async def add_assignee(
self, definition_id: int, child_id: int
) -> list[ChildRecord]:
) -> None:
"""Assign ``child_id`` to the definition (idempotent).
Validates the assignee inside the same serialized transaction
as the INSERT (existing and active AT ASSIGNMENT TIME), so a
child deactivated concurrently cannot become an assignee.
Assigning an already-assigned child is a no-op, mirroring the
composite primary key's storage-level guarantee. Returns the
definition's assignees read back INSIDE the same transaction
that performed the write, so the returned roster is a
consistent post-assignment snapshot — a concurrent assign or
unassign cannot race it after the write commits. Changes
composite primary key's storage-level guarantee. Changes
future instances only: already-generated instances and
completion history are never rewritten here.
"""
@@ -1069,7 +857,6 @@ class QuestDefinitionsDao:
"ON CONFLICT (definition_id, child_id) DO NOTHING",
(definition_id, child_id),
)
return await self.list_assignees(definition_id)
async def remove_assignee(
self, definition_id: int, child_id: int
@@ -1204,47 +991,4 @@ class QuestDefinitionsDao:
if not child[0]:
raise ValueError(
f"child {child_id} is inactive and cannot be assigned"
)
async def load_materialization_input(
database: NestQuestDatabase,
range_start: str,
range_end: str,
):
"""Read every materialization input in ONE locked transaction.
Returns ``(snapshots, schedules, overrides)``:
- ``snapshots``: every active definition with its rule, assignees and
windows (the :class:`QuestDefinitionSnapshot` shape).
- ``schedules`` / ``overrides``: the presence schedules and
range-scoped overrides of every assignee child, in
:mod:`.dao_presence`'s record shapes.
The whole read — definitions, rules, assignees, windows, and the
assignees' presence schedules and overrides — runs inside a single
BEGIN..COMMIT span under the connection lock, so a concurrent edit
(definition, assignment, window, or presence) cannot interleave
between the parts and the walk never sees a mixed-time view. Each
table's SQL stays in its own DAO module: the presence portion is
delegated to :mod:`.dao_presence` (a lazy import here, since that
module imports this one).
"""
definitions_dao = QuestDefinitionsDao(database)
async with _connection_lock(database):
async with database.transaction():
snapshots = await definitions_dao.read_snapshots_active()
child_ids = sorted(
{
child.id
for snapshot in snapshots
for child in snapshot.assignees
}
)
from . import dao_presence # lazy: dao_presence imports us
schedules, overrides = await dao_presence.read_snapshot_unlocked(
database, child_ids, range_start, range_end
)
return snapshots, schedules, overrides
)
-302
View File
@@ -1,302 +0,0 @@
"""The materialization walk (Feature 07 core).
:func:`materialize` turns the schedule rules + custody presence of the
active quest definitions into concrete ``quest_instances`` rows over a
date range. It is the pure walk over definitions x dates x assignees x
windows; HA scheduling (day-rollover triggers) is a separate later
task and lives elsewhere.
The walk is deterministic and idempotent:
- All inputs — the active definitions with their rules, assignees and
windows, AND the assignee children's presence schedules and scoped
overrides — are read inside ONE locked transaction (see
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
single coherent snapshot rather than a definition state from one
instant and a presence state from another.
- Presence is snapshotted into an immutable
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
A config change that lands AFTER the snapshot is read but BEFORE a
tuple's write (an assignment removed, a definition or child deactivated,
a window removed) makes that tuple SKIP rather than abort the batch:
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
every materialization precondition (active definition, active child,
live assignment, declared window, and its CURRENT ``due_time``) inside
the SAME transaction as the INSERT, so the check and the write cannot be
split by a racing config edit.
The upsert is idempotent on (definition_id, child_id, due_date,
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, _today
from .dao_rules import (
_validate_date,
load_materialization_input,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
)
from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
from .recurrence import ScheduleRule, occurs_on
def _now_stamp() -> str:
"""Return the batch's shared UTC second-precision generation stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _decode_rule(snapshot) -> ScheduleRule:
"""Decode a snapshot's rule record back into a ScheduleRule.
The record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through ``ScheduleRulesDao``) preserves the coherence the snapshot
guarantees.
"""
return schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
def _build_engine(
schedules_records,
overrides_records,
) -> PresenceEngine:
"""Convert raw presence records into an immutable PresenceEngine."""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
child_id, record.anchor_date, record.pattern
)
overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items():
overrides[child_id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in records
]
return PresenceEngine(schedules, overrides)
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].
``start_date`` and ``end_date`` must be strict ISO calendar dates
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
else raises ValueError naming the offending field. Returns the number
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
transaction as the INSERT, so a config change that arrives mid-batch
skips the affected tuples rather than aborting the batch or leaving a
partial result.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
if end_date < start_date:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end_date!r} < {start_date!r}"
)
snapshots, schedules_records, overrides_records = await (
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),
snapshot.assignees, snapshot.windows)
for snapshot in snapshots
]
generated_at = _now_stamp()
instances = QuestInstancesDao(database)
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
count = 0
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for definition_id, rule, assignees, windows in definitions:
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:
written = await instances.upsert_if_valid(
definition_id,
child.id,
iso,
generated_at,
window=window.window,
due_time=window.due_time,
)
if written is not None:
count += 1
cursor += datetime.timedelta(days=1)
return count
async def regenerate_for_definition(
database: NestQuestDatabase,
definition_id: int,
) -> int:
"""Regenerate a definition's future instances after a config change.
The Feature 06 edit/assignment/activation paths call this after a
successful write so future instances track the new state: the
definition's open instances at or after today are deleted (never a
completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
then the materialization walk re-runs over the rolling horizon
``[today, today + DEFAULT_HORIZON_DAYS]`` so the (changed) rule,
assignee set and windows re-materialize against today's state.
"today" is computed the same way the walk computes it —
: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 = _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]
)
@@ -1,13 +1,10 @@
"""Business layer for creating and editing quest definitions (Feature 06).
"""Business layer for creating quest definitions (Feature 06).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
all-or-nothing create here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path; there is no
permission check here (that is Feature 09).
Validation policy mirrors :mod:`.children`:
@@ -41,30 +38,23 @@ from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import ChildRecord, _connection_lock
from .dao_children import ChildRecord
from .dao_rules import (
QuestDefinitionRecord,
QuestDefinitionSnapshot,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
ScheduleRuleStorage,
ScheduleRulesDao,
_UNSET,
_validate_date,
_validate_time,
_validate_window,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .materialize import regenerate_for_definition
from .recurrence import ScheduleRule, occurs_on
from .recurrence import ScheduleRule
@dataclass(frozen=True)
class CreatedQuestDefinition:
"""The persisted result of a create, edit or activation change.
"""The persisted result of :func:`create_quest_definition`.
``definition`` is the stored row; ``rule`` is the schedule rule
decoded back through the storage mapping; ``assignees`` and
@@ -179,30 +169,6 @@ def _normalize_windows(
return normalized
def _validate_definition_id(value: object) -> int:
"""Reject non-int definition ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address definition 1 and a float would round — malformed service
input must never mutate the wrong definition.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"definition_id must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def create_quest_definition(
database: NestQuestDatabase,
title: str,
@@ -260,300 +226,3 @@ async def create_quest_definition(
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def edit_quest_definition(
database: NestQuestDatabase,
definition_id: int,
*,
title: str | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
(required and trimmed); ``description`` and ``icon`` are optional
and may be cleared by passing ``None`` explicitly. A provided
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
and REPLACES the definition's schedule rule IN PLACE — the existing
``schedule_rules`` row is updated, never orphaned or duplicated. A
provided ``windows`` list REPLACES the whole window set (each entry
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
so the returned bundle is a consistent edit snapshot. Edits change
future instances only; no ``quest_instances`` or
``completion_events`` row is touched.
"""
_validate_definition_id(definition_id)
title_value: str | None | object = _UNSET
if title is not _UNSET:
name = _validate_text(title, "title", required=True)
assert name is not None
title_value = name
description_value: str | None | object = _UNSET
if description is not _UNSET:
description_value = _validate_text(description, "description")
icon_value: str | None | object = _UNSET
if icon is not _UNSET:
icon_value = _validate_text(icon, "icon")
rule_storage: ScheduleRuleStorage | object = _UNSET
if rule is not _UNSET:
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
rule_storage = schedule_rule_to_storage(rule)
window_specs: list[tuple[str, str | None]] | object = _UNSET
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.edit_definition(
definition_id,
title=title_value,
description=description_value,
icon=icon_value,
rule=rule_storage,
windows=window_specs,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
await regenerate_for_definition(database, definition_id)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def assign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
) -> list[ChildRecord]:
"""Assign ``child_id`` to the definition (idempotent).
Validates that both ids are plain ints (bools and floats rejected)
and that the child exists and is active — the DAO enforces the
active/exists check inside its transaction, and also confirms the
definition exists. Assigning an already-assigned child is a no-op
(no duplicate row). Returns the definition's assignees read back
inside the DAO's same transaction, a consistent post-assignment
roster (a concurrent assign/unassign cannot race it after the
write). Raises ValueError on any rejected argument. Only the
``quest_definition_assignees`` link is written; existing instances
and completion history are never touched.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
try:
assignees = await dao.add_assignee(definition_id, child_id)
except ValueError as error:
# The DAO names only the child/definition; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(f"child_id: {message}") from error
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
await regenerate_for_definition(database, definition_id)
return assignees
async def unassign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
) -> bool:
"""Unassign ``child_id`` from the definition; True when removed.
Validates both ids are plain ints (bools and floats rejected) and
that the definition exists, then removes the (definition, child)
link. Returns True when a row was removed and False when the child
was not currently assigned. Raises ValueError on a rejected
argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
definition = await dao.get(definition_id)
if definition is None:
raise ValueError(
f"definition_id: quest definition {definition_id} does not exist"
)
removed = await dao.remove_assignee(definition_id, child_id)
await regenerate_for_definition(database, definition_id)
return removed
async def set_quest_definition_active(
database: NestQuestDatabase,
definition_id: int,
is_active: bool,
) -> CreatedQuestDefinition:
"""Deactivate or reactivate a definition; returns the updated view.
Flipping the flag is the only removal path (Feature 06 guardrail):
a deactivated definition stops future instance generation but its
row, schedule rule, assignees and windows all survive, and existing
instances plus completion history are never touched. Reactivation
re-enables future generation; generating or removing instances is
Feature 07 and does not happen here.
``definition_id`` must be a plain int (bools and floats rejected,
since SQLite would bind ``True`` onto definition 1) and
``is_active`` must be a real bool — the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError naming the
offending field when the id is malformed, ``is_active`` is not a
bool, or the definition does not exist.
Atomicity: the existence check, the UPDATE and the readback of the
definition, its rule, assignees and windows all run inside the
connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race.
"""
_validate_definition_id(definition_id)
if not isinstance(is_active, bool):
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
async with _connection_lock(database):
dao = QuestDefinitionsDao(database)
if await dao.get(definition_id) is None:
raise ValueError(
f"definition_id: quest definition {definition_id} "
"does not exist"
)
await dao.set_active(definition_id, is_active)
updated = await dao.get(definition_id)
assert updated is not None
rule_record = await ScheduleRulesDao(database).get(
updated.schedule_rule_id
)
assert rule_record is not None
assignees = await dao.list_assignees(definition_id)
windows = await dao.list_windows(definition_id)
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(rule_record)
)
await regenerate_for_definition(database, definition_id)
return CreatedQuestDefinition(
definition=updated,
rule=rule,
assignees=assignees,
windows=windows,
)
def _bundle_snapshot(
snapshot: QuestDefinitionSnapshot,
) -> CreatedQuestDefinition:
"""Decode a DAO snapshot into the layer's rich view.
The rule record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
coherence the DAO's locked snapshot guarantees: the rule, assignees
and windows all come from one atomic read, never three separate ones.
"""
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def list_active_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return every ACTIVE definition with its decoded rule and links.
Ordered by rising definition id, matching the DAO's ``list_active``
order, so callers get a stable, repeatable sequence. Each entry is
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
the layer: the stored row plus its decoded
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
definitions and their links are read by the DAO inside one locked
transaction, so each bundle is a coherent snapshot that a concurrent
edit cannot interleave.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
) -> list[CreatedQuestDefinition]:
"""Return the definitions assigned to ``child_id`` (rich view).
``child_id`` must be a plain int (bools and floats rejected, since
SQLite would bind ``True`` onto child 1). Results are ordered by
rising definition id, matching ``list_active_definitions`` and
``list_definitions_firing_on``. Each entry is a
:class:`CreatedQuestDefinition` snapshot bundling the definition with
its decoded rule, assignees and windows, read atomically by the DAO.
"""
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_by_child(child_id)
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return sorted(bundles, key=lambda bundle: bundle.definition.id)
async def list_definitions_firing_on(
database: NestQuestDatabase,
date: str,
) -> list[CreatedQuestDefinition]:
"""Return the ACTIVE definitions whose rule fires on ``date``.
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
value raises ValueError naming the field. Each active definition's
stored rule is decoded through the task-1 storage mapping and handed
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
definitions whose decoded rule fires on the date are returned, in
rising definition-id order, each as the same rich
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
The definitions and their rules are read inside one locked
transaction, so a bundle can never filter on one rule while
presenting another state's windows.
"""
_validate_date(date, "date")
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff