Multi-assignee definitions via quest_definition_assignees (D-008)

Per IMPLEMENTATION-ORDER.md 1.3: a quest_definition_assignees table
(definition_id, child_id) with a composite primary key and FKs to
quest_definitions and children replaces the single child_id column on
quest_definitions.

- schema.py: definitions DDL loses child_id; new assignees table joins
  SCHEMA_V1_STATEMENTS; the definitions table body is extracted to
  QUEST_DEFINITIONS_TABLE_SQL so the rebuild migration cannot drift
  from the canonical shape.
- migrations.py: callable migrations now own their transaction and
  stamp via stamp_schema_version(database, version), because the
  rebuild must bracket itself with PRAGMA foreign_keys OFF/ON. New
  migration 3 creates the assignees table and, when a pre-D-008
  quest_definitions still carries child_id, rebuilds the table via
  SQLite's canonical procedure (create, copy, move assignees, drop,
  rename), verifies PRAGMA foreign_key_check inside the transaction,
  and stamps. Migration 2 converted to the new callable contract.
- dao_rules.py: QuestDefinitionsDao.create drops the child_id argument
  and applies assignee_child_ids atomically; set_assignee is replaced
  by add_assignee (idempotent, active-child validated under the
  connection lock), remove_assignee and list_assignees; list_by_child
  reads through the assignees table.
- dao_instances.py: upsert validates the child against
  quest_definition_assignees instead of the definitions column.
- tests: schema shape tests for the assignees table (columns, PK, FKs,
  duplicate/unknown constraints); DAO tests rewritten for the new
  assignment API; migration tests for the v2 -> v3 rebuild (rows and
  instances survive, child_id gone, foreign_key_check clean).
This commit is contained in:
Joshua King
2026-09-15 09:59:29 -04:00
parent c988f6508b
commit 25f0a43e16
9 changed files with 883 additions and 261 deletions
+15 -14
View File
@@ -82,7 +82,7 @@ async def _prepare(path) -> tuple:
child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1)
definition = await definitions.create(
"Brush teeth", child.id, rule.id, _now_stamp()
"Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id]
)
return (
database,
@@ -184,17 +184,15 @@ def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None:
with pytest.raises(ValueError, match="definition 999"):
await instances.upsert(999, child.id, D1, _now_stamp())
# Unknown child: the definition-assignee check fires first and
# is the one that matters (an instance must carry its
# definition's assignee). To reach the child-existence check
# at all, the definition would have to claim child 999 — an
# FK-impossible state — so the child check only guards direct
# callers with a matching fake assignee; assert the guard
# order instead of forcing the FK-impossible state.
# is the one that matters (an instance must carry an assigned
# child). Under D-008 the assignee check is a membership test
# against quest_definition_assignees, so child 999 simply is
# not an assignee — the error names the pair.
rule = await rules.create("daily", D1)
other_definition = await definitions.create(
"X", child.id, rule.id, _now_stamp()
"X", rule.id, _now_stamp(), assignee_child_ids=[child.id]
)
with pytest.raises(ValueError, match="not 999"):
with pytest.raises(ValueError, match="not assigned to child 999"):
await instances.upsert(other_definition.id, 999, D1, _now_stamp())
return None
@@ -329,7 +327,10 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1)
definition = await definitions.create(
"Brush teeth", child.id, rule.id, _now_stamp()
"Brush teeth",
rule.id,
_now_stamp(),
assignee_child_ids=[child.id],
)
return await _body(
database, children, rules, definitions, instances,
@@ -406,7 +407,7 @@ def test_list_by_child_and_date_filters(tmp_path) -> None:
other = await children.create("Bo", _now_stamp())
rule = await rules.create("daily", D1)
other_definition = await definitions.create(
"Make bed", other.id, rule.id, _now_stamp()
"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())
@@ -537,8 +538,8 @@ def test_delete_future_uncompleted_other_definitions_untouched(
async def _body(database, children, rules, definitions, instances,
events, child, definition):
other = await definitions.create(
"Make bed", child.id, (await rules.create("daily", D1)).id,
_now_stamp(),
"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())
@@ -1090,7 +1091,7 @@ def test_append_concurrent_events_serialize(tmp_path) -> None:
child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1)
definition = await definitions.create(
"Brush teeth", child.id, rule.id, _now_stamp()
"Brush teeth", rule.id, _now_stamp(), assignee_child_ids=[child.id]
)
return (database, children, rules, definitions, instances,
events, child, definition)