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
+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
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, and migration 5 rebuilds
quest_instances onto the widened (definition, child, date, window)
key.
"""
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 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:
"""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
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:
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:
_run(database.execute(sql))
_run(database.execute(VERSION_TABLE_DDL))
_run(
@@ -506,14 +532,33 @@ 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
# No dangling references anywhere.
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
# The rebuilt table is live for the new model: a definition