feat: add event tables schema and models

Create migration for events, event_revisions, event_tombstones,
idempotency_nonces, and event_animals tables with ULID checks
and JSON validation. Add Pydantic models with field validators.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-27 21:37:19 +00:00
parent bd0e865e80
commit 262739d8ec
5 changed files with 884 additions and 1 deletions

View File

@@ -1,6 +1,13 @@
# ABOUTME: Models package for AnimalTrack.
# ABOUTME: Exports all Pydantic models and enums for reference data.
# ABOUTME: Exports all Pydantic models and enums for reference and event data.
from animaltrack.models.events import (
Event,
EventAnimal,
EventRevision,
EventTombstone,
IdempotencyNonce,
)
from animaltrack.models.reference import (
FeedType,
Location,
@@ -12,7 +19,12 @@ from animaltrack.models.reference import (
)
__all__ = [
"Event",
"EventAnimal",
"EventRevision",
"EventTombstone",
"FeedType",
"IdempotencyNonce",
"Location",
"Product",
"ProductUnit",

View File

@@ -0,0 +1,92 @@
# ABOUTME: Pydantic models for event sourcing tables.
# ABOUTME: Includes Event, EventRevision, EventTombstone, IdempotencyNonce, EventAnimal.
from pydantic import BaseModel, Field, field_validator
class Event(BaseModel):
"""Core event model representing a state change in the system."""
id: str = Field(..., min_length=26, max_length=26)
type: str
ts_utc: int
actor: str
entity_refs: dict
payload: dict
version: int = 1
@field_validator("id")
@classmethod
def id_must_be_26_chars(cls, v: str) -> str:
"""Event ID must be exactly 26 characters (ULID format)."""
if len(v) != 26:
msg = "Event ID must be exactly 26 characters"
raise ValueError(msg)
return v
class EventRevision(BaseModel):
"""Stores prior versions of events when they are edited."""
event_id: str = Field(..., min_length=26, max_length=26)
version: int
ts_utc: int
actor: str
entity_refs: dict
payload: dict
edited_at_utc: int
edited_by: str
@field_validator("event_id")
@classmethod
def event_id_must_be_26_chars(cls, v: str) -> str:
"""Event ID must be exactly 26 characters (ULID format)."""
if len(v) != 26:
msg = "Event ID must be exactly 26 characters"
raise ValueError(msg)
return v
class EventTombstone(BaseModel):
"""Immutable record of event deletion."""
id: str = Field(..., min_length=26, max_length=26)
ts_utc: int
actor: str
target_event_id: str = Field(..., min_length=26, max_length=26)
reason: str | None = None
@field_validator("id", "target_event_id")
@classmethod
def ulid_must_be_26_chars(cls, v: str) -> str:
"""ULID fields must be exactly 26 characters."""
if len(v) != 26:
msg = "ULID must be exactly 26 characters"
raise ValueError(msg)
return v
class IdempotencyNonce(BaseModel):
"""Tracks POST form nonces to prevent duplicate submissions."""
nonce: str
actor: str
route: str
created_at_utc: int
class EventAnimal(BaseModel):
"""Links events to affected animals for efficient querying."""
event_id: str = Field(..., min_length=26, max_length=26)
animal_id: str = Field(..., min_length=26, max_length=26)
ts_utc: int
@field_validator("event_id", "animal_id")
@classmethod
def ulid_must_be_26_chars(cls, v: str) -> str:
"""ULID fields must be exactly 26 characters."""
if len(v) != 26:
msg = "ULID must be exactly 26 characters"
raise ValueError(msg)
return v