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
+14 -9
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(