6.2 KiB
6.2 KiB
Phase 1: Storage — model, migration, repository
Status: ⬜ Not Started Parent plan: PLAN.md Domain: data
Objective
Create the persistent foundation for the audit log: an ActivityLogEntry dataclass, an
additive idempotent SQLite migration that creates a dedicated indexed activity_log table,
and a purpose-built ActivityLogRepository (NOT BaseSqliteStore) supporting append,
keyset-paginated filtered query, count, time/count-based prune, and streaming export.
Tasks
- Create
server/src/ledgrab/storage/activity_log.py:ActivityCategoryandActivitySeveritystring enums (orLiteralunions used as constants). Categories:auth,device,entity,capture,system. Severities:info,warning,error.@dataclass ActivityLogEntrywith fields:id: str(e.g.al_<uuid8>),ts: datetime(UTC, server-assigned),category: str,action: str,severity: str,actor: str,entity_type: str | None,entity_id: str | None,entity_name: str | None,message: str,metadata: dict(small JSON; default empty). Provideto_row()/from_row()(column tuple/dict ↔ dataclass;metadataJSON-encoded;tsisoformat).
- Add migration to
server/src/ledgrab/storage/data_migrations.py:- New
DataMigrationsubclassAddActivityLogTableMigrationwith uniquename(next sequential id, e.g."NNN_add_activity_log"— match existing naming) andapply(conn)creatingactivity_logwith an INTEGER PRIMARY KEY AUTOINCREMENTseq(monotonic keyset tiebreaker) plus columns:id TEXT UNIQUE NOT NULL,ts TEXT NOT NULL,category TEXT NOT NULL,action TEXT NOT NULL,severity TEXT NOT NULL,actor TEXT NOT NULL,entity_type TEXT,entity_id TEXT,entity_name TEXT,message TEXT NOT NULL,metadata TEXT NOT NULL DEFAULT '{}'. - Indexes:
(ts DESC, seq DESC)(primary keyset/sort),category,severity,actor,(entity_type, entity_id). UseCREATE TABLE/INDEX IF NOT EXISTSfor idempotency. - Append the instance to
ALL_MIGRATIONS(never reorder existing entries).
- New
- Create
server/src/ledgrab/storage/activity_log_repository.py:class ActivityLogRepositorytakingdb: Database(NOT subclassingBaseSqliteStore).record(entry: ActivityLogEntry) -> None: single parameterized INSERT viadb.execute(...)(auto-commit). Theseqis DB-assigned. Caller guarantees this runs on the event-loop thread (see Phase 2 — cross-thread marshaling lives in the recorder).query(filters: ActivityLogFilters, *, before_seq: int | None, limit: int) -> list[ActivityLogEntry]: keyset paginationWHERE seq < ? ORDER BY seq DESC LIMIT ?plus optional filters —category IN (...),severity IN (...),actor = ?,entity_type = ?,entity_id = ?,ts >= ?/ts <= ?,message LIKE ?(free-text,%q%, escaped). All parameterized.count(filters) -> int.prune(*, before_ts: datetime | None, max_entries: int | None) -> int: delete rows older thanbefore_ts, and/or trim to the newestmax_entriesbyseq. Returns rows deleted.clear() -> int: delete all rows (used by the API clear endpoint; the clear action is itself audited by the recorder, not here). Returns rows deleted.iter_export(filters) -> Iterator[ActivityLogEntry]: cursor-based streaming for export (does not load all rows into memory).- Define a small
ActivityLogFiltersdataclass (all-optional fields) in the repository oractivity_log.pyand reuse it across query/count/prune/export.
- Unit tests in
server/tests/storage/test_activity_log_repository.py:- insert + read back round-trip (incl. metadata JSON, UTC ts);
- filter by each dimension (category/severity/actor/entity/date/free-text);
- keyset pagination stability across two pages with same-
tsrows (seq tiebreaker); - prune by age and by max_entries;
- clear; count; export iterator yields all matching rows;
- migration idempotency (constructing the repo twice / running migrations twice is safe).
Files to Modify/Create
server/src/ledgrab/storage/activity_log.py— new: dataclass + enums + filters + row codecserver/src/ledgrab/storage/data_migrations.py— modify: add migration + append toALL_MIGRATIONSserver/src/ledgrab/storage/activity_log_repository.py— new: repositoryserver/tests/storage/test_activity_log_repository.py— new: unit tests
Acceptance Criteria
activity_logtable + indexes created idempotently on startup (running migrations twice is a no-op).- Query is keyset-paginated and index-backed; a 10k-row table never loads fully into memory.
- Pagination is stable when many rows share the same millisecond
ts(usesseqtiebreaker). pruneremoves by age AND by max-entry cap;clearempties the table;exportstreams.- All filters use parameterized SQL (no string interpolation of user input).
- New unit tests pass;
ruff checkclean; existing tests still green.
Notes
- Reference patterns:
storage/database.py(execute,transaction,get_setting),storage/data_migrations.py(DataMigration,MigrationRunner,ALL_MIGRATIONS),storage/sync_clock.py(dataclassto_dict/from_dictstyle). - 🔒 Migration-safety addendum (data domain): this migration is purely additive (new
table) — no rename, no field/key/file move, no data movement → no data-loss risk. Still
idempotent (
IF NOT EXISTS). Rollback = drop the table; no user data is transformed. - Do NOT wire the repository into
main.pyordependencies.pyhere — that is Phase 2. Database's connection is created with the existing threading model; the repository must not assume it can be called from arbitrary threads. Thread marshaling is Phase 2's job.
Review Checklist
- All tasks completed
- Code follows project conventions (dataclass codec style, migration naming)
- No unintended side effects (no startup wiring yet)
- Build passes (ruff + pytest)
- Tests pass (new + existing)