1use 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
25pub 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#[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 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#[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#[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 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 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 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 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 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 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 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; 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 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 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 #[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 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}