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.
This commit is contained in:
Joshua King
2026-09-15 10:17:31 -04:00
parent ebc2064222
commit 25f1479444
8 changed files with 479 additions and 173 deletions
+51 -35
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. mutation SQL for this table out of every other module.
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
(definition_id, due_date) — re-running materialization for a date (definition_id, child_id, due_date, window) — re-running
whose instance already exists updates nothing that matters materialization for a tuple whose instance already exists refreshes
(child_id/due_time are refreshed only in the degenerate re-generation only the generation-time snapshot columns and never duplicates a row.
case where they changed) and never duplicates a row. The schema's The schema's UNIQUE(definition_id, child_id, due_date, window) is the
UNIQUE(definition_id, due_date) is the last line of defense. The last line of defense. The upsert refuses a child that is not an
upsert refuses a child that is not an assignee of the definition assignee of the definition and a window outside ``const.QUEST_WINDOWS``
(D-008 multi-assignee model). (D-008 multi-assignee, multi-window model).
``delete_future_uncompleted`` implements the Feature 06/07 rule that ``delete_future_uncompleted`` implements the Feature 06/07 rule that
reassignment and schedule edits regenerate future instances: it reassignment and schedule edits regenerate future instances: it
@@ -37,7 +37,7 @@ import datetime
from dataclasses import dataclass from dataclasses import dataclass
from .dao_children import _connection_lock from .dao_children import _connection_lock
from .dao_rules import _validate_date from .dao_rules import _validate_date, _validate_window
from .db import NestQuestDatabase from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC #: Timestamp policy for completion_events.occurred_at: strict UTC
@@ -90,6 +90,7 @@ class QuestInstanceRecord:
id: int id: int
definition_id: int definition_id: int
child_id: int child_id: int
window: str
due_date: str due_date: str
due_time: str | None due_time: str | None
generated_at: str generated_at: str
@@ -110,7 +111,7 @@ class CompletionEventRecord:
_INSTANCE_COLUMNS = ( _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 = ( _EVENT_COLUMNS = (
"id, instance_id, child_id, event_type, actor_source, actor_user_id, " "id, instance_id, child_id, event_type, actor_source, actor_user_id, "
@@ -128,9 +129,10 @@ def _instance_from_row(row: tuple) -> QuestInstanceRecord:
id=row[0], id=row[0],
definition_id=row[1], definition_id=row[1],
child_id=row[2], child_id=row[2],
due_date=row[3], window=row[3],
due_time=row[4], due_date=row[4],
generated_at=row[5], due_time=row[5],
generated_at=row[6],
) )
@@ -160,31 +162,40 @@ class QuestInstancesDao:
due_date: str, due_date: str,
generated_at: str, generated_at: str,
*, *,
window: str,
due_time: str | None = None, due_time: str | None = None,
) -> QuestInstanceRecord: ) -> 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 The instance key is (definition_id, child_id, due_date, window)
yields ONE row: the ON CONFLICT path refreshes the (D-008): running materialization twice for the same tuple
generation-time snapshot columns (child_id, due_time) and yields ONE row — the ON CONFLICT path refreshes the
regenerates the stamp, but can never create a duplicate — the generation-time snapshot columns (due_time) and regenerates the
schema UNIQUE(definition_id, due_date) backs this up. 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: Guardrails enforced here:
- ``due_date`` must be TODAY or later: instances are never - ``due_date`` must be TODAY or later: instances are never
generated in the past (Feature 07). The caller's generated in the past (Feature 07). The caller's
materialization horizon is future-dated by definition. materialization horizon is future-dated by definition.
- ``window`` must be one of ``const.QUEST_WINDOWS``.
- An existing instance WITH a completion event is IMMUTABLE: - An existing instance WITH a completion event is IMMUTABLE:
the conflict path must not rewrite its snapshot columns the conflict path must not rewrite its snapshot columns
(Feature 07/08). The upsert refuses with ValueError in that (Feature 07/08). The upsert refuses with ValueError in that
case — silently no-oping would mask a materialization bug. 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 The definition must exist (validated atomically under the
connection lock so a concurrent definition deactivation or connection lock so a concurrent definition deactivation or
child deletion cannot slip between check and insert). child deletion cannot slip between check and insert).
""" """
_validate_date(due_date, "due_date") _validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates BEFORE queuing on the # Fail fast on obviously-past dates BEFORE queuing on the
# connection lock (a nice early error); the AUTHORITATIVE # connection lock (a nice early error); the AUTHORITATIVE
# check runs after the lock is acquired, because a caller can # check runs after the lock is acquired, because a caller can
@@ -227,51 +238,56 @@ class QuestInstancesDao:
f"assigned to child {child_id}; instance must " f"assigned to child {child_id}; instance must "
"carry an assigned child" "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( existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events " "SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances " "WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND due_date = ?)", "WHERE definition_id = ? AND child_id = ? "
(definition_id, due_date), "AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
) )
if existing is not None: if existing is not None:
raise ValueError( raise ValueError(
f"instance for (definition {definition_id}, " 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" "event and is immutable; it cannot be regenerated"
) )
await self._database.execute( await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, " "INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) " "window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?) " "VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, due_date) DO UPDATE SET " "ON CONFLICT (definition_id, child_id, due_date, "
"child_id = excluded.child_id, " "window) DO UPDATE SET "
"due_time = excluded.due_time, " "due_time = excluded.due_time, "
"generated_at = excluded.generated_at", "generated_at = excluded.generated_at",
( (
definition_id, definition_id,
child_id, child_id,
window,
due_date, due_date,
due_time, due_time,
generated_at, 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 assert instance is not None
return instance return instance
async def get( async def get(
self, definition_id: int, due_date: str self,
definition_id: int,
child_id: int,
due_date: str,
window: str,
) -> QuestInstanceRecord | None: ) -> 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( row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances " f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE definition_id = ? AND due_date = ?", "WHERE definition_id = ? AND child_id = ? AND due_date = ? "
(definition_id, due_date), "AND window = ?",
(definition_id, child_id, due_date, window),
) )
return _instance_from_row(row) if row is not None else None return _instance_from_row(row) if row is not None else None
+82 -3
View File
@@ -24,9 +24,12 @@ names before anything else assumes the new model. Migration 3 brings
pre-D-008 databases to the multi-assignee model: it creates pre-D-008 databases to the multi-assignee model: it creates
``quest_definition_assignees`` and, when ``quest_definitions`` still ``quest_definition_assignees`` and, when ``quest_definitions`` still
carries a single ``child_id`` column, rebuilds the table without it, carries a single ``child_id`` column, rebuilds the table without it,
copying each definition's assignee across. Each callable migration copying each definition's assignee across. Migration 4 adds the
runs inside its own transaction together with its version stamp, so a windows table and migration 5 rebuilds ``quest_instances`` onto the
crash mid-step rolls the statements and the stamp back together. widened (definition_id, child_id, due_date, window) key. 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 Migration entries are either an ordered sequence of SQL statements or
an async callable taking ``(database, target_version)``. Callables an async callable taking ``(database, target_version)``. Callables
@@ -71,6 +74,7 @@ from typing import Union
from .db import NestQuestDatabase from .db import NestQuestDatabase
from .schema import ( from .schema import (
QUEST_DEFINITIONS_TABLE_SQL, QUEST_DEFINITIONS_TABLE_SQL,
QUEST_INSTANCES_TABLE_SQL,
SCHEMA_V1_STATEMENTS, SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
@@ -147,6 +151,14 @@ async def _table_has_column(
return any(row[1] == column for row in rows) 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( async def _rename_legacy_task_tables(
database: NestQuestDatabase, target_version: int database: NestQuestDatabase, target_version: int
) -> None: ) -> None:
@@ -250,6 +262,70 @@ async def _add_definition_assignees(
await database.execute("PRAGMA foreign_keys = ON") 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")
#: The ordered migration list. Append-only: never edit an applied #: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. (Migration 1's content was #: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.) #: rewritten pre-release per D-007 — no version 1 database shipped.)
@@ -261,6 +337,9 @@ MIGRATIONS: Sequence[MigrationStep] = [
# CREATE IF NOT EXISTS suffices for both fresh databases (which # CREATE IF NOT EXISTS suffices for both fresh databases (which
# already made it in migration 1) and pre-window databases. # already made it in migration 1) and pre-window databases.
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL), 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,
] ]
+42 -28
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) in ``completion_events`` (Feature 08), so materialization (Feature 07)
can insert instances without knowing anything about completion and the can insert instances without knowing anything about completion and the
append-only log stays the single source of truth. ``definition_id``, append-only log stays the single source of truth. ``definition_id``,
``due_date`` and ``child_id`` snapshot the materialization-time facts ``child_id``, ``due_date`` and ``window`` snapshot the
("why this instance exists"): reassignment and rule edits change future materialization-time facts ("why this instance exists"): assignment
instances only and never rewrite these rows. ``due_time`` snapshots edits and rule edits change future instances only and never rewrite
the definition's optional due time at generation for the same reason — these rows. ``window`` records which declared day window produced the
the instance keeps its generation-time value even if the definition is instance (D-008), CHECK-constrained to the same three spellings
edited later. ``generated_at`` is a caller-set UTC ISO-8601 timestamp ``const`` owns. ``due_time`` snapshots the window's optional due time
per the module timestamp policy (no DB default, so a generation batch at generation for the same reason — the instance keeps its
stamps one coherent time). 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 Uniqueness: the (definition_id, child_id, due_date, window) tuple is
table-level ``UNIQUE`` constraint rather than a separate ``CREATE declared as a table-level ``UNIQUE`` constraint rather than a separate
UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique ``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an
index (``sqlite_autoindex_quest_instances_1``) — the required "unique implicit unique index (``sqlite_autoindex_quest_instances_1``) — the
index prevents duplicate instances" — and the constraint form cannot be required "unique index prevents duplicate instances" — and the
silently dropped the way a standalone index can, so the duplicate door constraint form cannot be silently dropped the way a standalone index
stays shut. Idempotent materialization keys on exactly this pair. No can, so the duplicate door stays shut. Idempotent materialization keys
secondary indexes beyond it are declared yet: history and board queries on exactly this tuple: the twice-daily case (one definition, two
(Features 08/10/13) should justify their indexes as a later migration windows, one child, one date) yields two rows and the shared case (one
once their query shapes exist. 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 No ``ON DELETE`` clauses are declared on any foreign key in this
module: children and definitions are deactivated, never deleted, so a module: children and definitions are deactivated, never deleted, so a
@@ -316,17 +322,25 @@ 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] = [ SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
""" f"""
CREATE TABLE IF NOT EXISTS quest_instances ( CREATE TABLE IF NOT EXISTS quest_instances {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),
due_date TEXT NOT NULL,
due_time TEXT,
generated_at TEXT NOT NULL,
UNIQUE (definition_id, due_date)
)
""", """,
] ]
+144 -80
View File
@@ -119,12 +119,10 @@ def test_upsert_creates_then_is_idempotent(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
first = await instances.upsert( first = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp(), window="morning")
)
assert isinstance(first, QuestInstanceRecord) assert isinstance(first, QuestInstanceRecord)
second = await instances.upsert( 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 assert second.id == first.id
rows = await database.fetch_one( rows = await database.fetch_one(
"SELECT COUNT(*) FROM quest_instances" "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: def test_upsert_different_dates_create_distinct_rows(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
a = await instances.upsert(definition.id, child.id, D1, _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()) b = await instances.upsert(definition.id, child.id, D2, _now_stamp(), window="morning")
assert a.id != b.id assert a.id != b.id
return (a, b) return (a, b)
_with_db(tmp_path, "upsert-multi-date.db")(_body) _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: def test_upsert_refreshes_snapshot_columns_on_conflict(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
first = await instances.upsert( 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( second = await instances.upsert(
definition.id, definition.id,
child.id, child.id,
D1, D1,
f"{D1}T13:00:00+00:00", f"{D1}T13:00:00+00:00",
due_time="08:30", due_time="08:30", window="morning")
)
assert second.id == first.id assert second.id == first.id
assert second.due_time == "08:30" assert second.due_time == "08:30"
assert second.generated_at == f"{D1}T13:00:00+00:00" 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): events, child, definition):
other = await children.create("Bo", _now_stamp()) other = await children.create("Bo", _now_stamp())
with pytest.raises(ValueError, match="assigned to"): 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 return None
_with_db(tmp_path, "upsert-wrong-child.db")(_body) _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, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
with pytest.raises(ValueError, match="definition 999"): 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 # Unknown child: the definition-assignee check fires first and
# is the one that matters (an instance must carry an assigned # is the one that matters (an instance must carry an assigned
# child). Under D-008 the assignee check is a membership test # 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] "X", rule.id, _now_stamp(), assignee_child_ids=[child.id]
) )
with pytest.raises(ValueError, match="not assigned to child 999"): 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 return None
_with_db(tmp_path, "upsert-unknown.db")(_body) _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): events, child, definition):
for bad in ("not-a-date", "2026-9-15", ""): for bad in ("not-a-date", "2026-9-15", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"): 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 return None
_with_db(tmp_path, "upsert-bad-date.db")(_body) _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): events, child, definition):
yesterday = _future_day(-1) yesterday = _future_day(-1)
with pytest.raises(ValueError, match="never generated in the past"): 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 # Today itself is allowed (the rollover job generates today's
# instances before the day is over). # instances before the day is over).
today = datetime.date.today().isoformat() 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 assert record.due_date == today
return record 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) await asyncio.wait_for(holder_inside.wait(), timeout=5)
task = asyncio.ensure_future( 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 # A is now queued on the connection lock (it cannot pass
# its in-lock check while B holds the lock). Shift the # 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): events, child, definition):
generated_at = _now_stamp() generated_at = _now_stamp()
instance = await instances.upsert( 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( await events.append(
instance.id, child.id, "completed", "user", _now_stamp(), True, instance.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1", actor_user_id="user-1",
) )
with pytest.raises(ValueError, match="immutable"): with pytest.raises(ValueError, match="immutable"):
await instances.upsert( await instances.upsert(
definition.id, child.id, D1, _now_stamp(), due_time="09:00" definition.id, child.id, D1, _now_stamp(), due_time="09:00", window="morning")
) fetched = await instances.get(definition.id, child.id, D1, "morning")
fetched = await instances.get(definition.id, D1)
assert fetched.due_time == "08:00", ( assert fetched.due_time == "08:00", (
"failed regeneration must not touch the completed instance" "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, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
created = await instances.upsert( created = await instances.upsert(
definition.id, child.id, D1, _now_stamp(), due_time="17:30" definition.id, child.id, D1, _now_stamp(), due_time="17:30", window="morning")
) fetched = await instances.get(definition.id, child.id, D1, "morning")
fetched = await instances.get(definition.id, D1)
assert fetched == created assert fetched == created
assert fetched.due_time == "17:30" 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 return fetched
_with_db(tmp_path, "get-round-trip.db")(_body) _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: def test_get_by_id_round_trip(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): 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) fetched = await instances.get_by_id(created.id)
assert fetched == created assert fetched == created
assert await instances.get_by_id(999) is None 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( other_definition = await definitions.create(
"Make bed", rule.id, _now_stamp(), assignee_child_ids=[other.id] "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, D1, _now_stamp(), window="morning")
await instances.upsert(definition.id, child.id, D2, _now_stamp()) await instances.upsert(definition.id, child.id, D2, _now_stamp(), window="morning")
await instances.upsert( 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) mine = await instances.list_by_child_and_date(child.id, D1)
assert len(mine) == 1 assert len(mine) == 1
assert mine[0].child_id == child.id 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, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
for day in (D1, D3, D5, D20): 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( found = await instances.list_by_date_range(
child.id, D2, D6 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: def test_delete_future_uncompleted_removes_only_eligible(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): 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( future_open = await instances.upsert(
definition.id, child.id, D5, _now_stamp() definition.id, child.id, D5, _now_stamp(), window="morning")
) await instances.upsert(definition.id, child.id, D6, _now_stamp(), window="morning")
await instances.upsert(definition.id, child.id, D6, _now_stamp())
deleted = await instances.delete_future_uncompleted( deleted = await instances.delete_future_uncompleted(
definition.id, D5 definition.id, D5
) )
assert deleted == 2 assert deleted == 2
assert await instances.get(definition.id, D1) is not None assert await instances.get(definition.id, child.id, D1, "morning") is not None
assert await instances.get(definition.id, D5) is None assert await instances.get(definition.id, child.id, D5, "morning") is None
assert await instances.get(definition.id, D6) is None assert await instances.get(definition.id, child.id, D6, "morning") is None
return deleted return deleted
_with_db(tmp_path, "delete-future.db")(_body) _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, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
completed = await instances.upsert( 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( open_one = await instances.upsert(
definition.id, child.id, D6, _now_stamp() definition.id, child.id, D6, _now_stamp(), window="morning")
)
await events.append( await events.append(
completed.id, child.id, "completed", "user", _now_stamp(), True, completed.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1", 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, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
today = datetime.date.today().isoformat() 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( deleted = await instances.delete_future_uncompleted(
definition.id, "2020-01-01" definition.id, "2020-01-01"
) )
assert deleted == 1 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 return deleted
_with_db(tmp_path, "delete-no-backdate.db")(_body) _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, "Make bed", (await rules.create("daily", D1)).id,
_now_stamp(), assignee_child_ids=[child.id], _now_stamp(), assignee_child_ids=[child.id],
) )
await instances.upsert(definition.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()) await instances.upsert(other.id, child.id, D20, _now_stamp(), window="morning")
deleted = await instances.delete_future_uncompleted( deleted = await instances.delete_future_uncompleted(
definition.id, D20 definition.id, D20
) )
assert deleted == 1 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 return deleted
_with_db(tmp_path, "delete-scoped.db")(_body) _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, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
instance = await instances.upsert( instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp(), window="morning")
)
event = await events.append( event = await events.append(
instance.id, child.id, "completed", "user", _now_stamp(), True, instance.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1", actor_user_id="user-1",
@@ -582,8 +661,7 @@ def test_append_panel_event_round_trips(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
instance = await instances.upsert( instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp(), window="morning")
)
event = await events.append( event = await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), False, instance.id, child.id, "completed", "panel", _now_stamp(), False,
actor_user_id=None, actor_user_id=None,
@@ -600,8 +678,7 @@ def test_append_uncompleted_with_optional_on_time(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
instance = await instances.upsert( instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp(), window="morning")
)
reversal = await events.append( reversal = await events.append(
instance.id, child.id, "uncompleted", "user", _now_stamp(), None, instance.id, child.id, "uncompleted", "user", _now_stamp(), None,
actor_user_id="user-1", actor_user_id="user-1",
@@ -622,8 +699,7 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
instance = await instances.upsert( 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"): with pytest.raises(ValueError, match="event_type"):
await events.append( await events.append(
instance.id, child.id, "missed", "user", _now_stamp(), True, instance.id, child.id, "missed", "user", _now_stamp(), True,
@@ -670,8 +746,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
instance = await instances.upsert( instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp(), window="morning")
)
for bad in ( for bad in (
"2026-09-14 12:00:00+00:00", # space separator "2026-09-14 12:00:00+00:00", # space separator
"2026-09-14T12:00:00", # no offset "2026-09-14T12:00:00", # no offset
@@ -695,8 +770,7 @@ def test_append_requires_was_on_time_strict_bool(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
instance = await instances.upsert( 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): for bad in (0.5, 1.5, 2, "true", 1):
with pytest.raises(ValueError, match="must be True, False"): with pytest.raises(ValueError, match="must be True, False"):
await events.append( await events.append(
@@ -717,8 +791,7 @@ def test_append_rejects_unknown_instance_or_child(tmp_path) -> None:
actor_user_id="user-1", actor_user_id="user-1",
) )
instance = await instances.upsert( 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"): with pytest.raises(ValueError, match="belongs to child"):
await events.append( await events.append(
instance.id, 999, "completed", "user", _now_stamp(), True, instance.id, 999, "completed", "user", _now_stamp(), True,
@@ -740,8 +813,7 @@ def test_completed_uncompleted_recompleted_three_ordered_events(
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
instance = await instances.upsert( instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp(), window="morning")
)
await events.append( await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True instance.id, child.id, "completed", "panel", _now_stamp(), True
) )
@@ -771,8 +843,7 @@ def test_get_latest_for_instance_none_when_untouched(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
instance = await instances.upsert( 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) return await events.get_latest_for_instance(instance.id)
assert _with_db(tmp_path, "latest-none.db")(_body) is None assert _with_db(tmp_path, "latest-none.db")(_body) is None
@@ -782,8 +853,7 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
instance = await instances.upsert( instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp(), window="morning")
)
await events.append( await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True instance.id, child.id, "completed", "panel", _now_stamp(), True
) )
@@ -802,14 +872,11 @@ def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
early = await instances.upsert( early = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp(), window="morning")
)
inside = await instances.upsert( inside = await instances.upsert(
definition.id, child.id, D2, _now_stamp() definition.id, child.id, D2, _now_stamp(), window="morning")
)
late = await instances.upsert( late = await instances.upsert(
definition.id, child.id, D20, _now_stamp() definition.id, child.id, D20, _now_stamp(), window="morning")
)
await events.append( await events.append(
early.id, child.id, "completed", "panel", _now_stamp(), True early.id, child.id, "completed", "panel", _now_stamp(), True
) )
@@ -847,8 +914,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
async def _body(database, children, rules, definitions, instances, async def _body(database, children, rules, definitions, instances,
events, child, definition): events, child, definition):
late_due = await instances.upsert( 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 event's occurred_at is today at 08:00 UTC (_now_stamp);
# the instance is due D20. The due-date scope queried on D20 # the instance is due D20. The due-date scope queried on D20
# still finds the event (scoping follows the instance's due # still finds the event (scoping follows the instance's due
@@ -1028,8 +1094,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability(
database.execute = _recording database.execute = _recording
instance = await instances.upsert( instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp(), window="morning")
)
await events.append( await events.append(
instance.id, child.id, "completed", "user", _now_stamp(), True, instance.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1", actor_user_id="user-1",
@@ -1059,8 +1124,7 @@ def test_append_concurrent_events_serialize(tmp_path) -> None:
prepared prepared
) )
instance = await instances.upsert( instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp() definition.id, child.id, D1, _now_stamp(), window="morning")
)
tasks = [ tasks = [
asyncio.ensure_future( asyncio.ensure_future(
events.append( events.append(
+14 -9
View File
@@ -112,7 +112,8 @@ class World:
self.child.id, D1, D2, True, note="test" self.child.id, D1, D2, True, note="test"
) )
self.instance = await self.instances.upsert( 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: def test_matrix_quest_instances_crud_and_constraints(tmp_path) -> None:
async def _body(w: World): async def _body(w: World):
# insert (seeded) + read # 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 assert fetched == w.instance
# update path: conflict refresh on the OPEN instance — same row # update path: conflict refresh on the OPEN instance — same row
# id, snapshot columns (due_time) actually rewritten. # id, snapshot columns (due_time) actually rewritten.
refreshed = await w.instances.upsert( 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, ( assert refreshed.id == fetched.id, (
"the conflict path must refresh the existing row in place" "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. # proving the schema backs the DAO's idempotency.
await w.database.execute( await w.database.execute(
"INSERT INTO quest_instances (definition_id, child_id, " "INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) " "window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, NULL, ?)", "VALUES (?, ?, 'morning', ?, NULL, ?)",
(w.definition.id, w.child.id, D20, _stamp()), (w.definition.id, w.child.id, D20, _stamp()),
) )
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
await w.database.execute( await w.database.execute(
"INSERT INTO quest_instances (definition_id, child_id, " "INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) " "window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, NULL, ?)", "VALUES (?, ?, 'morning', ?, NULL, ?)",
(w.definition.id, w.child.id, D20, _stamp()), (w.definition.id, w.child.id, D20, _stamp()),
) )
# delete future uncompleted removes D20's open instance # 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: def test_matrix_quest_instances_upsert_idempotency(tmp_path) -> None:
async def _body(w: World): async def _body(w: World):
first = await w.instances.upsert( 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( 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 assert second.id == first.id
count = await w.database.fetch_one( count = await w.database.fetch_one(
+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). # this table, but its row shape proves assignment isolation).
await database.execute( await database.execute(
"INSERT INTO quest_instances (definition_id, child_id, " "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), (definition.id, child.id, "2026-09-15", NOW),
) )
await definitions.add_assignee(definition.id, other.id) await definitions.add_assignee(definition.id, other.id)
+53 -8
View File
@@ -149,13 +149,16 @@ def test_migration_list_shape() -> None:
(D-007) to create the quest_* tables directly; migration 2 exists (D-007) to create the quest_* tables directly; migration 2 exists
for dev databases stamped version 1 by the pre-rename runner, for dev databases stamped version 1 by the pre-rename runner,
migration 3 brings pre-D-008 definitions to the multi-assignee 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, and migration 5 rebuilds
quest_instances onto the widened (definition, child, date, window)
key.
""" """
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert callable(MIGRATIONS[1]) assert callable(MIGRATIONS[1])
assert callable(MIGRATIONS[2]) assert callable(MIGRATIONS[2])
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
assert len(MIGRATIONS) == 4 assert callable(MIGRATIONS[4])
assert len(MIGRATIONS) == 5
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -425,6 +428,20 @@ _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)
)
""",
]
def _seed_v2_multi_assignee_upgrade(database) -> None: def _seed_v2_multi_assignee_upgrade(database) -> None:
"""Create a database shaped like a post-rename, pre-D-008 dev file. """Create a database shaped like a post-rename, pre-D-008 dev file.
@@ -433,11 +450,20 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
single child_id column and no assignees table exists — stamped at single child_id column and no assignees table exists — stamped at
version 2, with definition and instance rows that must survive. 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 ",
)
for sql in SCHEMA_V1_STATEMENTS: for sql in SCHEMA_V1_STATEMENTS:
if "quest_definitions " in sql: if any(name in sql for name in _v2_replaced):
continue # replaced by the legacy-shape DDL below continue
_run(database.execute(sql)) _run(database.execute(sql))
for sql in _V2_DEFINITIONS_DDL: for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL:
_run(database.execute(sql)) _run(database.execute(sql))
_run(database.execute(VERSION_TABLE_DDL)) _run(database.execute(VERSION_TABLE_DDL))
_run( _run(
@@ -506,14 +532,33 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
) )
) )
assert assignees == [(1, 2)] 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( instance = _run(
database.fetch_one( database.fetch_one(
"SELECT definition_id, child_id, due_date " "SELECT definition_id, child_id, window, due_date "
"FROM quest_instances WHERE id = 1" "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
# No dangling references anywhere. # No dangling references anywhere.
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == [] assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
# The rebuilt table is live for the new model: a definition # The rebuilt table is live for the new model: a definition
+92 -9
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): def _insert_instance(database, definition_id=1, window="morning", **overrides):
"""Insert one valid task_instance, returning the instance id. """Insert one valid quest_instance, returning the instance id.
Pass ``_OMIT`` as a value to leave that column out of the INSERT so Pass ``_OMIT`` as a value to leave that column out of the INSERT so
its DDL DEFAULT applies. its DDL DEFAULT applies.
@@ -1731,6 +1731,7 @@ def _insert_instance(database, definition_id=1, **overrides):
values: dict[str, object] = { values: dict[str, object] = {
"definition_id": definition_id, "definition_id": definition_id,
"child_id": 1, "child_id": 1,
"window": window,
"due_date": "2026-09-14", "due_date": "2026-09-14",
"generated_at": "2026-09-14T00:00:00+00:00", "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), ("id", "INTEGER", 0, None, 1),
("definition_id", "INTEGER", 1, None, 0), ("definition_id", "INTEGER", 1, None, 0),
("child_id", "INTEGER", 1, None, 0), ("child_id", "INTEGER", 1, None, 0),
("window", "TEXT", 1, None, 0),
("due_date", "TEXT", 1, None, 0), ("due_date", "TEXT", 1, None, 0),
("due_time", "TEXT", 0, None, 0), ("due_time", "TEXT", 0, None, 0),
("generated_at", "TEXT", 1, None, 0), ("generated_at", "TEXT", 1, None, 0),
@@ -1884,7 +1886,7 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None:
_run(database.close()) _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") database = _open_db(tmp_path / "task-instances-unique.db")
try: try:
_apply(database) _apply(database)
@@ -1897,15 +1899,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] implicit = [(name, sql) for name, sql in rows if sql is None]
assert len(implicit) == 1, ( assert len(implicit) == 1, (
"the (definition_id, due_date) UNIQUE constraint must be backed " "the (definition_id, child_id, due_date, window) UNIQUE "
"by exactly one implicit unique index" "constraint must be backed by exactly one implicit unique index"
) )
columns = _run( columns = _run(
database.fetch_all( database.fetch_all(
f"PRAGMA index_info('{implicit[0][0]}')" 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 # The implicit index is genuinely unique: UNIQUE(...) is the only
# constraint SQLite backs with a sql-less autoindex on this table. # constraint SQLite backs with a sql-less autoindex on this table.
row = _run( row = _run(
@@ -1932,12 +1939,16 @@ def test_quest_instances_valid_insert_round_trips(tmp_path) -> None:
instance_id = _insert_instance(database, definition_id=1, child_id=1) instance_id = _insert_instance(database, definition_id=1, child_id=1)
row = _run( row = _run(
database.fetch_one( 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 = ?", "FROM quest_instances WHERE id = ?",
(instance_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: finally:
_run(database.close()) _run(database.close())
@@ -1961,7 +1972,7 @@ def test_quest_instances_due_time_round_trips(tmp_path) -> None:
_run(database.close()) _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") database = _open_db(tmp_path / "task-instances-duplicate.db")
try: try:
_apply(database) _apply(database)
@@ -1973,6 +1984,78 @@ def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None:
_run(database.close()) _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( def test_quest_instances_same_date_different_definitions_allowed(
tmp_path, tmp_path,
) -> None: ) -> None: