feat: add event deletion with tombstone creation and cascade rules
Implement event deletion per spec §10 with role-based rules: - Recorder can delete own events if no dependents exist - Admin can cascade delete (tombstone target + all dependents) - All deletions create immutable tombstone records New modules: - events/dependencies.py: find events depending on a target via shared animals - events/delete.py: delete_event() with projection reversal DependentEventsError exception added for 409 Conflict responses. E2E test #6 implemented per spec §21.6. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
115
src/animaltrack/events/delete.py
Normal file
115
src/animaltrack/events/delete.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# ABOUTME: Event deletion functionality with tombstone creation and cascade rules.
|
||||
# ABOUTME: Implements recorder vs admin deletion rules per spec section 10.
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from animaltrack.events.dependencies import find_dependent_events
|
||||
from animaltrack.events.exceptions import (
|
||||
DependentEventsError,
|
||||
EventNotFoundError,
|
||||
EventTombstonedError,
|
||||
)
|
||||
from animaltrack.events.processor import revert_event
|
||||
from animaltrack.events.store import EventStore
|
||||
from animaltrack.id_gen import generate_id
|
||||
from animaltrack.models.events import Event
|
||||
from animaltrack.projections import ProjectionRegistry
|
||||
|
||||
|
||||
def delete_event(
|
||||
db: Any,
|
||||
event_store: EventStore,
|
||||
event_id: str,
|
||||
actor: str,
|
||||
role: str,
|
||||
cascade: bool = False,
|
||||
reason: str | None = None,
|
||||
registry: ProjectionRegistry | None = None,
|
||||
) -> list[str]:
|
||||
"""Delete an event by creating a tombstone.
|
||||
|
||||
Steps:
|
||||
1. Retrieve the event (raise if not found)
|
||||
2. Check if already tombstoned (raise if so)
|
||||
3. Find dependent events
|
||||
4. Apply role-based rules:
|
||||
- Recorder: Cannot delete if dependents exist
|
||||
- Admin: Can delete with cascade to delete dependents
|
||||
5. Revert projections for all events to be deleted
|
||||
6. Create tombstone(s)
|
||||
|
||||
Args:
|
||||
db: Database connection.
|
||||
event_store: EventStore instance.
|
||||
event_id: The ULID of the event to delete.
|
||||
actor: Username of who is making the deletion.
|
||||
role: Either "recorder" or "admin".
|
||||
cascade: If True and role is admin, delete dependents too.
|
||||
reason: Optional reason for the deletion.
|
||||
registry: Optional projection registry for updating projections.
|
||||
|
||||
Returns:
|
||||
List of event IDs that were tombstoned.
|
||||
|
||||
Raises:
|
||||
EventNotFoundError: If the event doesn't exist.
|
||||
EventTombstonedError: If the event has already been deleted.
|
||||
DependentEventsError: If the event has dependents and cascade is not allowed.
|
||||
"""
|
||||
# Get the target event
|
||||
target_event = event_store.get_event(event_id)
|
||||
if target_event is None:
|
||||
raise EventNotFoundError(f"Event {event_id} not found")
|
||||
|
||||
# Check if already tombstoned
|
||||
if event_store.is_tombstoned(event_id):
|
||||
raise EventTombstonedError(f"Event {event_id} has already been deleted")
|
||||
|
||||
# Find dependent events
|
||||
dependents = find_dependent_events(db, event_store, event_id)
|
||||
|
||||
# Apply role-based rules
|
||||
if dependents:
|
||||
if role == "recorder":
|
||||
raise DependentEventsError(
|
||||
f"Cannot delete event {event_id}: it has {len(dependents)} dependent event(s)",
|
||||
dependents,
|
||||
)
|
||||
elif role == "admin" and not cascade:
|
||||
raise DependentEventsError(
|
||||
f"Cannot delete event {event_id}: it has {len(dependents)} dependent event(s). "
|
||||
"Use cascade=True to delete all dependents.",
|
||||
dependents,
|
||||
)
|
||||
|
||||
# Build the list of events to delete
|
||||
# Delete in reverse chronological order (newest first) to properly revert projections
|
||||
events_to_delete: list[Event] = [target_event]
|
||||
if cascade and dependents:
|
||||
# Sort dependents by ts_utc DESC (newest first) for proper revert order
|
||||
events_to_delete = sorted(dependents, key=lambda e: e.ts_utc, reverse=True) + [target_event]
|
||||
|
||||
# Revert projections for all events (newest first)
|
||||
if registry is not None:
|
||||
for event in events_to_delete:
|
||||
revert_event(event, registry)
|
||||
|
||||
# Create tombstones
|
||||
deleted_at_utc = int(time.time() * 1000)
|
||||
tombstoned_ids = []
|
||||
|
||||
for event in events_to_delete:
|
||||
tombstone_id = generate_id()
|
||||
tombstone_reason = reason
|
||||
if event.id != event_id:
|
||||
tombstone_reason = f"cascade from {event_id}" + (f": {reason}" if reason else "")
|
||||
|
||||
db.execute(
|
||||
"""INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id, reason)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(tombstone_id, deleted_at_utc, actor, event.id, tombstone_reason),
|
||||
)
|
||||
tombstoned_ids.append(event.id)
|
||||
|
||||
return tombstoned_ids
|
||||
78
src/animaltrack/events/dependencies.py
Normal file
78
src/animaltrack/events/dependencies.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# ABOUTME: Dependency detection for event deletion.
|
||||
# ABOUTME: Finds events that depend on a target event via shared animals.
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from animaltrack.events.store import EventStore
|
||||
from animaltrack.models.events import Event
|
||||
|
||||
|
||||
def find_dependent_events(db: Any, event_store: EventStore, event_id: str) -> list[Event]:
|
||||
"""Find events that depend on the given event.
|
||||
|
||||
An event B depends on event A if:
|
||||
- B references at least one animal that A also references
|
||||
- B's ts_utc > A's ts_utc (B happened after A)
|
||||
- B is not tombstoned
|
||||
|
||||
This is used to determine if an event can be safely deleted.
|
||||
If an event has dependents, a recorder cannot delete it (409 Conflict).
|
||||
An admin can delete it with cascade=True to delete all dependents.
|
||||
|
||||
Args:
|
||||
db: Database connection.
|
||||
event_store: EventStore instance for fetching events.
|
||||
event_id: The ULID of the target event.
|
||||
|
||||
Returns:
|
||||
List of Event objects that depend on the target event,
|
||||
ordered by ts_utc ASC (oldest first).
|
||||
"""
|
||||
# Get the target event
|
||||
target_event = event_store.get_event(event_id)
|
||||
if target_event is None:
|
||||
return []
|
||||
|
||||
# Get animal_ids linked to the target event
|
||||
target_animals = db.execute(
|
||||
"SELECT animal_id FROM event_animals WHERE event_id = ?",
|
||||
(event_id,),
|
||||
).fetchall()
|
||||
|
||||
if not target_animals:
|
||||
return []
|
||||
|
||||
target_animal_ids = [row[0] for row in target_animals]
|
||||
|
||||
# Find other events that reference any of these animals and are:
|
||||
# - After the target event (ts_utc > target.ts_utc)
|
||||
# - Not the target event itself
|
||||
# - Not tombstoned
|
||||
placeholders = ",".join("?" for _ in target_animal_ids)
|
||||
query = f"""
|
||||
SELECT DISTINCT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version
|
||||
FROM events e
|
||||
JOIN event_animals ea ON ea.event_id = e.id
|
||||
WHERE ea.animal_id IN ({placeholders})
|
||||
AND e.ts_utc > ?
|
||||
AND e.id != ?
|
||||
AND e.id NOT IN (SELECT target_event_id FROM event_tombstones)
|
||||
ORDER BY e.ts_utc ASC, e.id ASC
|
||||
"""
|
||||
|
||||
params = (*target_animal_ids, target_event.ts_utc, event_id)
|
||||
rows = db.execute(query, params).fetchall()
|
||||
|
||||
return [
|
||||
Event(
|
||||
id=row[0],
|
||||
type=row[1],
|
||||
ts_utc=row[2],
|
||||
actor=row[3],
|
||||
entity_refs=json.loads(row[4]),
|
||||
payload=json.loads(row[5]),
|
||||
version=row[6],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
@@ -24,3 +24,21 @@ class EventNotFoundError(Exception):
|
||||
|
||||
class EventTombstonedError(Exception):
|
||||
"""Raised when attempting to edit or access a tombstoned (deleted) event."""
|
||||
|
||||
|
||||
class DependentEventsError(Exception):
|
||||
"""Raised when attempting to delete an event that has dependent events.
|
||||
|
||||
Contains the list of dependent events so the caller can inform the user
|
||||
or request cascade deletion with admin privileges.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, dependent_events: list) -> None:
|
||||
"""Initialize with message and list of dependent events.
|
||||
|
||||
Args:
|
||||
message: Error message.
|
||||
dependent_events: List of Event objects that depend on the target.
|
||||
"""
|
||||
super().__init__(message)
|
||||
self.dependent_events = dependent_events
|
||||
|
||||
Reference in New Issue
Block a user