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
+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,
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,8 +661,7 @@ 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_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,
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 +699,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,
@@ -670,8 +746,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
@@ -695,8 +770,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 +791,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,8 +813,7 @@ 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
)
@@ -771,8 +843,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,8 +853,7 @@ 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
)
@@ -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,
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
)
@@ -847,8 +914,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
@@ -1028,8 +1094,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 +1124,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(