feat(persistence): SQLite EventStore and migrations #24

Merged
charles merged 3 commits from peon/5 into main 2026-04-17 07:24:39 +00:00
Collaborator

Closes #5

Summary

  • migrations/001_initial.sql — complete schema from spec §12.1: events table (append-only event log), denormalized projection tables (projection_projects, projection_threads, projection_turns, projection_messages, checkpoints, settings), and the three required indexes (idx_events_sequence, idx_turns_thread, idx_messages_turn)
  • persistence/migrations.rsopen_or_create_db(data_dir) creates the DB file and parent dirs if absent; run_migrations runs sqlx embedded migrations at startup via sqlx::migrate!()
  • persistence/event_store.rsEventStore::new(pool), append_all (atomic batch insert with last_insert_rowid), replay_all, replay_from(sequence); all four acceptance-criteria tests pass against in-memory SQLite
  • orchestration/events.rsOrchestrationEvent enum (all variants from spec §5.2), event_type_name() helper, StoredEvent envelope; defined here as required by the EventStore (blocking dependency on #4)

Test plan

  • replay_all_on_empty_db_returns_empty
  • append_single_event_replay_verifies_sequence_and_payload
  • append_batch_has_contiguous_sequences
  • replay_from_filters_correctly
  • cargo fmt --check passes
  • cargo clippy --workspace -- -D warnings passes
  • cargo test --workspace passes (4 new + all existing tests green)
Closes #5 ## Summary - **`migrations/001_initial.sql`** — complete schema from spec §12.1: `events` table (append-only event log), denormalized projection tables (`projection_projects`, `projection_threads`, `projection_turns`, `projection_messages`, `checkpoints`, `settings`), and the three required indexes (`idx_events_sequence`, `idx_turns_thread`, `idx_messages_turn`) - **`persistence/migrations.rs`** — `open_or_create_db(data_dir)` creates the DB file and parent dirs if absent; `run_migrations` runs sqlx embedded migrations at startup via `sqlx::migrate!()` - **`persistence/event_store.rs`** — `EventStore::new(pool)`, `append_all` (atomic batch insert with `last_insert_rowid`), `replay_all`, `replay_from(sequence)`; all four acceptance-criteria tests pass against in-memory SQLite - **`orchestration/events.rs`** — `OrchestrationEvent` enum (all variants from spec §5.2), `event_type_name()` helper, `StoredEvent` envelope; defined here as required by the EventStore (blocking dependency on #4) ## Test plan - [x] `replay_all_on_empty_db_returns_empty` - [x] `append_single_event_replay_verifies_sequence_and_payload` - [x] `append_batch_has_contiguous_sequences` - [x] `replay_from_filters_correctly` - [x] `cargo fmt --check` passes - [x] `cargo clippy --workspace -- -D warnings` passes - [x] `cargo test --workspace` passes (4 new + all existing tests green)
feat(persistence): SQLite EventStore and migrations
Some checks failed
qa / qa (pull_request) Failing after 2m13s
3cb5c2f04d
Implements the append-only event store backed by SQLite with:

- migrations/001_initial.sql — events table plus denormalized projection
  tables (projects, threads, turns, messages, checkpoints, settings) and
  the three required indexes
- persistence/migrations.rs — open_or_create_db (creates file + dirs) and
  run_migrations using sqlx embedded migrations
- persistence/event_store.rs — EventStore with append_all (atomic batch
  insert), replay_all, and replay_from; four in-memory tests covering all
  acceptance criteria
- orchestration/events.rs — OrchestrationEvent enum with all variants from
  the spec, event_type_name() helper, and StoredEvent envelope; required by
  the EventStore (dependency on issue #4)

Closes #5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ghost approved these changes 2026-04-16 20:56:33 +00:00
Ghost left a comment

All acceptance criteria from #5 are met. Schema matches spec §12.1 exactly, all 17 event variants match spec §5.2, atomicity is correctly implemented with the transaction + rollback-on-drop pattern, and all four required tests are present and correct. One comment below on a future maintenance trap — not a blocker.

All acceptance criteria from #5 are met. Schema matches spec §12.1 exactly, all 17 event variants match spec §5.2, atomicity is correctly implemented with the transaction + rollback-on-drop pattern, and all four required tests are present and correct. One comment below on a future maintenance trap — not a blocker.
@ -0,0 +111,4 @@
git_ref: String,
message: String,
at: DateTime<Utc>,
},

Maintenance trap: two sources of truth for the event type name.

event_type_name() is a manual match that returns the variant name as a &'static str. #[serde(tag = "type")] independently embeds the same string in the JSON payload. They're in sync today, but if a variant is ever renamed with #[serde(rename = "...")], the two will silently diverge: the event_type column will hold the old name while the JSON payload holds the new one, breaking any SQL query filtered by type.

Suggested fix — add a unit test that cross-checks them for every variant:

#[test]
fn event_type_name_matches_serde_tag() {
    let cases: &[OrchestrationEvent] = &[
        OrchestrationEvent::ProjectCreated { id: Uuid::nil(), name: String::new(), path: String::new(), at: Utc::now() },
        // … one instance per variant …
    ];
    for event in cases {
        let v = serde_json::to_value(event).unwrap();
        assert_eq!(
            event.event_type_name(),
            v["type"].as_str().unwrap(),
            "event_type_name() diverged from serde tag for {:?}",
            event.event_type_name()
        );
    }
}

This makes the CI gate catch any future drift the moment a variant is renamed.

**Maintenance trap: two sources of truth for the event type name.** `event_type_name()` is a manual match that returns the variant name as a `&'static str`. `#[serde(tag = "type")]` independently embeds the same string in the JSON payload. They're in sync today, but if a variant is ever renamed with `#[serde(rename = "...")]`, the two will silently diverge: the `event_type` column will hold the old name while the JSON payload holds the new one, breaking any SQL query filtered by type. Suggested fix — add a unit test that cross-checks them for every variant: ```rust #[test] fn event_type_name_matches_serde_tag() { let cases: &[OrchestrationEvent] = &[ OrchestrationEvent::ProjectCreated { id: Uuid::nil(), name: String::new(), path: String::new(), at: Utc::now() }, // … one instance per variant … ]; for event in cases { let v = serde_json::to_value(event).unwrap(); assert_eq!( event.event_type_name(), v["type"].as_str().unwrap(), "event_type_name() diverged from serde tag for {:?}", event.event_type_name() ); } } ``` This makes the CI gate catch any future drift the moment a variant is renamed.
fix(persistence): remove redundant index on events(sequence)
Some checks failed
qa / qa (pull_request) Failing after 2m6s
e6a6ea53e8
The sequence column is INTEGER PRIMARY KEY AUTOINCREMENT which already
has an implicit B-tree index in SQLite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix(ci): add libsqlite3-dev to system dependencies
Some checks failed
qa / qa (pull_request) Failing after 1m59s
3c5f815409
Required by libsqlite3-sys for sqlx compilation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix(ci): bump cache key to v2 to invalidate stale target/
Some checks failed
qa / qa (pull_request) Has been cancelled
91409319ea
The previous cache was built before sqlx was used and is confusing
the compiler on rebuild.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
charles force-pushed peon/5 from 91409319ea
Some checks failed
qa / qa (pull_request) Has been cancelled
to 2c6f007713
All checks were successful
qa / qa (pull_request) Successful in 12m12s
2026-04-17 07:18:27 +00:00
Compare
Sign in to join this conversation.
No description provided.