Skip to main content

arc_es_sqlite/
lib.rs

1//! # Arc ES SQLite
2//!
3//! SQLite implementation of the [`EventStore`] trait from `arc-core`.
4//!
5//! Persists [`AuditMetadata`] inline alongside each event (see HIPAA-1 in
6//! `docs/ark/refactor-plan.md`). `append` calls
7//! [`validate_audit_batch`](arc_core::event_store::validate_audit_batch)
8//! before any write — defense-in-depth against an upstream that forgot to
9//! stamp.
10
11use arc_core::audit::AuditMetadata;
12use arc_core::event::Event;
13use arc_core::event_store::{
14    validate_audit_batch, EventStore, EventStoreError, EventStoreResult, VersionCheck,
15};
16use arc_core::integrity::{EventSignature, HmacSha256Chain, IntegrityChain, IntegrityError};
17use arc_core::snapshot::Snapshot;
18use async_trait::async_trait;
19use diesel::prelude::*;
20use diesel::r2d2::{self, ConnectionManager};
21use diesel::sqlite::SqliteConnection;
22use std::sync::Arc;
23use uuid::Uuid;
24
25// Re-export for convenience
26pub use arc_core::{Deserialize, Serialize};
27
28pub mod session;
29pub use session::SqliteSessionStore;
30
31pub mod read_model_store;
32pub use read_model_store::SqliteReadModelStore;
33
34#[cfg(test)]
35mod test_support;
36
37/// Database row used for inserting events.
38#[derive(Debug, Insertable, Clone)]
39#[diesel(table_name = events)]
40struct NewEventRecord {
41    pub event_id: String,
42    pub aggregate_type: String,
43    pub aggregate_id: String,
44    pub sequence: i64,
45    pub event_type: String,
46    pub payload: String,
47    pub timestamp: i64,
48    pub actor_id: String,
49    pub actor_session_id: Option<String>,
50    pub source_ip: Option<String>,
51    pub user_agent: Option<String>,
52    pub timestamp_utc_us: i64,
53    pub causation_id: Option<String>,
54    pub correlation_id: String,
55    pub integrity_signature: Option<String>,
56    pub integrity_key_id: Option<String>,
57}
58
59#[derive(Debug, Queryable, Clone)]
60struct EventRecord {
61    #[allow(dead_code)]
62    pub id: Option<i32>,
63    pub event_id: String,
64    pub aggregate_type: String,
65    pub aggregate_id: String,
66    pub sequence: i64,
67    pub event_type: String,
68    pub payload: String,
69    pub timestamp: i64,
70    pub actor_id: String,
71    pub actor_session_id: Option<String>,
72    pub source_ip: Option<String>,
73    pub user_agent: Option<String>,
74    pub timestamp_utc_us: i64,
75    pub causation_id: Option<String>,
76    pub correlation_id: String,
77    pub integrity_signature: Option<String>,
78    pub integrity_key_id: Option<String>,
79}
80
81impl NewEventRecord {
82    fn from_event(
83        event: &Event,
84        integrity_signature: Option<String>,
85        integrity_key_id: Option<String>,
86    ) -> Result<Self, EventStoreError> {
87        // sequence and timestamp are i64 end-to-end now — no truncation.
88        let timestamp_seconds: i64 = (event.timestamp / 1000) as i64;
89        Ok(NewEventRecord {
90            event_id: event.event_id.to_string(),
91            aggregate_type: event.aggregate_type.clone(),
92            aggregate_id: event.aggregate_id.clone(),
93            sequence: event.sequence,
94            event_type: event.event_type.clone(),
95            payload: serde_json::to_string(&event.payload)
96                .map_err(|e| EventStoreError::serialization(e.to_string()))?,
97            timestamp: timestamp_seconds,
98            actor_id: event.audit.actor_id.clone(),
99            actor_session_id: event.audit.actor_session_id.clone(),
100            source_ip: event.audit.source_ip.clone(),
101            user_agent: event.audit.user_agent.clone(),
102            timestamp_utc_us: event.audit.timestamp_utc_us,
103            causation_id: event.audit.causation_id.map(|u| u.to_string()),
104            correlation_id: event.audit.correlation_id.to_string(),
105            integrity_signature,
106            integrity_key_id,
107        })
108    }
109}
110
111impl EventRecord {
112    fn to_event(&self) -> Result<Event, EventStoreError> {
113        let event_id = Uuid::parse_str(&self.event_id)
114            .map_err(|e| EventStoreError::serialization(format!("Invalid UUID: {}", e)))?;
115
116        let payload: serde_json::Value = serde_json::from_str(&self.payload)
117            .map_err(|e| EventStoreError::serialization(e.to_string()))?;
118
119        let causation_id = match self.causation_id.as_deref() {
120            Some(s) => Some(Uuid::parse_str(s).map_err(|e| {
121                EventStoreError::serialization(format!("Invalid causation UUID: {}", e))
122            })?),
123            None => None,
124        };
125
126        let correlation_id = Uuid::parse_str(&self.correlation_id).map_err(|e| {
127            EventStoreError::serialization(format!("Invalid correlation UUID: {}", e))
128        })?;
129
130        let audit = AuditMetadata {
131            actor_id: self.actor_id.clone(),
132            actor_session_id: self.actor_session_id.clone(),
133            source_ip: self.source_ip.clone(),
134            user_agent: self.user_agent.clone(),
135            timestamp_utc_us: self.timestamp_utc_us,
136            causation_id,
137            correlation_id,
138        };
139
140        Ok(Event {
141            event_id,
142            aggregate_type: self.aggregate_type.clone(),
143            aggregate_id: self.aggregate_id.clone(),
144            sequence: self.sequence,
145            event_type: self.event_type.clone(),
146            payload,
147            audit,
148            timestamp: (self.timestamp as u64) * 1000,
149        })
150    }
151}
152
153/// Database row used for upserting snapshots. `state` holds the aggregate's
154/// serialized JSON; `created_at` is milliseconds since epoch (same unit as the
155/// core `Snapshot`), stored as i64 to match the events table's `timestamp`.
156#[derive(Debug, Insertable, Clone)]
157#[diesel(table_name = snapshots)]
158struct NewSnapshotRecord {
159    pub aggregate_id: String,
160    pub aggregate_type: String,
161    pub version: i64,
162    pub state: String,
163    pub created_at: i64,
164}
165
166#[derive(Debug, Queryable, Clone)]
167struct SnapshotRecord {
168    pub aggregate_id: String,
169    pub aggregate_type: String,
170    pub version: i64,
171    pub state: String,
172    pub created_at: i64,
173}
174
175impl NewSnapshotRecord {
176    fn from_snapshot(snapshot: &Snapshot) -> Result<Self, EventStoreError> {
177        Ok(NewSnapshotRecord {
178            aggregate_id: snapshot.aggregate_id.clone(),
179            aggregate_type: snapshot.aggregate_type.clone(),
180            version: snapshot.version,
181            state: serde_json::to_string(&snapshot.state)
182                .map_err(|e| EventStoreError::serialization(e.to_string()))?,
183            created_at: snapshot.created_at as i64,
184        })
185    }
186}
187
188impl SnapshotRecord {
189    fn to_snapshot(&self) -> Result<Snapshot, EventStoreError> {
190        let state: serde_json::Value = serde_json::from_str(&self.state)
191            .map_err(|e| EventStoreError::serialization(e.to_string()))?;
192        Ok(Snapshot {
193            aggregate_id: self.aggregate_id.clone(),
194            aggregate_type: self.aggregate_type.clone(),
195            version: self.version,
196            state,
197            created_at: self.created_at as u64,
198        })
199    }
200}
201
202mod schema {
203    diesel::table! {
204        events (id) {
205            id -> Nullable<Integer>,
206            event_id -> Text,
207            aggregate_type -> Text,
208            aggregate_id -> Text,
209            sequence -> BigInt,
210            event_type -> Text,
211            payload -> Text,
212            timestamp -> BigInt,
213            actor_id -> Text,
214            actor_session_id -> Nullable<Text>,
215            source_ip -> Nullable<Text>,
216            user_agent -> Nullable<Text>,
217            timestamp_utc_us -> BigInt,
218            causation_id -> Nullable<Text>,
219            correlation_id -> Text,
220            integrity_signature -> Nullable<Text>,
221            integrity_key_id -> Nullable<Text>,
222        }
223    }
224
225    diesel::table! {
226        snapshots (aggregate_id) {
227            aggregate_id -> Text,
228            aggregate_type -> Text,
229            version -> BigInt,
230            state -> Text,
231            created_at -> BigInt,
232        }
233    }
234}
235
236use schema::{events, snapshots};
237
238type Pool = r2d2::Pool<ConnectionManager<SqliteConnection>>;
239
240/// SQLite implementation of EventStore.
241#[derive(Clone)]
242pub struct SqliteEventStore {
243    pool: Arc<Pool>,
244    integrity: Option<Arc<IntegrityConfig>>,
245}
246
247struct IntegrityConfig {
248    chain: Arc<dyn IntegrityChain>,
249    key_id: String,
250}
251
252impl SqliteEventStore {
253    pub async fn new(database_url: &str) -> EventStoreResult<Self> {
254        let manager = ConnectionManager::<SqliteConnection>::new(database_url);
255        let pool = Pool::builder()
256            .max_size(10)
257            .build(manager)
258            .map_err(|e| EventStoreError::database(format!("Failed to create pool: {}", e)))?;
259
260        Ok(SqliteEventStore {
261            pool: Arc::new(pool),
262            integrity: None,
263        })
264    }
265
266    pub async fn new_with_integrity_key(
267        database_url: &str,
268        key: impl Into<Vec<u8>>,
269        key_id: impl Into<String>,
270    ) -> EventStoreResult<Self> {
271        let mut store = Self::new(database_url).await?;
272        store.integrity = Some(Arc::new(IntegrityConfig {
273            chain: Arc::new(HmacSha256Chain::new(key).map_err(EventStoreError::from)?),
274            key_id: key_id.into(),
275        }));
276        Ok(store)
277    }
278
279    pub fn with_pool(pool: Pool) -> Self {
280        SqliteEventStore {
281            pool: Arc::new(pool),
282            integrity: None,
283        }
284    }
285
286    pub fn with_pool_and_integrity_key(
287        pool: Pool,
288        key: impl Into<Vec<u8>>,
289        key_id: impl Into<String>,
290    ) -> EventStoreResult<Self> {
291        Ok(SqliteEventStore {
292            pool: Arc::new(pool),
293            integrity: Some(Arc::new(IntegrityConfig {
294                chain: Arc::new(HmacSha256Chain::new(key).map_err(EventStoreError::from)?),
295                key_id: key_id.into(),
296            })),
297        })
298    }
299}
300
301fn required_signature(
302    record: &EventRecord,
303    aggregate_id: &str,
304    sequence: i64,
305) -> EventStoreResult<EventSignature> {
306    let _key_id = record.integrity_key_id.as_ref().ok_or_else(|| {
307        EventStoreError::from(IntegrityError::BrokenAt {
308            aggregate_id: aggregate_id.to_string(),
309            sequence,
310        })
311    })?;
312
313    record
314        .integrity_signature
315        .as_ref()
316        .map(|s| EventSignature(s.clone()))
317        .ok_or_else(|| {
318            EventStoreError::from(IntegrityError::BrokenAt {
319                aggregate_id: aggregate_id.to_string(),
320                sequence,
321            })
322        })
323}
324
325fn verify_integrity_records(
326    integrity: &IntegrityConfig,
327    records: &[EventRecord],
328    previous_signature: EventSignature,
329) -> EventStoreResult<Vec<Event>> {
330    let mut previous = previous_signature;
331    let mut events = Vec::with_capacity(records.len());
332
333    for record in records {
334        let event = record.to_event()?;
335        let expected = integrity.chain.sign_event(&previous, &event)?;
336        let claimed = required_signature(record, &event.aggregate_id, event.sequence)?;
337
338        if expected != claimed {
339            return Err(EventStoreError::from(IntegrityError::BrokenAt {
340                aggregate_id: event.aggregate_id,
341                sequence: event.sequence,
342            }));
343        }
344
345        previous = claimed;
346        events.push(event);
347    }
348
349    Ok(events)
350}
351
352fn previous_signature_for_aggregate(
353    conn: &mut SqliteConnection,
354    aggregate_id: &str,
355    before_sequence: i64,
356) -> EventStoreResult<EventSignature> {
357    if before_sequence <= 1 {
358        return Ok(EventSignature::genesis());
359    }
360
361    let record = events::table
362        .filter(events::aggregate_id.eq(aggregate_id))
363        .filter(events::sequence.lt(before_sequence))
364        .order(events::sequence.desc())
365        .first::<EventRecord>(conn)
366        .optional()
367        .map_err(|e| EventStoreError::database(e.to_string()))?;
368
369    match record {
370        Some(record) => required_signature(&record, aggregate_id, record.sequence),
371        None => Ok(EventSignature::genesis()),
372    }
373}
374
375fn verify_stream_integrity_records(
376    conn: &mut SqliteConnection,
377    integrity: &IntegrityConfig,
378    records: &[EventRecord],
379) -> EventStoreResult<Vec<Event>> {
380    use std::collections::HashMap;
381
382    let mut previous_by_aggregate: HashMap<String, EventSignature> = HashMap::new();
383    let mut events = Vec::with_capacity(records.len());
384
385    for record in records {
386        let event = record.to_event()?;
387        let previous = match previous_by_aggregate.get(&event.aggregate_id) {
388            Some(sig) => sig.clone(),
389            None => previous_signature_for_aggregate(conn, &event.aggregate_id, event.sequence)?,
390        };
391
392        let expected = integrity.chain.sign_event(&previous, &event)?;
393        let claimed = required_signature(record, &event.aggregate_id, event.sequence)?;
394
395        if expected != claimed {
396            return Err(EventStoreError::from(IntegrityError::BrokenAt {
397                aggregate_id: event.aggregate_id,
398                sequence: event.sequence,
399            }));
400        }
401
402        previous_by_aggregate.insert(event.aggregate_id.clone(), claimed);
403        events.push(event);
404    }
405
406    Ok(events)
407}
408
409#[async_trait]
410impl EventStore for SqliteEventStore {
411    async fn append(
412        &self,
413        aggregate_id: &str,
414        version_check: VersionCheck,
415        new_events: Vec<Event>,
416    ) -> EventStoreResult<()> {
417        if new_events.is_empty() {
418            return Ok(());
419        }
420
421        // Defense-in-depth: reject any event with invalid audit before touching the DB.
422        validate_audit_batch(aggregate_id, &new_events)?;
423
424        let aggregate_id = aggregate_id.to_string();
425        let pool = self.pool.clone();
426        let integrity = self.integrity.clone();
427
428        tokio::task::spawn_blocking(move || -> EventStoreResult<()> {
429            use diesel::connection::AnsiTransactionManager;
430            use diesel::connection::TransactionManager;
431
432            let mut conn = pool.get().map_err(|e| {
433                EventStoreError::database(format!("Failed to get connection: {}", e))
434            })?;
435
436            AnsiTransactionManager::begin_transaction(&mut *conn)
437                .map_err(|e| EventStoreError::database(e.to_string()))?;
438
439            let result = (|| -> EventStoreResult<()> {
440                let current_version = events::table
441                    .filter(events::aggregate_id.eq(&aggregate_id))
442                    .select(diesel::dsl::max(events::sequence))
443                    .first::<Option<i64>>(&mut *conn)
444                    .map_err(|e| EventStoreError::database(e.to_string()))?
445                    .unwrap_or(0);
446
447                if let Some(expected) = version_check.version() {
448                    if current_version != expected {
449                        return Err(EventStoreError::ConcurrencyConflict {
450                            aggregate_id: aggregate_id.clone(),
451                            expected,
452                            actual: current_version,
453                        });
454                    }
455                }
456
457                for (expected_sequence, event) in (current_version + 1..).zip(new_events.iter()) {
458                    if event.sequence != expected_sequence {
459                        return Err(EventStoreError::InvalidSequence {
460                            aggregate_id: aggregate_id.clone(),
461                            expected: expected_sequence,
462                            actual: event.sequence,
463                        });
464                    }
465                }
466
467                let mut previous_signature = if integrity.is_some() {
468                    previous_signature_for_aggregate(&mut conn, &aggregate_id, current_version + 1)?
469                } else {
470                    EventSignature::genesis()
471                };
472
473                for event in &new_events {
474                    let mut record = NewEventRecord::from_event(event, None, None)?;
475
476                    if let Some(integrity) = integrity.as_ref() {
477                        let mut persisted_event = event.clone();
478                        persisted_event.timestamp = (record.timestamp as u64) * 1000;
479                        let signature = integrity
480                            .chain
481                            .sign_event(&previous_signature, &persisted_event)
482                            .map_err(EventStoreError::from)?;
483                        previous_signature = signature.clone();
484                        record.integrity_signature = Some(signature.0);
485                        record.integrity_key_id = Some(integrity.key_id.clone());
486                    }
487
488                    diesel::insert_into(events::table)
489                        .values(&record)
490                        .execute(&mut *conn)
491                        .map_err(|e| EventStoreError::database(e.to_string()))?;
492                }
493
494                Ok(())
495            })();
496
497            match result {
498                Ok(_) => {
499                    AnsiTransactionManager::commit_transaction(&mut *conn)
500                        .map_err(|e| EventStoreError::database(e.to_string()))?;
501                    Ok(())
502                }
503                Err(e) => {
504                    let _ = AnsiTransactionManager::rollback_transaction(&mut *conn);
505                    Err(e)
506                }
507            }
508        })
509        .await
510        .map_err(|e| EventStoreError::other(format!("Task join error: {}", e)))?
511    }
512
513    async fn load(&self, aggregate_id: &str) -> EventStoreResult<Vec<Event>> {
514        self.load_from(aggregate_id, 1).await
515    }
516
517    async fn load_from(
518        &self,
519        aggregate_id: &str,
520        from_sequence: i64,
521    ) -> EventStoreResult<Vec<Event>> {
522        let aggregate_id = aggregate_id.to_string();
523        let pool = self.pool.clone();
524        let integrity = self.integrity.clone();
525
526        tokio::task::spawn_blocking(move || {
527            let mut conn = pool.get().map_err(|e| {
528                EventStoreError::database(format!("Failed to get connection: {}", e))
529            })?;
530
531            let records: Vec<EventRecord> = events::table
532                .filter(events::aggregate_id.eq(&aggregate_id))
533                .filter(events::sequence.ge(from_sequence))
534                .order(events::sequence.asc())
535                .load(&mut conn)
536                .map_err(|e| EventStoreError::database(e.to_string()))?;
537
538            match integrity.as_ref() {
539                Some(integrity) => {
540                    let previous =
541                        previous_signature_for_aggregate(&mut conn, &aggregate_id, from_sequence)?;
542                    verify_integrity_records(integrity, &records, previous)
543                }
544                None => records.iter().map(|r| r.to_event()).collect(),
545            }
546        })
547        .await
548        .map_err(|e| EventStoreError::other(format!("Task join error: {}", e)))?
549    }
550
551    async fn stream_all(&self, from_position: i64) -> EventStoreResult<Vec<Event>> {
552        let pool = self.pool.clone();
553        let integrity = self.integrity.clone();
554
555        tokio::task::spawn_blocking(move || {
556            let mut conn = pool.get().map_err(|e| {
557                EventStoreError::database(format!("Failed to get connection: {}", e))
558            })?;
559
560            let records: Vec<EventRecord> = events::table
561                .filter(events::id.ge(from_position as i32))
562                .order(events::id.asc())
563                .load(&mut conn)
564                .map_err(|e| EventStoreError::database(e.to_string()))?;
565
566            match integrity.as_ref() {
567                Some(integrity) => verify_stream_integrity_records(&mut conn, integrity, &records),
568                None => records.iter().map(|r| r.to_event()).collect(),
569            }
570        })
571        .await
572        .map_err(|e| EventStoreError::other(format!("Task join error: {}", e)))?
573    }
574
575    async fn get_version(&self, aggregate_id: &str) -> EventStoreResult<i64> {
576        let aggregate_id = aggregate_id.to_string();
577        let pool = self.pool.clone();
578
579        tokio::task::spawn_blocking(move || {
580            let mut conn = pool.get().map_err(|e| {
581                EventStoreError::database(format!("Failed to get connection: {}", e))
582            })?;
583
584            let version = events::table
585                .filter(events::aggregate_id.eq(&aggregate_id))
586                .select(diesel::dsl::max(events::sequence))
587                .first::<Option<i64>>(&mut conn)
588                .map_err(|e| EventStoreError::database(e.to_string()))?
589                .unwrap_or(0);
590
591            Ok(version)
592        })
593        .await
594        .map_err(|e| EventStoreError::other(format!("Task join error: {}", e)))?
595    }
596
597    async fn save_snapshot(&self, snapshot: &Snapshot) -> EventStoreResult<()> {
598        let record = NewSnapshotRecord::from_snapshot(snapshot)?;
599        let pool = self.pool.clone();
600
601        tokio::task::spawn_blocking(move || -> EventStoreResult<()> {
602            let mut conn = pool.get().map_err(|e| {
603                EventStoreError::database(format!("Failed to get connection: {}", e))
604            })?;
605
606            // One snapshot per aggregate: replace the stored row in place rather
607            // than accumulating stale versions.
608            diesel::insert_into(snapshots::table)
609                .values(&record)
610                .on_conflict(snapshots::aggregate_id)
611                .do_update()
612                .set((
613                    snapshots::aggregate_type.eq(&record.aggregate_type),
614                    snapshots::version.eq(record.version),
615                    snapshots::state.eq(&record.state),
616                    snapshots::created_at.eq(record.created_at),
617                ))
618                .execute(&mut *conn)
619                .map_err(|e| EventStoreError::database(e.to_string()))?;
620
621            Ok(())
622        })
623        .await
624        .map_err(|e| EventStoreError::other(format!("Task join error: {}", e)))?
625    }
626
627    async fn load_snapshot(&self, aggregate_id: &str) -> EventStoreResult<Option<Snapshot>> {
628        let aggregate_id = aggregate_id.to_string();
629        let pool = self.pool.clone();
630
631        tokio::task::spawn_blocking(move || {
632            let mut conn = pool.get().map_err(|e| {
633                EventStoreError::database(format!("Failed to get connection: {}", e))
634            })?;
635
636            let record: Option<SnapshotRecord> = snapshots::table
637                .filter(snapshots::aggregate_id.eq(&aggregate_id))
638                .first::<SnapshotRecord>(&mut conn)
639                .optional()
640                .map_err(|e| EventStoreError::database(e.to_string()))?;
641
642            record.map(|r| r.to_snapshot()).transpose()
643        })
644        .await
645        .map_err(|e| EventStoreError::other(format!("Task join error: {}", e)))?
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use arc_core::audit::AuditMetadata;
653    use serde_json::json;
654
655    async fn setup_test_store() -> SqliteEventStore {
656        let manager = ConnectionManager::<SqliteConnection>::new(":memory:");
657        let pool = Pool::builder()
658            .max_size(1)
659            .build(manager)
660            .expect("Failed to create pool");
661
662        let mut conn = pool.get().expect("Failed to get connection");
663        crate::test_support::migrate(&mut conn);
664        drop(conn);
665
666        SqliteEventStore::with_pool(pool)
667    }
668
669    async fn setup_integrity_test_store() -> SqliteEventStore {
670        let manager = ConnectionManager::<SqliteConnection>::new(":memory:");
671        let pool = Pool::builder()
672            .max_size(1)
673            .build(manager)
674            .expect("Failed to create pool");
675
676        let mut conn = pool.get().expect("Failed to get connection");
677        crate::test_support::migrate(&mut conn);
678        drop(conn);
679
680        SqliteEventStore::with_pool_and_integrity_key(pool, integrity_key(), "test-key")
681            .expect("integrity store")
682    }
683
684    fn integrity_key() -> Vec<u8> {
685        b"012345678901234567890123456789AB".to_vec()
686    }
687
688    /// Helper: build an event with stamped audit.
689    fn stamped_event(
690        agg_type: &str,
691        agg_id: &str,
692        sequence: i64,
693        event_type: &str,
694        payload: serde_json::Value,
695    ) -> Event {
696        Event::new(agg_type, agg_id, sequence, event_type, payload)
697            .with_audit(AuditMetadata::test_default())
698    }
699
700    #[derive(QueryableByName, Debug)]
701    struct SignatureRow {
702        #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
703        integrity_signature: Option<String>,
704        #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
705        integrity_key_id: Option<String>,
706    }
707
708    #[tokio::test]
709    async fn test_append_and_load_single_event() {
710        let store = setup_test_store().await;
711        let event = stamped_event(
712            "User",
713            "user-123",
714            1,
715            "UserCreated",
716            json!({ "name": "Alice" }),
717        );
718
719        store
720            .append("user-123", VersionCheck::New, vec![event.clone()])
721            .await
722            .unwrap();
723        let loaded = store.load("user-123").await.unwrap();
724
725        assert_eq!(loaded.len(), 1);
726        assert_eq!(loaded[0].aggregate_id, "user-123");
727        assert_eq!(loaded[0].event_type, "UserCreated");
728        assert_eq!(loaded[0].sequence, 1);
729        assert_eq!(loaded[0].audit.actor_id, "test");
730    }
731
732    #[tokio::test]
733    async fn test_append_multiple_events() {
734        let store = setup_test_store().await;
735        let events = vec![
736            stamped_event("User", "user-456", 1, "UserCreated", json!({})),
737            stamped_event("User", "user-456", 2, "ProfileUpdated", json!({})),
738            stamped_event("User", "user-456", 3, "EmailChanged", json!({})),
739        ];
740
741        store
742            .append("user-456", VersionCheck::New, events)
743            .await
744            .unwrap();
745        let loaded = store.load("user-456").await.unwrap();
746
747        assert_eq!(loaded.len(), 3);
748        assert_eq!(loaded[0].sequence, 1);
749        assert_eq!(loaded[2].sequence, 3);
750    }
751
752    #[tokio::test]
753    async fn test_integrity_append_persists_signatures() {
754        let store = setup_integrity_test_store().await;
755        store
756            .append(
757                "signed-1",
758                VersionCheck::New,
759                vec![
760                    stamped_event("User", "signed-1", 1, "UserCreated", json!({})),
761                    stamped_event("User", "signed-1", 2, "ProfileUpdated", json!({})),
762                ],
763            )
764            .await
765            .unwrap();
766
767        let pool = store.pool.clone();
768        let rows = tokio::task::spawn_blocking(move || -> EventStoreResult<Vec<SignatureRow>> {
769            let mut conn = pool
770                .get()
771                .map_err(|e| EventStoreError::database(e.to_string()))?;
772            diesel::sql_query(
773                "SELECT integrity_signature, integrity_key_id
774                 FROM events WHERE aggregate_id = 'signed-1' ORDER BY sequence",
775            )
776            .load(&mut *conn)
777            .map_err(|e| EventStoreError::database(e.to_string()))
778        })
779        .await
780        .unwrap()
781        .unwrap();
782
783        assert_eq!(rows.len(), 2);
784        for row in rows {
785            assert_eq!(row.integrity_signature.as_deref().map(str::len), Some(64));
786            assert_eq!(row.integrity_key_id.as_deref(), Some("test-key"));
787        }
788    }
789
790    #[tokio::test]
791    async fn test_integrity_load_rejects_tampered_payload() {
792        let store = setup_integrity_test_store().await;
793        store
794            .append(
795                "tamper-1",
796                VersionCheck::New,
797                vec![stamped_event(
798                    "User",
799                    "tamper-1",
800                    1,
801                    "UserCreated",
802                    json!({"ok": true}),
803                )],
804            )
805            .await
806            .unwrap();
807
808        let pool = store.pool.clone();
809        tokio::task::spawn_blocking(move || -> EventStoreResult<()> {
810            let mut conn = pool
811                .get()
812                .map_err(|e| EventStoreError::database(e.to_string()))?;
813            diesel::sql_query(
814                "UPDATE events SET payload = '{\"ok\": false}' WHERE aggregate_id = 'tamper-1'",
815            )
816            .execute(&mut *conn)
817            .map_err(|e| EventStoreError::database(e.to_string()))?;
818            Ok(())
819        })
820        .await
821        .unwrap()
822        .unwrap();
823
824        let err = store.load("tamper-1").await.unwrap_err();
825        assert!(
826            matches!(err, EventStoreError::Integrity { .. }),
827            "expected integrity error, got {err:?}"
828        );
829    }
830
831    #[tokio::test]
832    async fn test_integrity_load_rejects_missing_signature() {
833        let store = setup_integrity_test_store().await;
834        store
835            .append(
836                "missing-sig",
837                VersionCheck::New,
838                vec![stamped_event(
839                    "User",
840                    "missing-sig",
841                    1,
842                    "UserCreated",
843                    json!({}),
844                )],
845            )
846            .await
847            .unwrap();
848
849        let pool = store.pool.clone();
850        tokio::task::spawn_blocking(move || -> EventStoreResult<()> {
851            let mut conn = pool
852                .get()
853                .map_err(|e| EventStoreError::database(e.to_string()))?;
854            diesel::sql_query(
855                "UPDATE events SET integrity_signature = NULL WHERE aggregate_id = 'missing-sig'",
856            )
857            .execute(&mut *conn)
858            .map_err(|e| EventStoreError::database(e.to_string()))?;
859            Ok(())
860        })
861        .await
862        .unwrap()
863        .unwrap();
864
865        let err = store.load("missing-sig").await.unwrap_err();
866        assert!(
867            matches!(err, EventStoreError::Integrity { .. }),
868            "expected integrity error, got {err:?}"
869        );
870    }
871
872    #[tokio::test]
873    async fn test_integrity_load_from_uses_previous_signature() {
874        let store = setup_integrity_test_store().await;
875        store
876            .append(
877                "load-from-signed",
878                VersionCheck::New,
879                vec![
880                    stamped_event("User", "load-from-signed", 1, "UserCreated", json!({})),
881                    stamped_event("User", "load-from-signed", 2, "ProfileUpdated", json!({})),
882                ],
883            )
884            .await
885            .unwrap();
886
887        let loaded = store.load_from("load-from-signed", 2).await.unwrap();
888        assert_eq!(loaded.len(), 1);
889        assert_eq!(loaded[0].sequence, 2);
890    }
891
892    #[tokio::test]
893    async fn test_integrity_stream_all_verifies_per_aggregate() {
894        let store = setup_integrity_test_store().await;
895        store
896            .append(
897                "signed-a",
898                VersionCheck::New,
899                vec![stamped_event(
900                    "User",
901                    "signed-a",
902                    1,
903                    "UserCreated",
904                    json!({}),
905                )],
906            )
907            .await
908            .unwrap();
909        store
910            .append(
911                "signed-b",
912                VersionCheck::New,
913                vec![stamped_event(
914                    "User",
915                    "signed-b",
916                    1,
917                    "UserCreated",
918                    json!({}),
919                )],
920            )
921            .await
922            .unwrap();
923        store
924            .append(
925                "signed-a",
926                VersionCheck::Expected(1),
927                vec![stamped_event(
928                    "User",
929                    "signed-a",
930                    2,
931                    "ProfileUpdated",
932                    json!({}),
933                )],
934            )
935            .await
936            .unwrap();
937
938        let loaded = store.stream_all(0).await.unwrap();
939        assert_eq!(loaded.len(), 3);
940    }
941
942    #[tokio::test]
943    async fn test_optimistic_concurrency_control() {
944        let store = setup_test_store().await;
945        store
946            .append(
947                "user-789",
948                VersionCheck::New,
949                vec![stamped_event(
950                    "User",
951                    "user-789",
952                    1,
953                    "UserCreated",
954                    json!({}),
955                )],
956            )
957            .await
958            .unwrap();
959        store
960            .append(
961                "user-789",
962                VersionCheck::Expected(1),
963                vec![stamped_event(
964                    "User",
965                    "user-789",
966                    2,
967                    "ProfileUpdated",
968                    json!({}),
969                )],
970            )
971            .await
972            .unwrap();
973        let result = store
974            .append(
975                "user-789",
976                VersionCheck::Expected(1),
977                vec![stamped_event(
978                    "User",
979                    "user-789",
980                    3,
981                    "EmailChanged",
982                    json!({}),
983                )],
984            )
985            .await;
986        assert!(matches!(
987            result,
988            Err(EventStoreError::ConcurrencyConflict {
989                expected: 1,
990                actual: 2,
991                ..
992            })
993        ));
994    }
995
996    #[tokio::test]
997    async fn test_invalid_sequence() {
998        let store = setup_test_store().await;
999        let result = store
1000            .append(
1001                "user-999",
1002                VersionCheck::New,
1003                vec![stamped_event(
1004                    "User",
1005                    "user-999",
1006                    5,
1007                    "UserCreated",
1008                    json!({}),
1009                )],
1010            )
1011            .await;
1012        assert!(matches!(
1013            result,
1014            Err(EventStoreError::InvalidSequence {
1015                expected: 1,
1016                actual: 5,
1017                ..
1018            })
1019        ));
1020    }
1021
1022    #[tokio::test]
1023    async fn test_load_from_sequence() {
1024        let store = setup_test_store().await;
1025        let events = vec![
1026            stamped_event("Order", "order-1", 1, "OrderCreated", json!({})),
1027            stamped_event("Order", "order-1", 2, "ItemAdded", json!({})),
1028            stamped_event("Order", "order-1", 3, "ItemAdded", json!({})),
1029            stamped_event("Order", "order-1", 4, "OrderShipped", json!({})),
1030        ];
1031        store
1032            .append("order-1", VersionCheck::New, events)
1033            .await
1034            .unwrap();
1035        let loaded = store.load_from("order-1", 3).await.unwrap();
1036        assert_eq!(loaded.len(), 2);
1037        assert_eq!(loaded[0].sequence, 3);
1038    }
1039
1040    #[tokio::test]
1041    async fn test_get_version() {
1042        let store = setup_test_store().await;
1043        assert_eq!(store.get_version("nope").await.unwrap(), 0);
1044        let events = vec![
1045            stamped_event("User", "u1", 1, "UserCreated", json!({})),
1046            stamped_event("User", "u1", 2, "ProfileUpdated", json!({})),
1047            stamped_event("User", "u1", 3, "EmailChanged", json!({})),
1048        ];
1049        store.append("u1", VersionCheck::New, events).await.unwrap();
1050        assert_eq!(store.get_version("u1").await.unwrap(), 3);
1051    }
1052
1053    #[tokio::test]
1054    async fn test_stream_all() {
1055        let store = setup_test_store().await;
1056        store
1057            .append(
1058                "user-1",
1059                VersionCheck::New,
1060                vec![
1061                    stamped_event("User", "user-1", 1, "UserCreated", json!({})),
1062                    stamped_event("User", "user-1", 2, "ProfileUpdated", json!({})),
1063                ],
1064            )
1065            .await
1066            .unwrap();
1067        store
1068            .append(
1069                "order-1",
1070                VersionCheck::New,
1071                vec![
1072                    stamped_event("Order", "order-1", 1, "OrderCreated", json!({})),
1073                    stamped_event("Order", "order-1", 2, "OrderShipped", json!({})),
1074                ],
1075            )
1076            .await
1077            .unwrap();
1078        assert_eq!(store.stream_all(0).await.unwrap().len(), 4);
1079    }
1080
1081    #[tokio::test]
1082    async fn test_empty_aggregate() {
1083        let store = setup_test_store().await;
1084        assert_eq!(store.load("nothing").await.unwrap().len(), 0);
1085    }
1086
1087    #[tokio::test]
1088    async fn test_audit_roundtrip_preserves_all_fields() {
1089        let store = setup_test_store().await;
1090        let mut audit = AuditMetadata::test_default();
1091        audit.actor_id = "user-uuid-42".to_string();
1092        audit.actor_session_id = Some("sess-XYZ".to_string());
1093        audit.source_ip = Some("10.0.0.42".to_string());
1094        audit.user_agent = Some("Mozilla/5.0 (test)".to_string());
1095        audit.causation_id = Some(Uuid::new_v4());
1096        let expected_corr = audit.correlation_id;
1097        let expected_caus = audit.causation_id;
1098
1099        let event =
1100            Event::new("User", "u-audit", 1, "UserCreated", json!({})).with_audit(audit.clone());
1101
1102        store
1103            .append("u-audit", VersionCheck::New, vec![event])
1104            .await
1105            .unwrap();
1106        let loaded = store.load("u-audit").await.unwrap();
1107
1108        assert_eq!(loaded[0].audit.actor_id, "user-uuid-42");
1109        assert_eq!(
1110            loaded[0].audit.actor_session_id.as_deref(),
1111            Some("sess-XYZ")
1112        );
1113        assert_eq!(loaded[0].audit.source_ip.as_deref(), Some("10.0.0.42"));
1114        assert_eq!(
1115            loaded[0].audit.user_agent.as_deref(),
1116            Some("Mozilla/5.0 (test)")
1117        );
1118        assert_eq!(loaded[0].audit.correlation_id, expected_corr);
1119        assert_eq!(loaded[0].audit.causation_id, expected_caus);
1120        assert!(loaded[0].audit.timestamp_utc_us > 0);
1121    }
1122
1123    #[tokio::test]
1124    async fn test_append_rejects_pending_audit() {
1125        let store = setup_test_store().await;
1126        // Built without with_audit — audit stays pending.
1127        let event = Event::new("User", "u-bad", 1, "UserCreated", json!({}));
1128        let err = store
1129            .append("u-bad", VersionCheck::New, vec![event])
1130            .await
1131            .unwrap_err();
1132        assert!(matches!(err, EventStoreError::InvalidAudit { .. }));
1133
1134        // No row should have been written.
1135        assert_eq!(store.load("u-bad").await.unwrap().len(), 0);
1136    }
1137
1138    #[tokio::test]
1139    async fn test_actor_id_index_used() {
1140        let store = setup_test_store().await;
1141        let mut a = AuditMetadata::test_default();
1142        a.actor_id = "alice-uuid".to_string();
1143        let event = Event::new("User", "u1", 1, "UserCreated", json!({})).with_audit(a);
1144        store
1145            .append("u1", VersionCheck::New, vec![event])
1146            .await
1147            .unwrap();
1148
1149        // EXPLAIN QUERY PLAN must show an index search on actor_id
1150        let pool = store.pool.clone();
1151        let plan = tokio::task::spawn_blocking(move || -> EventStoreResult<Vec<String>> {
1152            let mut conn = pool
1153                .get()
1154                .map_err(|e| EventStoreError::database(e.to_string()))?;
1155            let plan: Vec<(i32, i32, i32, String)> = diesel::sql_query(
1156                "EXPLAIN QUERY PLAN SELECT * FROM events WHERE actor_id = 'alice-uuid'",
1157            )
1158            .load::<ExplainRow>(&mut *conn)
1159            .map_err(|e| EventStoreError::database(e.to_string()))?
1160            .into_iter()
1161            .map(|r| (r.id, r.parent, r.notused, r.detail))
1162            .collect();
1163            Ok(plan.into_iter().map(|(_, _, _, d)| d).collect())
1164        })
1165        .await
1166        .unwrap()
1167        .unwrap();
1168
1169        assert!(
1170            plan.iter().any(|d| d.contains("idx_events_actor_id")),
1171            "actor_id query did not use index; plan: {:?}",
1172            plan
1173        );
1174    }
1175
1176    #[derive(QueryableByName, Debug)]
1177    struct ExplainRow {
1178        #[diesel(sql_type = diesel::sql_types::Integer)]
1179        id: i32,
1180        #[diesel(sql_type = diesel::sql_types::Integer)]
1181        parent: i32,
1182        #[diesel(sql_type = diesel::sql_types::Integer)]
1183        notused: i32,
1184        #[diesel(sql_type = diesel::sql_types::Text)]
1185        detail: String,
1186    }
1187
1188    #[tokio::test]
1189    async fn test_concurrent_appends() {
1190        let store = setup_test_store().await;
1191        store
1192            .append(
1193                "uc",
1194                VersionCheck::New,
1195                vec![stamped_event("User", "uc", 1, "UserCreated", json!({}))],
1196            )
1197            .await
1198            .unwrap();
1199
1200        let s1 = store.clone();
1201        let s2 = store.clone();
1202        let h1 = tokio::spawn(async move {
1203            s1.append(
1204                "uc",
1205                VersionCheck::Expected(1),
1206                vec![stamped_event("User", "uc", 2, "U1", json!({}))],
1207            )
1208            .await
1209        });
1210        let h2 = tokio::spawn(async move {
1211            s2.append(
1212                "uc",
1213                VersionCheck::Expected(1),
1214                vec![stamped_event("User", "uc", 2, "U2", json!({}))],
1215            )
1216            .await
1217        });
1218        let r1 = h1.await.unwrap();
1219        let r2 = h2.await.unwrap();
1220        assert!(r1.is_ok() != r2.is_ok());
1221    }
1222
1223    #[tokio::test]
1224    async fn test_sequence_above_i32_max_roundtrips_without_truncation() {
1225        // Pre-fix bug: sequence was cast to i32 on insert and read back as i32.
1226        // A value above i32::MAX (2_147_483_647) would silently overflow.
1227        // After widening, this round-trips intact.
1228        let store = setup_test_store().await;
1229        let pool = store.pool.clone();
1230
1231        let huge_seq: i64 = (i32::MAX as i64) + 1234;
1232        let huge_ts: i64 = 9_999_999_999; // year 2286 — would never fit in i32
1233
1234        let inserted = tokio::task::spawn_blocking(move || -> EventStoreResult<usize> {
1235            let mut conn = pool
1236                .get()
1237                .map_err(|e| EventStoreError::database(e.to_string()))?;
1238            diesel::sql_query(format!(
1239                "INSERT INTO events (event_id, aggregate_type, aggregate_id, sequence,
1240                    event_type, payload, timestamp,
1241                    actor_id, timestamp_utc_us, correlation_id)
1242                 VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'User', 'u-big', {seq},
1243                    'Event', '{{}}', {ts},
1244                    'tester', {seq}, '00000000-0000-0000-0000-000000000001')",
1245                seq = huge_seq,
1246                ts = huge_ts,
1247            ))
1248            .execute(&mut *conn)
1249            .map_err(|e| EventStoreError::database(e.to_string()))
1250        })
1251        .await
1252        .unwrap()
1253        .unwrap();
1254        assert_eq!(inserted, 1);
1255
1256        let loaded = store.load("u-big").await.expect("load");
1257        assert_eq!(loaded.len(), 1);
1258        assert_eq!(loaded[0].sequence, huge_seq, "sequence must not truncate");
1259        // timestamp stored as seconds; round-trip back to milliseconds in Event::timestamp
1260        assert_eq!(loaded[0].timestamp, (huge_ts as u64) * 1000);
1261
1262        let v = store.get_version("u-big").await.expect("version");
1263        assert_eq!(v, huge_seq, "get_version must not truncate either");
1264    }
1265
1266    #[tokio::test]
1267    async fn test_legacy_backfilled_row_roundtrips() {
1268        // Simulates a row written before HIPAA-1, then backfilled by the migration:
1269        // actor_id='legacy-pre-hipaa', timestamp_utc_us derived from seconds*1_000_000,
1270        // correlation_id = nil UUID. The row must load without panicking.
1271        let store = setup_test_store().await;
1272        let pool = store.pool.clone();
1273
1274        let inserted_count = tokio::task::spawn_blocking(move || -> EventStoreResult<usize> {
1275            let mut conn = pool
1276                .get()
1277                .map_err(|e| EventStoreError::database(e.to_string()))?;
1278            diesel::sql_query(
1279                "INSERT INTO events (event_id, aggregate_type, aggregate_id, sequence,
1280                    event_type, payload, timestamp,
1281                    actor_id, timestamp_utc_us, correlation_id)
1282                 VALUES ('11111111-1111-1111-1111-111111111111', 'User', 'u-legacy', 1,
1283                    'UserCreated', '{}', 1700000000,
1284                    'legacy-pre-hipaa', 1700000000000000,
1285                    '00000000-0000-0000-0000-000000000000')",
1286            )
1287            .execute(&mut *conn)
1288            .map_err(|e| EventStoreError::database(e.to_string()))
1289        })
1290        .await
1291        .unwrap()
1292        .unwrap();
1293        assert_eq!(inserted_count, 1);
1294
1295        let loaded = store.load("u-legacy").await.expect("legacy row must load");
1296        assert_eq!(loaded.len(), 1);
1297        assert_eq!(loaded[0].audit.actor_id, "legacy-pre-hipaa");
1298        assert_eq!(loaded[0].audit.timestamp_utc_us, 1_700_000_000_000_000);
1299        assert_eq!(loaded[0].audit.correlation_id, Uuid::nil());
1300        assert!(loaded[0].audit.causation_id.is_none());
1301    }
1302
1303    #[tokio::test]
1304    async fn test_load_rejects_malformed_correlation_uuid() {
1305        let store = setup_test_store().await;
1306        let pool = store.pool.clone();
1307
1308        tokio::task::spawn_blocking(move || -> EventStoreResult<()> {
1309            let mut conn = pool
1310                .get()
1311                .map_err(|e| EventStoreError::database(e.to_string()))?;
1312            diesel::sql_query(
1313                "INSERT INTO events (event_id, aggregate_type, aggregate_id, sequence,
1314                    event_type, payload, timestamp,
1315                    actor_id, timestamp_utc_us, correlation_id)
1316                 VALUES ('22222222-2222-2222-2222-222222222222', 'User', 'u-bad', 1,
1317                    'X', '{}', 1700000000,
1318                    'tester', 1700000000000000,
1319                    'not-a-uuid')",
1320            )
1321            .execute(&mut *conn)
1322            .map_err(|e| EventStoreError::database(e.to_string()))?;
1323            Ok(())
1324        })
1325        .await
1326        .unwrap()
1327        .unwrap();
1328
1329        let err = store.load("u-bad").await.unwrap_err();
1330        assert!(
1331            matches!(err, EventStoreError::SerializationError { ref message } if message.contains("Invalid correlation UUID")),
1332            "expected SerializationError on malformed correlation_id, got {:?}",
1333            err
1334        );
1335    }
1336
1337    #[tokio::test]
1338    async fn test_caused_by_chain_roundtrips_through_sqlite() {
1339        use arc_core::aggregate::{Aggregate, Command};
1340        use arc_core::command_bus::{CommandBus, CommandContext};
1341        use arc_core::event::Event as CoreEvent;
1342        use arc_core::event_bus::InProcessEventBus;
1343
1344        // Trivial aggregate so we can exercise CommandBus + SqliteEventStore together.
1345        #[derive(Default)]
1346        struct Counter {
1347            v: i64,
1348        }
1349        struct Cmd {
1350            id: String,
1351        }
1352        impl Command for Cmd {
1353            fn aggregate_id(&self) -> &str {
1354                &self.id
1355            }
1356        }
1357        #[derive(Debug, thiserror::Error)]
1358        #[error("never")]
1359        struct Never;
1360        #[async_trait]
1361        impl Aggregate for Counter {
1362            type Command = Cmd;
1363            type Event = ();
1364            type Error = Never;
1365            fn aggregate_type() -> &'static str {
1366                "Counter"
1367            }
1368            fn version(&self) -> i64 {
1369                self.v
1370            }
1371            async fn handle(&self, c: Self::Command) -> Result<Vec<CoreEvent>, Self::Error> {
1372                Ok(vec![CoreEvent::new(
1373                    "Counter",
1374                    &c.id,
1375                    self.v + 1,
1376                    "Incremented",
1377                    serde_json::json!({}),
1378                )])
1379            }
1380            fn apply(&mut self, e: &CoreEvent) {
1381                self.v = e.sequence;
1382            }
1383        }
1384
1385        let store = setup_test_store().await;
1386        let bus =
1387            CommandBus::<Counter>::new(Box::new(store.clone()), Box::new(InProcessEventBus::new()));
1388
1389        let first_ctx = CommandContext::for_actor("alice");
1390        let trigger_corr = first_ctx.correlation_id;
1391        let triggers = bus
1392            .dispatch(Cmd { id: "c1".into() }, first_ctx)
1393            .await
1394            .unwrap();
1395
1396        let follow_ctx = CommandContext::caused_by("worker", &triggers[0]);
1397        let follow_corr = follow_ctx.correlation_id;
1398        let _ = bus
1399            .dispatch(Cmd { id: "c2".into() }, follow_ctx)
1400            .await
1401            .unwrap();
1402
1403        // Reload from SQLite — chain must survive the trip
1404        let loaded_c2 = store.load("c2").await.unwrap();
1405        assert_eq!(loaded_c2.len(), 1);
1406        assert_eq!(loaded_c2[0].audit.correlation_id, trigger_corr);
1407        assert_eq!(loaded_c2[0].audit.correlation_id, follow_corr);
1408        assert_eq!(loaded_c2[0].audit.causation_id, Some(triggers[0].event_id));
1409    }
1410
1411    #[tokio::test]
1412    async fn test_event_ordering_within_aggregate() {
1413        let store = setup_test_store().await;
1414        store
1415            .append(
1416                "uo",
1417                VersionCheck::New,
1418                vec![
1419                    stamped_event("User", "uo", 1, "UserCreated", json!({})),
1420                    stamped_event("User", "uo", 2, "EmailChanged", json!({})),
1421                ],
1422            )
1423            .await
1424            .unwrap();
1425        store
1426            .append(
1427                "uo",
1428                VersionCheck::Expected(2),
1429                vec![
1430                    stamped_event("User", "uo", 3, "ProfileUpdated", json!({})),
1431                    stamped_event("User", "uo", 4, "PasswordChanged", json!({})),
1432                ],
1433            )
1434            .await
1435            .unwrap();
1436        let loaded = store.load("uo").await.unwrap();
1437        for (i, e) in loaded.iter().enumerate() {
1438            assert_eq!(e.sequence, (i + 1) as i64);
1439        }
1440    }
1441
1442    #[tokio::test]
1443    async fn test_save_then_load_snapshot() {
1444        let store = setup_test_store().await;
1445        let snap = Snapshot::new("agg-1", "User", 5, json!({ "name": "Alice" }));
1446        store.save_snapshot(&snap).await.unwrap();
1447        let loaded = store.load_snapshot("agg-1").await.unwrap();
1448        assert_eq!(loaded, Some(snap));
1449    }
1450
1451    #[tokio::test]
1452    async fn test_load_snapshot_unknown_returns_none() {
1453        let store = setup_test_store().await;
1454        assert_eq!(store.load_snapshot("missing").await.unwrap(), None);
1455    }
1456
1457    #[tokio::test]
1458    async fn test_save_snapshot_upserts_newer_version() {
1459        let store = setup_test_store().await;
1460        store
1461            .save_snapshot(&Snapshot::new("agg-2", "User", 3, json!({ "v": 3 })))
1462            .await
1463            .unwrap();
1464        let newer = Snapshot::new("agg-2", "User", 9, json!({ "v": 9 }));
1465        store.save_snapshot(&newer).await.unwrap();
1466        let loaded = store.load_snapshot("agg-2").await.unwrap().unwrap();
1467        assert_eq!(loaded.version, 9);
1468        assert_eq!(loaded.state["v"], 9);
1469    }
1470}