Compare commits

...
Author SHA1 Message Date
Joshua King fe90064b65 Add actor_child_id to completion_events (D-008)
Per IMPLEMENTATION-ORDER.md 1.5: a nullable actor_child_id column
records which panel profile was tapped; admin events stay NULL.

- schema.py: completion_events table body extracted to
  COMPLETION_EVENTS_TABLE_SQL; the new column carries a FK to
  children(id) and the actor-pair coherence is extended to a second
  CHECK: 'user' events require actor_user_id and forbid
  actor_child_id, 'panel' events require actor_child_id and forbid
  actor_user_id.
- migrations.py: migration 6 rebuilds legacy-shaped completion_events
  via the canonical procedure (CHECKs cannot be added to an existing
  table), backfilling each legacy panel row to its own child_id
  (pre-D-008 panel completions were always the tapped child) via
  INSERT..SELECT — no UPDATE or DELETE path exists, so the append-only
  guarantee is untouched; ids preserved; PRAGMA foreign_key_check
  verified in-transaction.
- dao_instances.py: append() takes actor_child_id, validates both
  actor shapes (panel requires it, user forbids it) and the tapped
  child's existence under the connection lock; the record and column
  list grow accordingly.
- tests: coherence CHECK matrix (5 bad shapes), unknown
  actor_child_id FK, panel/user round-trips at DDL and DAO layers,
  migration-6 backfill test on a frozen v2-shape seed; the
  completion_events leak guard gains justified INSERT exemptions for
  the migration layer (UPDATE/DELETE stay forbidden everywhere).
2026-09-15 10:25:50 -04:00
Joshua King 25f1479444 Widen instance key to (definition_id, child_id, due_date, window) (D-008)
Per IMPLEMENTATION-ORDER.md 1.2: quest_instances gains a NOT NULL
window column (CHECK-constrained to the const window spellings) and
the table-level UNIQUE constraint widens to the full fan-out tuple.

- schema.py: instance table body extracted to QUEST_INSTANCES_TABLE_SQL
  shared with the rebuild migration; uniqueness docstring rewritten for
  the widened key.
- migrations.py: migration 5 rebuilds a pre-window quest_instances via
  the canonical procedure; migrated rows are pinned to the documented
  default 'morning' window (pre-window rows were whole-day obligations;
  future dates are regenerated with real windows by materialization),
  preserving ids so completion_events keep pointing at the same
  instances; PRAGMA foreign_key_check verified inside the transaction.
- dao_instances.py: upsert takes required keyword window and is
  idempotent on the widened tuple; the immutable-once-completed check
  and the read-back get() are keyed on the full tuple; window spelling
  validated via dao_rules._validate_window.
- tests: schema shape (window column, widened autoindex columns),
  twice-daily and shared-assignee fan-out cases at both the DDL and DAO
  layers (incl. idempotent re-runs), cross-child same-key allowance,
  window CHECK/NOT NULL constraints, v2 seed frozen to the true
  version-2 shapes and its migration assertions widened.
2026-09-15 10:17:31 -04:00
8 changed files with 789 additions and 231 deletions
+91 -50
View File
@@ -15,13 +15,13 @@ 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, due_date) — re-running materialization for a date
whose instance already exists updates nothing that matters
(child_id/due_time are refreshed only in the degenerate re-generation
case where they changed) and never duplicates a row. The schema's
UNIQUE(definition_id, due_date) is the last line of defense. The
upsert refuses a child that is not an assignee of the definition
(D-008 multi-assignee model).
(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
@@ -37,7 +37,7 @@ import datetime
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _validate_date
from .dao_rules import _validate_date, _validate_window
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
@@ -90,6 +90,7 @@ class QuestInstanceRecord:
id: int
definition_id: int
child_id: int
window: str
due_date: str
due_time: str | None
generated_at: str
@@ -105,16 +106,17 @@ class CompletionEventRecord:
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, due_date, due_time, generated_at"
"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, "
"occurred_at, was_on_time"
"actor_child_id, occurred_at, was_on_time"
)
#: The only event types the append path accepts; the schema CHECK is
@@ -128,9 +130,10 @@ def _instance_from_row(row: tuple) -> QuestInstanceRecord:
id=row[0],
definition_id=row[1],
child_id=row[2],
due_date=row[3],
due_time=row[4],
generated_at=row[5],
window=row[3],
due_date=row[4],
due_time=row[5],
generated_at=row[6],
)
@@ -142,8 +145,9 @@ def _event_from_row(row: tuple) -> CompletionEventRecord:
event_type=row[3],
actor_source=row[4],
actor_user_id=row[5],
occurred_at=row[6],
was_on_time=None if row[7] is None else bool(row[7]),
actor_child_id=row[6],
occurred_at=row[7],
was_on_time=None if row[8] is None else bool(row[8]),
)
@@ -160,31 +164,40 @@ class QuestInstancesDao:
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
) -> QuestInstanceRecord:
"""Create the dated instance for (definition_id, due_date) idempotently.
"""Create the dated instance for the widened key idempotently.
Running materialization twice for the same definition/date
yields ONE row: the ON CONFLICT path refreshes the
generation-time snapshot columns (child_id, due_time) and
regenerates the stamp, but can never create a duplicate — the
schema UNIQUE(definition_id, due_date) backs this up.
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.
- ``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
@@ -227,51 +240,56 @@ class QuestInstancesDao:
f"assigned to child {child_id}; instance must "
"carry an assigned child"
)
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")
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND due_date = ?)",
(definition_id, due_date),
"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"due_date {due_date!r}) already has a completion "
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, "
"due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, due_date) DO UPDATE SET "
"child_id = excluded.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, due_date)
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, due_date: str
self,
definition_id: int,
child_id: int,
due_date: str,
window: str,
) -> QuestInstanceRecord | None:
"""Return the instance for (definition_id, due_date), or 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 due_date = ?",
(definition_id, due_date),
"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
@@ -380,19 +398,21 @@ class CompletionEventsDao:
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' (with ``actor_user_id`` set) or
'panel' (no user id — the tapped child is ``child_id``); the
actor-pair CHECK enforces 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
and child must exist — validated atomically under the
connection lock.
``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(
@@ -412,6 +432,15 @@ class CompletionEventsDao:
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 "
@@ -442,16 +471,28 @@ class CompletionEventsDao:
)
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, occurred_at, "
"was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)",
"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),
),
+156 -3
View File
@@ -24,9 +24,13 @@ 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. 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.
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. 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
@@ -70,7 +74,9 @@ 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,
@@ -147,6 +153,14 @@ async def _table_has_column(
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:
@@ -250,6 +264,138 @@ async def _add_definition_assignees(
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.)
@@ -261,6 +407,13 @@ MIGRATIONS: Sequence[MigrationStep] = [
# 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,
]
+83 -55
View File
@@ -109,25 +109,31 @@ 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``,
``due_date`` and ``child_id`` snapshot the materialization-time facts
("why this instance exists"): reassignment and rule edits change future
instances only and never rewrite these rows. ``due_time`` snapshots
the definition'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).
``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, due_date) pair 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 pair. 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.
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
@@ -153,11 +159,12 @@ 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 already ``child_id``). A coherence
CHECK makes the pair shape non-negotiable: ``'user'`` requires
``actor_user_id``, ``'panel'`` forbids it. Other actor sources are
rejected; if an unattended system actor is ever needed, that is a
schema migration, not a silent widening.
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
@@ -316,43 +323,64 @@ SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
""",
]
#: 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] = [
"""
CREATE TABLE IF NOT EXISTS quest_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
due_date TEXT NOT NULL,
due_time TEXT,
generated_at TEXT NOT NULL,
UNIQUE (definition_id, due_date)
)
f"""
CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL}
""",
]
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS completion_events (
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,
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 (
(event_type = 'completed' AND was_on_time IS NOT NULL)
OR (event_type = 'uncompleted')
)
#: 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}
""",
]
+181 -91
View File
@@ -119,12 +119,10 @@ def test_upsert_creates_then_is_idempotent(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
first = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
assert isinstance(first, QuestInstanceRecord)
second = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
assert second.id == first.id
rows = await database.fetch_one(
"SELECT COUNT(*) FROM quest_instances"
@@ -138,27 +136,116 @@ def test_upsert_creates_then_is_idempotent(tmp_path) -> None:
def test_upsert_different_dates_create_distinct_rows(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
a = await instances.upsert(definition.id, child.id, D1, _now_stamp())
b = await instances.upsert(definition.id, child.id, D2, _now_stamp())
a = await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning")
b = await instances.upsert(definition.id, child.id, D2, _now_stamp(), window="morning")
assert a.id != b.id
return (a, b)
_with_db(tmp_path, "upsert-multi-date.db")(_body)
def test_upsert_twice_daily_fanout_two_windows_two_rows(tmp_path) -> None:
"""D-008 twice-daily case: one definition, one child, one date,
two windows — two rows, each idempotent on its own tuple."""
async def _body(database, children, rules, definitions, instances,
events, child, definition):
morning = await instances.upsert(
definition.id, child.id, D1, _now_stamp(), window="morning"
)
evening = await instances.upsert(
definition.id, child.id, D1, _now_stamp(), window="evening"
)
assert morning.id != evening.id
rows = await database.fetch_all(
"SELECT window, child_id, due_date FROM quest_instances "
"WHERE definition_id = ? AND due_date = ?",
(definition.id, D1),
)
assert sorted(rows) == [
("evening", child.id, D1),
("morning", child.id, D1),
]
# Re-running both is still idempotent.
await instances.upsert(
definition.id, child.id, D1, _now_stamp(), window="morning"
)
await instances.upsert(
definition.id, child.id, D1, _now_stamp(), window="evening"
)
count = await database.fetch_one(
"SELECT COUNT(*) FROM quest_instances"
)
assert count == (2,)
return (morning, evening)
_with_db(tmp_path, "upsert-twice-daily.db")(_body)
def test_upsert_shared_definition_three_assignees_three_rows(
tmp_path,
) -> None:
"""D-008 shared case: one definition, three assignees, one window
— three rows, one per child."""
async def _body(database, children, rules, definitions, instances,
events, child, definition):
second = await children.create("Bo", _now_stamp())
third = await children.create("Cleo", _now_stamp())
await definitions.add_assignee(definition.id, second.id)
await definitions.add_assignee(definition.id, third.id)
created = [
await instances.upsert(
definition.id, assignee.id, D1, _now_stamp(),
window="morning",
)
for assignee in (child, second, third)
]
assert len({row.id for row in created}) == 3
count = await database.fetch_one(
"SELECT COUNT(*) FROM quest_instances "
"WHERE definition_id = ? AND due_date = ? AND window = ?",
(definition.id, D1, "morning"),
)
assert count == (3,)
# Re-running the fan-out changes nothing.
for assignee in (child, second, third):
await instances.upsert(
definition.id, assignee.id, D1, _now_stamp(),
window="morning",
)
count = await database.fetch_one(
"SELECT COUNT(*) FROM quest_instances"
)
assert count == (3,)
return created
_with_db(tmp_path, "upsert-shared-fanout.db")(_body)
def test_upsert_window_outside_const_list_raises(tmp_path) -> None:
"""The DAO rejects a window spelling the schema CHECK would reject
anyway, with the typed ValueError instead of raw IntegrityError."""
async def _body(database, children, rules, definitions, instances,
events, child, definition):
with pytest.raises(ValueError, match="window must be one of"):
await instances.upsert(
definition.id, child.id, D1, _now_stamp(), window="noon"
)
return None
_with_db(tmp_path, "upsert-bad-window.db")(_body)
def test_upsert_refreshes_snapshot_columns_on_conflict(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
first = await instances.upsert(
definition.id, child.id, D1, _now_stamp(), due_time="08:00"
)
definition.id, child.id, D1, _now_stamp(), due_time="08:00", window="morning")
second = await instances.upsert(
definition.id,
child.id,
D1,
f"{D1}T13:00:00+00:00",
due_time="08:30",
)
due_time="08:30", window="morning")
assert second.id == first.id
assert second.due_time == "08:30"
assert second.generated_at == f"{D1}T13:00:00+00:00"
@@ -172,7 +259,7 @@ def test_upsert_rejects_wrong_child_for_definition(tmp_path) -> None:
events, child, definition):
other = await children.create("Bo", _now_stamp())
with pytest.raises(ValueError, match="assigned to"):
await instances.upsert(definition.id, other.id, D1, _now_stamp())
await instances.upsert(definition.id, other.id, D1, _now_stamp(), window="morning")
return None
_with_db(tmp_path, "upsert-wrong-child.db")(_body)
@@ -182,7 +269,7 @@ def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
with pytest.raises(ValueError, match="definition 999"):
await instances.upsert(999, child.id, D1, _now_stamp())
await instances.upsert(999, child.id, D1, _now_stamp(), window="morning")
# Unknown child: the definition-assignee check fires first and
# is the one that matters (an instance must carry an assigned
# child). Under D-008 the assignee check is a membership test
@@ -193,7 +280,7 @@ def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None:
"X", rule.id, _now_stamp(), assignee_child_ids=[child.id]
)
with pytest.raises(ValueError, match="not assigned to child 999"):
await instances.upsert(other_definition.id, 999, D1, _now_stamp())
await instances.upsert(other_definition.id, 999, D1, _now_stamp(), window="morning")
return None
_with_db(tmp_path, "upsert-unknown.db")(_body)
@@ -204,7 +291,7 @@ def test_upsert_malformed_due_date_raises(tmp_path) -> None:
events, child, definition):
for bad in ("not-a-date", "2026-9-15", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await instances.upsert(definition.id, child.id, bad, _now_stamp())
await instances.upsert(definition.id, child.id, bad, _now_stamp(), window="morning")
return None
_with_db(tmp_path, "upsert-bad-date.db")(_body)
@@ -216,11 +303,11 @@ def test_upsert_rejects_past_due_dates(tmp_path) -> None:
events, child, definition):
yesterday = _future_day(-1)
with pytest.raises(ValueError, match="never generated in the past"):
await instances.upsert(definition.id, child.id, yesterday, _now_stamp())
await instances.upsert(definition.id, child.id, yesterday, _now_stamp(), window="morning")
# Today itself is allowed (the rollover job generates today's
# instances before the day is over).
today = datetime.date.today().isoformat()
record = await instances.upsert(definition.id, child.id, today, _now_stamp())
record = await instances.upsert(definition.id, child.id, today, _now_stamp(), window="morning")
assert record.due_date == today
return record
@@ -296,7 +383,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
await asyncio.wait_for(holder_inside.wait(), timeout=5)
task = asyncio.ensure_future(
instances.upsert(definition.id, child.id, today, _now_stamp())
instances.upsert(definition.id, child.id, today, _now_stamp(), window="morning")
)
# A is now queued on the connection lock (it cannot pass
# its in-lock check while B holds the lock). Shift the
@@ -349,17 +436,15 @@ def test_upsert_refuses_immutable_completed_instance(tmp_path) -> None:
events, child, definition):
generated_at = _now_stamp()
instance = await instances.upsert(
definition.id, child.id, D1, generated_at, due_time="08:00"
)
definition.id, child.id, D1, generated_at, due_time="08:00", window="morning")
await events.append(
instance.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1",
)
with pytest.raises(ValueError, match="immutable"):
await instances.upsert(
definition.id, child.id, D1, _now_stamp(), due_time="09:00"
)
fetched = await instances.get(definition.id, D1)
definition.id, child.id, D1, _now_stamp(), due_time="09:00", window="morning")
fetched = await instances.get(definition.id, child.id, D1, "morning")
assert fetched.due_time == "08:00", (
"failed regeneration must not touch the completed instance"
)
@@ -378,12 +463,11 @@ def test_get_by_definition_and_date_round_trip(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
created = await instances.upsert(
definition.id, child.id, D1, _now_stamp(), due_time="17:30"
)
fetched = await instances.get(definition.id, D1)
definition.id, child.id, D1, _now_stamp(), due_time="17:30", window="morning")
fetched = await instances.get(definition.id, child.id, D1, "morning")
assert fetched == created
assert fetched.due_time == "17:30"
assert await instances.get(definition.id, D2) is None
assert await instances.get(definition.id, child.id, D2, "morning") is None
return fetched
_with_db(tmp_path, "get-round-trip.db")(_body)
@@ -392,7 +476,7 @@ def test_get_by_definition_and_date_round_trip(tmp_path) -> None:
def test_get_by_id_round_trip(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
created = await instances.upsert(definition.id, child.id, D1, _now_stamp())
created = await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning")
fetched = await instances.get_by_id(created.id)
assert fetched == created
assert await instances.get_by_id(999) is None
@@ -409,11 +493,10 @@ def test_list_by_child_and_date_filters(tmp_path) -> None:
other_definition = await definitions.create(
"Make bed", rule.id, _now_stamp(), assignee_child_ids=[other.id]
)
await instances.upsert(definition.id, child.id, D1, _now_stamp())
await instances.upsert(definition.id, child.id, D2, _now_stamp())
await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning")
await instances.upsert(definition.id, child.id, D2, _now_stamp(), window="morning")
await instances.upsert(
other_definition.id, other.id, D1, _now_stamp()
)
other_definition.id, other.id, D1, _now_stamp(), window="morning")
mine = await instances.list_by_child_and_date(child.id, D1)
assert len(mine) == 1
assert mine[0].child_id == child.id
@@ -427,7 +510,7 @@ def test_list_by_date_range_filters_and_orders(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
for day in (D1, D3, D5, D20):
await instances.upsert(definition.id, child.id, day, _now_stamp())
await instances.upsert(definition.id, child.id, day, _now_stamp(), window="morning")
found = await instances.list_by_date_range(
child.id, D2, D6
)
@@ -461,18 +544,17 @@ def test_list_by_date_range_rejects_inverted_range(tmp_path) -> None:
def test_delete_future_uncompleted_removes_only_eligible(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
await instances.upsert(definition.id, child.id, D1, _now_stamp())
await instances.upsert(definition.id, child.id, D1, _now_stamp(), window="morning")
future_open = await instances.upsert(
definition.id, child.id, D5, _now_stamp()
)
await instances.upsert(definition.id, child.id, D6, _now_stamp())
definition.id, child.id, D5, _now_stamp(), window="morning")
await instances.upsert(definition.id, child.id, D6, _now_stamp(), window="morning")
deleted = await instances.delete_future_uncompleted(
definition.id, D5
)
assert deleted == 2
assert await instances.get(definition.id, D1) is not None
assert await instances.get(definition.id, D5) is None
assert await instances.get(definition.id, D6) is None
assert await instances.get(definition.id, child.id, D1, "morning") is not None
assert await instances.get(definition.id, child.id, D5, "morning") is None
assert await instances.get(definition.id, child.id, D6, "morning") is None
return deleted
_with_db(tmp_path, "delete-future.db")(_body)
@@ -482,11 +564,9 @@ def test_delete_future_uncompleted_spares_completed(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
completed = await instances.upsert(
definition.id, child.id, D5, _now_stamp()
)
definition.id, child.id, D5, _now_stamp(), window="morning")
open_one = await instances.upsert(
definition.id, child.id, D6, _now_stamp()
)
definition.id, child.id, D6, _now_stamp(), window="morning")
await events.append(
completed.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1",
@@ -521,12 +601,12 @@ def test_delete_future_uncompleted_cannot_backdate_cutoff(
async def _body(database, children, rules, definitions, instances,
events, child, definition):
today = datetime.date.today().isoformat()
await instances.upsert(definition.id, child.id, today, _now_stamp())
await instances.upsert(definition.id, child.id, today, _now_stamp(), window="morning")
deleted = await instances.delete_future_uncompleted(
definition.id, "2020-01-01"
)
assert deleted == 1
assert await instances.get(definition.id, today) is None
assert await instances.get(definition.id, child.id, today, "morning") is None
return deleted
_with_db(tmp_path, "delete-no-backdate.db")(_body)
@@ -541,13 +621,13 @@ def test_delete_future_uncompleted_other_definitions_untouched(
"Make bed", (await rules.create("daily", D1)).id,
_now_stamp(), assignee_child_ids=[child.id],
)
await instances.upsert(definition.id, child.id, D20, _now_stamp())
await instances.upsert(other.id, child.id, D20, _now_stamp())
await instances.upsert(definition.id, child.id, D20, _now_stamp(), window="morning")
await instances.upsert(other.id, child.id, D20, _now_stamp(), window="morning")
deleted = await instances.delete_future_uncompleted(
definition.id, D20
)
assert deleted == 1
assert await instances.get(other.id, D20) is not None
assert await instances.get(other.id, child.id, D20, "morning") is not None
return deleted
_with_db(tmp_path, "delete-scoped.db")(_body)
@@ -562,8 +642,7 @@ def test_append_completion_round_trips(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
event = await events.append(
instance.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1",
@@ -582,10 +661,10 @@ def test_append_panel_event_round_trips(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
event = await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), False,
actor_child_id=child.id,
actor_user_id=None,
)
assert event.actor_source == "panel"
@@ -600,8 +679,7 @@ def test_append_uncompleted_with_optional_on_time(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
reversal = await events.append(
instance.id, child.id, "uncompleted", "user", _now_stamp(), None,
actor_user_id="user-1",
@@ -622,8 +700,7 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
with pytest.raises(ValueError, match="event_type"):
await events.append(
instance.id, child.id, "missed", "user", _now_stamp(), True,
@@ -642,17 +719,31 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
with pytest.raises(ValueError, match="must not carry"):
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id,
actor_user_id="user-1",
)
with pytest.raises(ValueError, match="requires actor_child_id"):
await events.append(
instance.id, child.id, "completed", "panel",
_now_stamp(), True,
)
with pytest.raises(ValueError, match="must not carry actor_child_id"):
await events.append(
instance.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1",
actor_child_id=child.id,
)
with pytest.raises(ValueError, match="requires was_on_time"):
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), None
instance.id, child.id, "completed", "panel", _now_stamp(), None,
actor_child_id=child.id
)
# was_on_time must be a real bool: int-convertible junk that
# would silently coerce (0.5 -> False) is rejected outright.
with pytest.raises(ValueError, match="must be True, False or None"):
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), 0.5
instance.id, child.id, "completed", "panel",
_now_stamp(), 0.5, actor_child_id=child.id,
)
with pytest.raises(ValueError, match="must be True, False or None"):
await events.append(
@@ -670,8 +761,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
for bad in (
"2026-09-14 12:00:00+00:00", # space separator
"2026-09-14T12:00:00", # no offset
@@ -683,7 +773,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None:
with pytest.raises(ValueError, match="UTC"):
await events.append(
instance.id, child.id, "completed", "panel", bad,
True,
True, actor_child_id=child.id,
)
return None
@@ -695,8 +785,7 @@ def test_append_requires_was_on_time_strict_bool(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
for bad in (0.5, 1.5, 2, "true", 1):
with pytest.raises(ValueError, match="must be True, False"):
await events.append(
@@ -717,8 +806,7 @@ def test_append_rejects_unknown_instance_or_child(tmp_path) -> None:
actor_user_id="user-1",
)
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
with pytest.raises(ValueError, match="belongs to child"):
await events.append(
instance.id, 999, "completed", "user", _now_stamp(), True,
@@ -740,17 +828,18 @@ def test_completed_uncompleted_recompleted_three_ordered_events(
async def _body(database, children, rules, definitions, instances,
events, child, definition):
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True
instance.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
await events.append(
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
actor_user_id="user-1",
)
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), False
instance.id, child.id, "completed", "panel", _now_stamp(), False,
actor_child_id=child.id
)
history = await events.list_by_instance(instance.id)
assert [e.event_type for e in history] == [
@@ -771,8 +860,7 @@ def test_get_latest_for_instance_none_when_untouched(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
return await events.get_latest_for_instance(instance.id)
assert _with_db(tmp_path, "latest-none.db")(_body) is None
@@ -782,10 +870,10 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True
instance.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
await events.append(
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
@@ -802,26 +890,26 @@ def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
early = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
inside = await instances.upsert(
definition.id, child.id, D2, _now_stamp()
)
definition.id, child.id, D2, _now_stamp(), window="morning")
late = await instances.upsert(
definition.id, child.id, D20, _now_stamp()
definition.id, child.id, D20, _now_stamp(), window="morning")
await events.append(
early.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
await events.append(
early.id, child.id, "completed", "panel", _now_stamp(), True
)
await events.append(
inside.id, child.id, "completed", "panel", _now_stamp(), False
inside.id, child.id, "completed", "panel", _now_stamp(), False,
actor_child_id=child.id
)
await events.append(
inside.id, child.id, "uncompleted", "user", _now_stamp(), True,
actor_user_id="user-1",
)
await events.append(
late.id, child.id, "completed", "panel", _now_stamp(), True
late.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
found = await events.list_by_child_and_date_range(
child.id, D2, D2
@@ -847,8 +935,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances,
events, child, definition):
late_due = await instances.upsert(
definition.id, child.id, D20, _now_stamp()
)
definition.id, child.id, D20, _now_stamp(), window="morning")
# The event's occurred_at is today at 08:00 UTC (_now_stamp);
# the instance is due D20. The due-date scope queried on D20
# still finds the event (scoping follows the instance's due
@@ -856,7 +943,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
# queried on today's date finds it too.
event = await events.append(
late_due.id, child.id, "completed", "panel",
_now_stamp(), False,
_now_stamp(), False, actor_child_id=child.id,
)
by_due = await events.list_by_child_and_date_range(
child.id, D20, D20
@@ -944,7 +1031,12 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None:
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
insert_exempt = {
"custom_components/nestquest/dao_instances.py",
"custom_components/nestquest/migrations.py", # the rebuild
# migrations re-create the table via INSERT..SELECT (append-
# order preserved); UPDATE/DELETE stays forbidden everywhere
"tests/test_schema.py",
"tests/test_migrations.py", # seeds frozen legacy shapes to
# exercise the rebuild migrations
"tests/test_dao_instances.py", # this guard file; scanned by
# its own guard's regex spans below instead
}
@@ -1028,8 +1120,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability(
database.execute = _recording
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
await events.append(
instance.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1",
@@ -1059,8 +1150,7 @@ def test_append_concurrent_events_serialize(tmp_path) -> None:
prepared
)
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp()
)
definition.id, child.id, D1, _now_stamp(), window="morning")
tasks = [
asyncio.ensure_future(
events.append(
+18 -11
View File
@@ -112,7 +112,8 @@ class World:
self.child.id, D1, D2, True, note="test"
)
self.instance = await self.instances.upsert(
self.definition.id, self.child.id, D1, _stamp()
self.definition.id, self.child.id, D1, _stamp(),
window="morning",
)
@@ -291,12 +292,15 @@ def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None:
def test_matrix_quest_instances_crud_and_constraints(tmp_path) -> None:
async def _body(w: World):
# insert (seeded) + read
fetched = await w.instances.get(w.definition.id, D1)
fetched = await w.instances.get(
w.definition.id, w.child.id, D1, "morning"
)
assert fetched == w.instance
# update path: conflict refresh on the OPEN instance — same row
# id, snapshot columns (due_time) actually rewritten.
refreshed = await w.instances.upsert(
w.definition.id, w.child.id, D1, _stamp(), due_time="17:00"
w.definition.id, w.child.id, D1, _stamp(),
window="morning", due_time="17:00",
)
assert refreshed.id == fetched.id, (
"the conflict path must refresh the existing row in place"
@@ -316,15 +320,15 @@ def test_matrix_quest_instances_constraints(tmp_path) -> None:
# proving the schema backs the DAO's idempotency.
await w.database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) "
"VALUES (?, ?, ?, NULL, ?)",
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, 'morning', ?, NULL, ?)",
(w.definition.id, w.child.id, D20, _stamp()),
)
with pytest.raises(sqlite3.IntegrityError):
await w.database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) "
"VALUES (?, ?, ?, NULL, ?)",
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, 'morning', ?, NULL, ?)",
(w.definition.id, w.child.id, D20, _stamp()),
)
# delete future uncompleted removes D20's open instance
@@ -340,10 +344,11 @@ def test_matrix_quest_instances_constraints(tmp_path) -> None:
def test_matrix_quest_instances_upsert_idempotency(tmp_path) -> None:
async def _body(w: World):
first = await w.instances.upsert(
w.definition.id, w.child.id, D2, _stamp()
w.definition.id, w.child.id, D2, _stamp(), window="morning"
)
second = await w.instances.upsert(
w.definition.id, w.child.id, D2, _stamp(), due_time="17:00"
w.definition.id, w.child.id, D2, _stamp(),
window="morning", due_time="17:00",
)
assert second.id == first.id
count = await w.database.fetch_one(
@@ -367,14 +372,16 @@ def test_matrix_completion_events_append_read_and_no_mutation(
async def _body(w: World):
# insert: append a completion then a reversal, then re-complete
await w.events.append(
w.instance.id, w.child.id, "completed", "panel", _stamp(), True
w.instance.id, w.child.id, "completed", "panel", _stamp(), True,
actor_child_id=w.child.id,
)
await w.events.append(
w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None,
actor_user_id="user-1",
)
await w.events.append(
w.instance.id, w.child.id, "completed", "panel", _stamp(), False
w.instance.id, w.child.id, "completed", "panel", _stamp(), False,
actor_child_id=w.child.id,
)
# read: full ordered history + latest
history = await w.events.list_by_instance(w.instance.id)
+1 -1
View File
@@ -927,7 +927,7 @@ def test_definition_assignment_leaves_history_rows_alone(
# this table, but its row shape proves assignment isolation).
await database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, generated_at) VALUES (?, ?, ?, ?)",
"window, due_date, generated_at) VALUES (?, ?, 'morning', ?, ?)",
(definition.id, child.id, "2026-09-15", NOW),
)
await definitions.add_assignee(definition.id, other.id)
+107 -8
View File
@@ -149,13 +149,17 @@ def test_migration_list_shape() -> None:
(D-007) to create the quest_* tables directly; migration 2 exists
for dev databases stamped version 1 by the pre-rename runner,
migration 3 brings pre-D-008 definitions to the multi-assignee
model, and migration 4 adds the windows table.
model, migration 4 adds the windows table, migration 5 rebuilds
quest_instances onto the widened (definition, child, date, window)
key, and migration 6 adds actor_child_id to completion_events.
"""
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert callable(MIGRATIONS[1])
assert callable(MIGRATIONS[2])
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
assert len(MIGRATIONS) == 4
assert callable(MIGRATIONS[4])
assert callable(MIGRATIONS[5])
assert len(MIGRATIONS) == 6
# ---------------------------------------------------------------------------
@@ -425,6 +429,35 @@ _V2_DEFINITIONS_DDL = [
""",
]
_V2_INSTANCES_DDL = [
"""
CREATE TABLE IF NOT EXISTS quest_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
due_date TEXT NOT NULL,
due_time TEXT,
generated_at TEXT NOT NULL,
UNIQUE (definition_id, due_date)
)
""",
]
_V2_EVENTS_DDL = [
"""
CREATE TABLE IF NOT EXISTS completion_events (
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,
actor_source TEXT NOT NULL,
actor_user_id TEXT,
occurred_at TEXT NOT NULL,
was_on_time INTEGER
)
""",
]
def _seed_v2_multi_assignee_upgrade(database) -> None:
"""Create a database shaped like a post-rename, pre-D-008 dev file.
@@ -433,11 +466,21 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
single child_id column and no assignees table exists — stamped at
version 2, with definition and instance rows that must survive.
"""
# Freeze the version-2 shape: definitions carry child_id, the
# instance table has no window column, and the assignees/windows
# tables do not exist yet — everything else matches the current DDL.
_v2_replaced = (
"quest_definitions ",
"quest_definition_assignees ",
"quest_definition_windows ",
"quest_instances ",
"completion_events ",
)
for sql in SCHEMA_V1_STATEMENTS:
if "quest_definitions " in sql:
continue # replaced by the legacy-shape DDL below
if any(name in sql for name in _v2_replaced):
continue
_run(database.execute(sql))
for sql in _V2_DEFINITIONS_DDL:
for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL + _V2_EVENTS_DDL:
_run(database.execute(sql))
_run(database.execute(VERSION_TABLE_DDL))
_run(
@@ -477,6 +520,26 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
"VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')"
)
)
# One legacy panel event and one legacy admin event: the panel one
# must backfill actor_child_id to its own child; the admin one must
# stay NULL.
_run(
database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, occurred_at, was_on_time) "
"VALUES (1, 2, 'completed', 'panel', "
"'2026-09-15T10:00:00+00:00', 1)"
)
)
_run(
database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, actor_user_id, occurred_at, "
"was_on_time) "
"VALUES (1, 2, 'uncompleted', 'user', 'parent-1', "
"'2026-09-15T11:00:00+00:00', 1)"
)
)
def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
@@ -506,14 +569,50 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
)
)
assert assignees == [(1, 2)]
# The instance survived, still referencing the definition.
# The instance survived the rebuild, pinned to the migrated
# window, still referencing the definition.
instance = _run(
database.fetch_one(
"SELECT definition_id, child_id, due_date "
"SELECT definition_id, child_id, window, due_date "
"FROM quest_instances WHERE id = 1"
)
)
assert instance == (1, 2, "2026-09-15")
assert instance == (1, 2, "morning", "2026-09-15")
# The rebuilt instance table carries the widened unique key.
index_rows = _run(
database.fetch_all(
"PRAGMA index_list(quest_instances)"
)
)
unique_columns = []
for index_row in index_rows:
if index_row[2]: # unique flag
info = _run(
database.fetch_all(
f"PRAGMA index_info('{index_row[1]}')"
)
)
unique_columns.append([col[2] for col in info])
assert [
"definition_id", "child_id", "due_date", "window"
] in unique_columns
# The completion events survived the actor_child_id rebuild:
# the panel row backfilled to its own child, the admin row
# stayed NULL, and ids are preserved.
events = _run(
database.fetch_all(
"SELECT id, event_type, actor_source, actor_child_id "
"FROM completion_events ORDER BY id"
)
)
assert events == [
(1, "completed", "panel", 2),
(2, "uncompleted", "user", None),
]
columns = _run(
database.fetch_all("PRAGMA table_info(completion_events)")
)
assert "actor_child_id" in {c[1] for c in columns}
# No dangling references anywhere.
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
# The rebuilt table is live for the new model: a definition
+152 -12
View File
@@ -1722,8 +1722,8 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None:
# ---------------------------------------------------------------------------
def _insert_instance(database, definition_id=1, **overrides):
"""Insert one valid task_instance, returning the instance id.
def _insert_instance(database, definition_id=1, window="morning", **overrides):
"""Insert one valid quest_instance, returning the instance id.
Pass ``_OMIT`` as a value to leave that column out of the INSERT so
its DDL DEFAULT applies.
@@ -1731,6 +1731,7 @@ def _insert_instance(database, definition_id=1, **overrides):
values: dict[str, object] = {
"definition_id": definition_id,
"child_id": 1,
"window": window,
"due_date": "2026-09-14",
"generated_at": "2026-09-14T00:00:00+00:00",
}
@@ -1829,6 +1830,7 @@ def test_quest_instances_columns_types_and_constraints(tmp_path) -> None:
("id", "INTEGER", 0, None, 1),
("definition_id", "INTEGER", 1, None, 0),
("child_id", "INTEGER", 1, None, 0),
("window", "TEXT", 1, None, 0),
("due_date", "TEXT", 1, None, 0),
("due_time", "TEXT", 0, None, 0),
("generated_at", "TEXT", 1, None, 0),
@@ -1849,6 +1851,7 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None:
("event_type", "TEXT", 1, None, 0),
("actor_source", "TEXT", 1, None, 0),
("actor_user_id", "TEXT", 0, None, 0),
("actor_child_id", "INTEGER", 0, None, 0),
("occurred_at", "TEXT", 1, None, 0),
("was_on_time", "INTEGER", 0, None, 0),
]
@@ -1879,12 +1882,13 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None:
assert declared == {
("quest_instances", "instance_id", "id"),
("children", "child_id", "id"),
("children", "actor_child_id", "id"),
}
finally:
_run(database.close())
def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None:
def test_quest_instances_unique_index_on_widened_key(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-unique.db")
try:
_apply(database)
@@ -1897,15 +1901,20 @@ def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None:
)
implicit = [(name, sql) for name, sql in rows if sql is None]
assert len(implicit) == 1, (
"the (definition_id, due_date) UNIQUE constraint must be backed "
"by exactly one implicit unique index"
"the (definition_id, child_id, due_date, window) UNIQUE "
"constraint must be backed by exactly one implicit unique index"
)
columns = _run(
database.fetch_all(
f"PRAGMA index_info('{implicit[0][0]}')"
)
)
assert [row[2] for row in columns] == ["definition_id", "due_date"]
assert [row[2] for row in columns] == [
"definition_id",
"child_id",
"due_date",
"window",
]
# The implicit index is genuinely unique: UNIQUE(...) is the only
# constraint SQLite backs with a sql-less autoindex on this table.
row = _run(
@@ -1932,12 +1941,16 @@ def test_quest_instances_valid_insert_round_trips(tmp_path) -> None:
instance_id = _insert_instance(database, definition_id=1, child_id=1)
row = _run(
database.fetch_one(
"SELECT definition_id, child_id, due_date, due_time, generated_at "
"SELECT definition_id, child_id, window, due_date, "
"due_time, generated_at "
"FROM quest_instances WHERE id = ?",
(instance_id,),
)
)
assert row == (1, 1, "2026-09-14", None, "2026-09-14T00:00:00+00:00")
assert row == (
1, 1, "morning", "2026-09-14", None,
"2026-09-14T00:00:00+00:00",
)
finally:
_run(database.close())
@@ -1961,7 +1974,7 @@ def test_quest_instances_due_time_round_trips(tmp_path) -> None:
_run(database.close())
def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None:
def test_quest_instances_duplicate_tuple_fails(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-duplicate.db")
try:
_apply(database)
@@ -1973,6 +1986,78 @@ def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None:
_run(database.close())
def test_quest_instances_duplicate_tuple_different_child_allowed(
tmp_path,
) -> None:
"""D-008: same definition, date and window for two different
assignees of the same definition are two distinct instances."""
database = _open_db(tmp_path / "task-instances-shared.db")
try:
_apply(database)
_setup_definition(database)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Bo", "2026-09-13T00:00:00+00:00"),
)
)
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (1, 2)"
)
)
_insert_instance(database, definition_id=1, child_id=1)
_insert_instance(database, definition_id=1, child_id=2)
row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances"))
assert row == (2,)
finally:
_run(database.close())
def test_quest_instances_duplicate_tuple_different_window_allowed(
tmp_path,
) -> None:
"""D-008 twice-daily case: one definition, one child, one date,
two windows — two rows."""
database = _open_db(tmp_path / "task-instances-twice-daily.db")
try:
_apply(database)
_setup_definition(database)
_insert_instance(database, definition_id=1, window="morning")
_insert_instance(database, definition_id=1, window="evening")
rows = _run(
database.fetch_all(
"SELECT window FROM quest_instances ORDER BY window"
)
)
assert [row[0] for row in rows] == ["evening", "morning"]
finally:
_run(database.close())
def test_quest_instances_unknown_window_fails(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-bad-window.db")
try:
_apply(database)
_setup_definition(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_instance(database, definition_id=1, window="noon")
finally:
_run(database.close())
def test_quest_instances_window_not_null_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-window-null.db")
try:
_apply(database)
_setup_definition(database)
with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"):
_insert_instance(database, definition_id=1, window=_OMIT)
finally:
_run(database.close())
def test_quest_instances_same_date_different_definitions_allowed(
tmp_path,
) -> None:
@@ -2099,16 +2184,71 @@ def test_completion_events_valid_panel_completion_round_trips(tmp_path) -> None:
instance_id=instance_id,
actor_source="panel",
actor_user_id=None,
actor_child_id=1,
was_on_time=0,
)
row = _run(
database.fetch_one(
"SELECT actor_source, actor_user_id, was_on_time "
"FROM completion_events WHERE id = ?",
"SELECT actor_source, actor_user_id, actor_child_id, "
"was_on_time FROM completion_events WHERE id = ?",
(event_id,),
)
)
assert row == ("panel", None, 0)
assert row == ("panel", None, 1, 0)
finally:
_run(database.close())
@pytest.mark.parametrize(
("actor_source", "actor_user_id", "actor_child_id"),
[
("user", "user-1", 1), # admin event must not carry a profile
("panel", None, None), # panel event requires the profile
("panel", "user-1", 1), # panel must not carry a user id either
("panel", "user-1", None),
("user", None, None),
],
)
def test_completion_events_actor_child_coherence_enforced(
tmp_path, actor_source, actor_user_id, actor_child_id
) -> None:
"""D-008 actor-pair shape: 'user' events carry actor_user_id and no
actor_child_id; 'panel' events carry actor_child_id and no
actor_user_id. The CHECKs make both halves non-negotiable."""
database = _open_db(tmp_path / "completion-events-coherence.db")
try:
_apply(database)
_setup_definition(database)
instance_id = _insert_instance(database, definition_id=1)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_event(
database,
instance_id=instance_id,
event_type="completed",
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=1,
)
finally:
_run(database.close())
def test_completion_events_actor_child_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "completion-events-bad-actor-child.db")
try:
_apply(database)
_setup_definition(database)
instance_id = _insert_instance(database, definition_id=1)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_insert_event(
database,
instance_id=instance_id,
actor_source="panel",
actor_user_id=None,
actor_child_id=999,
was_on_time=1,
)
finally:
_run(database.close())