fix: reject duplicates, return consistent snapshot, test post-write rollback (NQ-67-01..03)

This commit is contained in:
2026-09-17 08:30:46 -04:00
parent 7cb03237d8
commit 71fbbf20e4
3 changed files with 131 additions and 12 deletions
+29 -2
View File
@@ -163,6 +163,22 @@ class QuestDefinitionWindowRecord:
due_time: str | None
@dataclass(frozen=True)
class QuestDefinitionSnapshot:
"""A definition plus its assignees and windows, read atomically.
Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows`
with the assignees and windows read inside the SAME transaction that
inserted them, so the snapshot is a consistent creation-time view —
a concurrent assignee or window mutation cannot change it between
the write and the read.
"""
definition: QuestDefinitionRecord
assignees: list[ChildRecord]
windows: list[QuestDefinitionWindowRecord]
_RULE_COLUMNS = (
"id, rule_type, interval, weekday_set, day_of_month, nth_weekday, "
"month, start_date, end_date"
@@ -644,7 +660,7 @@ class QuestDefinitionsDao:
*,
description: str | None = None,
icon: str | None = None,
) -> QuestDefinitionRecord:
) -> QuestDefinitionSnapshot:
"""Insert a rule, definition, assignees and windows atomically.
The whole write — the schedule rule row, the definition row,
@@ -655,6 +671,11 @@ class QuestDefinitionsDao:
assignee or window, and a rejected create leaves no partial
rows.
Returns a :class:`QuestDefinitionSnapshot` whose assignees and
windows are read back inside the same transaction, so the
returned view cannot be raced by a concurrent assignee/window
mutation after the write.
The rule arrives as pre-validated storage fields (mapped from a
:class:`~.recurrence.ScheduleRule` via
:func:`schedule_rule_to_storage`); its dates are re-validated
@@ -696,8 +717,14 @@ class QuestDefinitionsDao:
(definition_id, window, due_time),
)
definition = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
assert definition is not None
return definition
return QuestDefinitionSnapshot(
definition=definition,
assignees=assignees,
windows=window_records,
)
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
"""Raise ValueError unless the rule exists.
@@ -16,11 +16,15 @@ Validation policy mirrors :mod:`.children`:
- ``assignee_child_ids`` is non-empty; every id is a plain int (bools
and floats rejected, since SQLite would bind them onto a real child)
and must reference an existing ACTIVE child (the DAO enforces the
active/exists check inside the same transaction).
active/exists check inside the same transaction). Duplicate ids are
rejected here, before the composite primary key could surface them as
an :class:`sqlite3.IntegrityError`.
- ``windows`` is non-empty; each entry is a window name from
:data:`~.const.QUEST_WINDOWS` — either a bare name (no due time) or a
``(name, due_time)`` pair whose due time is a strict 24-hour HH:MM
string.
string. Duplicate window names are rejected here, before the
composite primary key could surface them as an
:class:`sqlite3.IntegrityError`.
The rule, definition, assignees and windows are persisted in ONE
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
@@ -113,6 +117,10 @@ def _validate_assignee_ids(value: object) -> list[int]:
f"got {entry!r}"
)
ids.append(entry)
if len(set(ids)) != len(ids):
raise ValueError(
"assignee_child_ids must not contain duplicate child ids"
)
return ids
@@ -135,6 +143,7 @@ def _normalize_windows(
if not value:
raise ValueError("windows must not be empty")
normalized: list[tuple[str, str | None]] = []
seen: set[str] = set()
for index, entry in enumerate(value):
if isinstance(entry, str):
name: object = entry
@@ -149,6 +158,11 @@ def _normalize_windows(
_validate_window(name)
if due_time is not None:
_validate_time(due_time, "due_time")
if name in seen:
raise ValueError(
f"windows must not contain duplicate window {name!r}"
)
seen.add(name)
normalized.append((name, due_time))
return normalized
@@ -168,8 +182,10 @@ async def create_quest_definition(
Validates the arguments up front, then persists the schedule rule,
the definition, its assignees and its windows in one transaction via
the DAO. Returns the stored definition together with its decoded
rule, assignees and windows. Raises ValueError on any rejected
argument; a rejected or failed create persists nothing.
rule, assignees and windows — all read back inside that same
transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing.
"""
name = _validate_text(title, "title", required=True)
assert name is not None
@@ -182,7 +198,7 @@ async def create_quest_definition(
storage = schedule_rule_to_storage(rule)
dao = QuestDefinitionsDao(database)
definition = await dao.create_with_rule_and_windows(
snapshot = await dao.create_with_rule_and_windows(
name,
storage,
_now_stamp(),
@@ -192,11 +208,9 @@ async def create_quest_definition(
icon=icon_value,
)
decoded_rule = schedule_rule_from_storage(storage)
assignees = await dao.list_assignees(definition.id)
window_records = await dao.list_windows(definition.id)
return CreatedQuestDefinition(
definition=definition,
definition=snapshot.definition,
rule=decoded_rule,
assignees=assignees,
windows=window_records,
assignees=snapshot.assignees,
windows=snapshot.windows,
)