1use arc_core::audit::AuditMetadata;
2use arc_core::event::Event;
3use arc_core::event_store::{
4 validate_audit_batch, EventStore, EventStoreError, EventStoreResult, VersionCheck,
5};
6use arc_core::integrity::{EventSignature, HmacSha256Chain, IntegrityChain, IntegrityError};
7use arc_core::snapshot::Snapshot;
8use async_trait::async_trait;
9use sqlx::postgres::{PgPool, PgPoolOptions};
10use sqlx::Row;
11use std::sync::Arc;
12use uuid::Uuid;
13
14pub use arc_core::{Deserialize, Serialize};
16
17pub mod read_model_store;
18pub use read_model_store::PostgresReadModelStore;
19
20const EVENTS_SCHEMA: &str = r#"
22CREATE TABLE IF NOT EXISTS events (
23 id BIGSERIAL PRIMARY KEY,
24 event_id TEXT NOT NULL UNIQUE,
25 aggregate_type TEXT NOT NULL,
26 aggregate_id TEXT NOT NULL,
27 sequence BIGINT NOT NULL,
28 event_type TEXT NOT NULL,
29 payload JSONB NOT NULL,
30 "timestamp" BIGINT NOT NULL,
31 actor_id TEXT NOT NULL DEFAULT 'legacy-pre-hipaa',
32 actor_session_id TEXT,
33 source_ip TEXT,
34 user_agent TEXT,
35 timestamp_utc_us BIGINT NOT NULL DEFAULT 0,
36 causation_id TEXT,
37 correlation_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
38 integrity_signature TEXT,
39 integrity_key_id TEXT,
40 UNIQUE(aggregate_id, sequence)
41);
42CREATE INDEX IF NOT EXISTS idx_events_aggregate ON events(aggregate_id, sequence);
43CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
44CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events("timestamp");
45CREATE INDEX IF NOT EXISTS idx_events_actor_id ON events(actor_id);
46CREATE INDEX IF NOT EXISTS idx_events_correlation_id ON events(correlation_id);
47"#;
48
49const SNAPSHOTS_SCHEMA: &str = r#"
51CREATE TABLE IF NOT EXISTS snapshots (
52 aggregate_id TEXT NOT NULL PRIMARY KEY,
53 aggregate_type TEXT NOT NULL,
54 version BIGINT NOT NULL,
55 state JSONB NOT NULL,
56 created_at BIGINT NOT NULL
57);
58"#;
59
60#[derive(Debug, Clone, PartialEq)]
64struct EventRow {
65 event_id: String,
66 aggregate_type: String,
67 aggregate_id: String,
68 sequence: i64,
69 event_type: String,
70 payload: serde_json::Value,
71 timestamp: i64,
72 actor_id: String,
73 actor_session_id: Option<String>,
74 source_ip: Option<String>,
75 user_agent: Option<String>,
76 timestamp_utc_us: i64,
77 causation_id: Option<String>,
78 correlation_id: String,
79 integrity_signature: Option<String>,
80 integrity_key_id: Option<String>,
81}
82
83impl EventRow {
84 fn from_event(
85 event: &Event,
86 integrity_signature: Option<String>,
87 integrity_key_id: Option<String>,
88 ) -> EventRow {
89 let timestamp_seconds: i64 = (event.timestamp / 1000) as i64;
91 EventRow {
92 event_id: event.event_id.to_string(),
93 aggregate_type: event.aggregate_type.clone(),
94 aggregate_id: event.aggregate_id.clone(),
95 sequence: event.sequence,
96 event_type: event.event_type.clone(),
97 payload: event.payload.clone(),
98 timestamp: timestamp_seconds,
99 actor_id: event.audit.actor_id.clone(),
100 actor_session_id: event.audit.actor_session_id.clone(),
101 source_ip: event.audit.source_ip.clone(),
102 user_agent: event.audit.user_agent.clone(),
103 timestamp_utc_us: event.audit.timestamp_utc_us,
104 causation_id: event.audit.causation_id.map(|u| u.to_string()),
105 correlation_id: event.audit.correlation_id.to_string(),
106 integrity_signature,
107 integrity_key_id,
108 }
109 }
110
111 fn to_event(&self) -> EventStoreResult<Event> {
112 let event_id = Uuid::parse_str(&self.event_id)
113 .map_err(|e| EventStoreError::serialization(format!("Invalid UUID: {}", e)))?;
114
115 let causation_id = match self.causation_id.as_deref() {
116 Some(s) => Some(Uuid::parse_str(s).map_err(|e| {
117 EventStoreError::serialization(format!("Invalid causation UUID: {}", e))
118 })?),
119 None => None,
120 };
121
122 let correlation_id = Uuid::parse_str(&self.correlation_id).map_err(|e| {
123 EventStoreError::serialization(format!("Invalid correlation UUID: {}", e))
124 })?;
125
126 let audit = AuditMetadata {
127 actor_id: self.actor_id.clone(),
128 actor_session_id: self.actor_session_id.clone(),
129 source_ip: self.source_ip.clone(),
130 user_agent: self.user_agent.clone(),
131 timestamp_utc_us: self.timestamp_utc_us,
132 causation_id,
133 correlation_id,
134 };
135
136 Ok(Event {
137 event_id,
138 aggregate_type: self.aggregate_type.clone(),
139 aggregate_id: self.aggregate_id.clone(),
140 sequence: self.sequence,
141 event_type: self.event_type.clone(),
142 payload: self.payload.clone(),
143 audit,
144 timestamp: (self.timestamp as u64) * 1000,
145 })
146 }
147
148 fn from_pg_row(row: &sqlx::postgres::PgRow) -> EventStoreResult<EventRow> {
149 let map = |e: sqlx::Error| EventStoreError::database(e.to_string());
150 Ok(EventRow {
151 event_id: row.try_get("event_id").map_err(map)?,
152 aggregate_type: row.try_get("aggregate_type").map_err(map)?,
153 aggregate_id: row.try_get("aggregate_id").map_err(map)?,
154 sequence: row.try_get("sequence").map_err(map)?,
155 event_type: row.try_get("event_type").map_err(map)?,
156 payload: row.try_get("payload").map_err(map)?,
157 timestamp: row.try_get("timestamp").map_err(map)?,
158 actor_id: row.try_get("actor_id").map_err(map)?,
159 actor_session_id: row.try_get("actor_session_id").map_err(map)?,
160 source_ip: row.try_get("source_ip").map_err(map)?,
161 user_agent: row.try_get("user_agent").map_err(map)?,
162 timestamp_utc_us: row.try_get("timestamp_utc_us").map_err(map)?,
163 causation_id: row.try_get("causation_id").map_err(map)?,
164 correlation_id: row.try_get("correlation_id").map_err(map)?,
165 integrity_signature: row.try_get("integrity_signature").map_err(map)?,
166 integrity_key_id: row.try_get("integrity_key_id").map_err(map)?,
167 })
168 }
169}
170
171#[derive(Clone)]
173pub struct PostgresEventStore {
174 pool: PgPool,
175 integrity: Option<Arc<IntegrityConfig>>,
176}
177
178struct IntegrityConfig {
179 chain: Arc<dyn IntegrityChain>,
180 key_id: String,
181}
182
183impl PostgresEventStore {
184 pub async fn new(database_url: &str) -> EventStoreResult<Self> {
186 let pool = PgPoolOptions::new()
187 .max_connections(10)
188 .connect(database_url)
189 .await
190 .map_err(|e| EventStoreError::database(format!("Failed to create pool: {}", e)))?;
191 Ok(PostgresEventStore {
192 pool,
193 integrity: None,
194 })
195 }
196
197 pub async fn new_with_integrity_key(
199 database_url: &str,
200 key: impl Into<Vec<u8>>,
201 key_id: impl Into<String>,
202 ) -> EventStoreResult<Self> {
203 let pool = PgPoolOptions::new()
204 .max_connections(10)
205 .connect(database_url)
206 .await
207 .map_err(|e| EventStoreError::database(format!("Failed to create pool: {}", e)))?;
208 Ok(PostgresEventStore {
209 pool,
210 integrity: Some(Arc::new(IntegrityConfig {
211 chain: Arc::new(HmacSha256Chain::new(key).map_err(EventStoreError::from)?),
212 key_id: key_id.into(),
213 })),
214 })
215 }
216
217 pub fn with_pool(pool: PgPool) -> Self {
220 PostgresEventStore {
221 pool,
222 integrity: None,
223 }
224 }
225
226 pub fn with_pool_and_integrity_key(
228 pool: PgPool,
229 key: impl Into<Vec<u8>>,
230 key_id: impl Into<String>,
231 ) -> EventStoreResult<Self> {
232 Ok(PostgresEventStore {
233 pool,
234 integrity: Some(Arc::new(IntegrityConfig {
235 chain: Arc::new(HmacSha256Chain::new(key).map_err(EventStoreError::from)?),
236 key_id: key_id.into(),
237 })),
238 })
239 }
240
241 pub fn pool(&self) -> &PgPool {
244 &self.pool
245 }
246
247 pub async fn initialize_schema(&self) -> EventStoreResult<()> {
250 sqlx::raw_sql(EVENTS_SCHEMA)
251 .execute(&self.pool)
252 .await
253 .map_err(|e| EventStoreError::database(e.to_string()))?;
254 sqlx::raw_sql(SNAPSHOTS_SCHEMA)
255 .execute(&self.pool)
256 .await
257 .map_err(|e| EventStoreError::database(e.to_string()))?;
258 Ok(())
259 }
260
261 async fn required_signature(
262 &self,
263 row: &EventRow,
264 aggregate_id: &str,
265 sequence: i64,
266 ) -> EventStoreResult<EventSignature> {
267 let _key_id = row.integrity_key_id.as_ref().ok_or_else(|| {
268 EventStoreError::from(IntegrityError::BrokenAt {
269 aggregate_id: aggregate_id.to_string(),
270 sequence,
271 })
272 })?;
273
274 row.integrity_signature
275 .as_ref()
276 .map(|s| EventSignature(s.clone()))
277 .ok_or_else(|| {
278 EventStoreError::from(IntegrityError::BrokenAt {
279 aggregate_id: aggregate_id.to_string(),
280 sequence,
281 })
282 })
283 }
284
285 async fn previous_signature_for_aggregate(
286 &self,
287 executor: &mut sqlx::Transaction<'_, sqlx::Postgres>,
288 aggregate_id: &str,
289 before_sequence: i64,
290 ) -> EventStoreResult<EventSignature> {
291 if before_sequence <= 1 {
292 return Ok(EventSignature::genesis());
293 }
294
295 let row = sqlx::query(
296 "SELECT * FROM events WHERE aggregate_id = $1 AND sequence < $2 ORDER BY sequence DESC LIMIT 1",
297 )
298 .bind(aggregate_id)
299 .bind(before_sequence)
300 .fetch_optional(&mut **executor)
301 .await
302 .map_err(|e| EventStoreError::database(e.to_string()))?;
303
304 match row {
305 Some(r) => {
306 let event_row = EventRow::from_pg_row(&r)?;
307 self.required_signature(&event_row, aggregate_id, event_row.sequence)
308 .await
309 }
310 None => Ok(EventSignature::genesis()),
311 }
312 }
313
314 async fn verify_integrity_rows(
315 &self,
316 integrity: &IntegrityConfig,
317 rows: &[EventRow],
318 previous_signature: EventSignature,
319 ) -> EventStoreResult<Vec<Event>> {
320 let mut previous = previous_signature;
321 let mut events = Vec::with_capacity(rows.len());
322
323 for row in rows {
324 let event = row.to_event()?;
325 let expected = integrity.chain.sign_event(&previous, &event)?;
326 let claimed = self
327 .required_signature(row, &event.aggregate_id, event.sequence)
328 .await?;
329
330 if expected != claimed {
331 return Err(EventStoreError::from(IntegrityError::BrokenAt {
332 aggregate_id: event.aggregate_id,
333 sequence: event.sequence,
334 }));
335 }
336
337 previous = claimed;
338 events.push(event);
339 }
340
341 Ok(events)
342 }
343
344 async fn verify_stream_integrity_rows(
345 &self,
346 integrity: &IntegrityConfig,
347 rows: &[EventRow],
348 ) -> EventStoreResult<Vec<Event>> {
349 use std::collections::HashMap;
350
351 let mut previous_by_aggregate: HashMap<String, EventSignature> = HashMap::new();
352 let mut events = Vec::with_capacity(rows.len());
353
354 for row in rows {
355 let event = row.to_event()?;
356 let previous = match previous_by_aggregate.get(&event.aggregate_id) {
357 Some(sig) => sig.clone(),
358 None => {
359 self.previous_signature_no_tx(&event.aggregate_id, event.sequence)
360 .await?
361 }
362 };
363
364 let expected = integrity.chain.sign_event(&previous, &event)?;
365 let claimed = self
366 .required_signature(row, &event.aggregate_id, event.sequence)
367 .await?;
368
369 if expected != claimed {
370 return Err(EventStoreError::from(IntegrityError::BrokenAt {
371 aggregate_id: event.aggregate_id,
372 sequence: event.sequence,
373 }));
374 }
375
376 previous_by_aggregate.insert(event.aggregate_id.clone(), claimed);
377 events.push(event);
378 }
379
380 Ok(events)
381 }
382
383 async fn previous_signature_no_tx(
384 &self,
385 aggregate_id: &str,
386 before_sequence: i64,
387 ) -> EventStoreResult<EventSignature> {
388 if before_sequence <= 1 {
389 return Ok(EventSignature::genesis());
390 }
391
392 let row = sqlx::query(
393 "SELECT * FROM events WHERE aggregate_id = $1 AND sequence < $2 ORDER BY sequence DESC LIMIT 1",
394 )
395 .bind(aggregate_id)
396 .bind(before_sequence)
397 .fetch_optional(&self.pool)
398 .await
399 .map_err(|e| EventStoreError::database(e.to_string()))?;
400
401 match row {
402 Some(r) => {
403 let event_row = EventRow::from_pg_row(&r)?;
404 self.required_signature(&event_row, aggregate_id, event_row.sequence)
405 .await
406 }
407 None => Ok(EventSignature::genesis()),
408 }
409 }
410}
411
412#[async_trait]
413impl EventStore for PostgresEventStore {
414 async fn append(
415 &self,
416 aggregate_id: &str,
417 version_check: VersionCheck,
418 new_events: Vec<Event>,
419 ) -> EventStoreResult<()> {
420 if new_events.is_empty() {
421 return Ok(());
422 }
423
424 validate_audit_batch(aggregate_id, &new_events)?;
426
427 let mut tx = self
428 .pool
429 .begin()
430 .await
431 .map_err(|e| EventStoreError::database(e.to_string()))?;
432
433 let current_version: i64 = sqlx::query(
434 "SELECT COALESCE(MAX(sequence), 0) AS v FROM events WHERE aggregate_id = $1",
435 )
436 .bind(aggregate_id)
437 .fetch_one(&mut *tx)
438 .await
439 .map_err(|e| EventStoreError::database(e.to_string()))?
440 .try_get("v")
441 .map_err(|e| EventStoreError::database(e.to_string()))?;
442
443 if let Some(expected) = version_check.version() {
444 if current_version != expected {
445 return Err(EventStoreError::ConcurrencyConflict {
446 aggregate_id: aggregate_id.to_string(),
447 expected,
448 actual: current_version,
449 });
450 }
451 }
452
453 for (expected_sequence, event) in (current_version + 1..).zip(new_events.iter()) {
454 if event.sequence != expected_sequence {
455 return Err(EventStoreError::InvalidSequence {
456 aggregate_id: aggregate_id.to_string(),
457 expected: expected_sequence,
458 actual: event.sequence,
459 });
460 }
461 }
462
463 let mut previous_signature = if self.integrity.is_some() {
464 self.previous_signature_for_aggregate(&mut tx, aggregate_id, current_version + 1)
465 .await?
466 } else {
467 EventSignature::genesis()
468 };
469
470 for event in &new_events {
471 let mut signature_str = None;
472 let mut key_id_str = None;
473
474 if let Some(integrity) = self.integrity.as_ref() {
475 let timestamp_seconds = (event.timestamp / 1000) as i64;
477 let mut persisted_event = event.clone();
478 persisted_event.timestamp = (timestamp_seconds as u64) * 1000;
479
480 let signature = integrity
481 .chain
482 .sign_event(&previous_signature, &persisted_event)
483 .map_err(EventStoreError::from)?;
484 previous_signature = signature.clone();
485 signature_str = Some(signature.0);
486 key_id_str = Some(integrity.key_id.clone());
487 }
488
489 let row = EventRow::from_event(event, signature_str, key_id_str);
490 sqlx::query(
491 r#"INSERT INTO events
492 (event_id, aggregate_type, aggregate_id, sequence, event_type, payload,
493 "timestamp", actor_id, actor_session_id, source_ip, user_agent,
494 timestamp_utc_us, causation_id, correlation_id,
495 integrity_signature, integrity_key_id)
496 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)"#,
497 )
498 .bind(&row.event_id)
499 .bind(&row.aggregate_type)
500 .bind(&row.aggregate_id)
501 .bind(row.sequence)
502 .bind(&row.event_type)
503 .bind(&row.payload)
504 .bind(row.timestamp)
505 .bind(&row.actor_id)
506 .bind(&row.actor_session_id)
507 .bind(&row.source_ip)
508 .bind(&row.user_agent)
509 .bind(row.timestamp_utc_us)
510 .bind(&row.causation_id)
511 .bind(&row.correlation_id)
512 .bind(&row.integrity_signature)
513 .bind(&row.integrity_key_id)
514 .execute(&mut *tx)
515 .await
516 .map_err(|e| EventStoreError::database(e.to_string()))?;
517 }
518
519 tx.commit()
520 .await
521 .map_err(|e| EventStoreError::database(e.to_string()))?;
522 Ok(())
523 }
524
525 async fn load(&self, aggregate_id: &str) -> EventStoreResult<Vec<Event>> {
526 self.load_from(aggregate_id, 1).await
527 }
528
529 async fn load_from(
530 &self,
531 aggregate_id: &str,
532 from_sequence: i64,
533 ) -> EventStoreResult<Vec<Event>> {
534 let rows = sqlx::query(
535 "SELECT * FROM events WHERE aggregate_id = $1 AND sequence >= $2 ORDER BY sequence ASC",
536 )
537 .bind(aggregate_id)
538 .bind(from_sequence)
539 .fetch_all(&self.pool)
540 .await
541 .map_err(|e| EventStoreError::database(e.to_string()))?;
542
543 let event_rows: Vec<EventRow> = rows
544 .iter()
545 .map(EventRow::from_pg_row)
546 .collect::<EventStoreResult<_>>()?;
547
548 match self.integrity.as_ref() {
549 Some(integrity) => {
550 let mut tx = self
551 .pool
552 .begin()
553 .await
554 .map_err(|e| EventStoreError::database(e.to_string()))?;
555 let previous = self
556 .previous_signature_for_aggregate(&mut tx, aggregate_id, from_sequence)
557 .await?;
558 self.verify_integrity_rows(integrity, &event_rows, previous)
559 .await
560 }
561 None => event_rows
562 .iter()
563 .map(|r| r.to_event())
564 .collect::<EventStoreResult<_>>(),
565 }
566 }
567
568 async fn stream_all(&self, from_position: i64) -> EventStoreResult<Vec<Event>> {
569 let rows = sqlx::query("SELECT * FROM events WHERE id >= $1 ORDER BY id ASC")
570 .bind(from_position)
571 .fetch_all(&self.pool)
572 .await
573 .map_err(|e| EventStoreError::database(e.to_string()))?;
574
575 let event_rows: Vec<EventRow> = rows
576 .iter()
577 .map(EventRow::from_pg_row)
578 .collect::<EventStoreResult<_>>()?;
579
580 match self.integrity.as_ref() {
581 Some(integrity) => {
582 self.verify_stream_integrity_rows(integrity, &event_rows)
583 .await
584 }
585 None => event_rows
586 .iter()
587 .map(|r| r.to_event())
588 .collect::<EventStoreResult<_>>(),
589 }
590 }
591
592 async fn get_version(&self, aggregate_id: &str) -> EventStoreResult<i64> {
593 let version: i64 = sqlx::query(
594 "SELECT COALESCE(MAX(sequence), 0) AS v FROM events WHERE aggregate_id = $1",
595 )
596 .bind(aggregate_id)
597 .fetch_one(&self.pool)
598 .await
599 .map_err(|e| EventStoreError::database(e.to_string()))?
600 .try_get("v")
601 .map_err(|e| EventStoreError::database(e.to_string()))?;
602 Ok(version)
603 }
604
605 async fn save_snapshot(&self, snapshot: &Snapshot) -> EventStoreResult<()> {
606 sqlx::query(
609 r#"INSERT INTO snapshots (aggregate_id, aggregate_type, version, state, created_at)
610 VALUES ($1, $2, $3, $4, $5)
611 ON CONFLICT (aggregate_id) DO UPDATE
612 SET aggregate_type = EXCLUDED.aggregate_type,
613 version = EXCLUDED.version,
614 state = EXCLUDED.state,
615 created_at = EXCLUDED.created_at"#,
616 )
617 .bind(&snapshot.aggregate_id)
618 .bind(&snapshot.aggregate_type)
619 .bind(snapshot.version)
620 .bind(&snapshot.state)
621 .bind(snapshot.created_at as i64)
622 .execute(&self.pool)
623 .await
624 .map_err(|e| EventStoreError::database(e.to_string()))?;
625 Ok(())
626 }
627
628 async fn load_snapshot(&self, aggregate_id: &str) -> EventStoreResult<Option<Snapshot>> {
629 let row = sqlx::query(
630 "SELECT aggregate_id, aggregate_type, version, state, created_at \
631 FROM snapshots WHERE aggregate_id = $1",
632 )
633 .bind(aggregate_id)
634 .fetch_optional(&self.pool)
635 .await
636 .map_err(|e| EventStoreError::database(e.to_string()))?;
637
638 match row {
639 Some(r) => {
640 let created_at: i64 = r
641 .try_get("created_at")
642 .map_err(|e| EventStoreError::database(e.to_string()))?;
643 Ok(Some(Snapshot {
644 aggregate_id: r
645 .try_get("aggregate_id")
646 .map_err(|e| EventStoreError::database(e.to_string()))?,
647 aggregate_type: r
648 .try_get("aggregate_type")
649 .map_err(|e| EventStoreError::database(e.to_string()))?,
650 version: r
651 .try_get("version")
652 .map_err(|e| EventStoreError::database(e.to_string()))?,
653 state: r
654 .try_get("state")
655 .map_err(|e| EventStoreError::database(e.to_string()))?,
656 created_at: created_at as u64,
657 }))
658 }
659 None => Ok(None),
660 }
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use arc_core::audit::AuditMetadata;
668 use serde_json::json;
669 use std::env;
670
671 async fn setup_test_store() -> Option<PostgresEventStore> {
672 let url = env::var("ARC_POSTGRES_TEST_DATABASE_URL").ok()?;
673 let store = PostgresEventStore::new(&url).await.unwrap();
674 store.initialize_schema().await.unwrap();
675
676 sqlx::query("TRUNCATE events RESTART IDENTITY")
678 .execute(store.pool())
679 .await
680 .unwrap();
681 sqlx::query("TRUNCATE snapshots")
682 .execute(store.pool())
683 .await
684 .unwrap();
685
686 Some(store)
687 }
688
689 async fn setup_integrity_test_store() -> Option<PostgresEventStore> {
690 let url = env::var("ARC_POSTGRES_TEST_DATABASE_URL").ok()?;
691 let store = PostgresEventStore::new_with_integrity_key(&url, integrity_key(), "test-key")
692 .await
693 .unwrap();
694 store.initialize_schema().await.unwrap();
695
696 sqlx::query("TRUNCATE events RESTART IDENTITY")
697 .execute(store.pool())
698 .await
699 .unwrap();
700 sqlx::query("TRUNCATE snapshots")
701 .execute(store.pool())
702 .await
703 .unwrap();
704
705 Some(store)
706 }
707
708 fn integrity_key() -> Vec<u8> {
709 b"012345678901234567890123456789AB".to_vec()
710 }
711
712 fn stamped_event(
713 agg_type: &str,
714 agg_id: &str,
715 sequence: i64,
716 event_type: &str,
717 payload: serde_json::Value,
718 ) -> Event {
719 Event::new(agg_type, agg_id, sequence, event_type, payload)
720 .with_audit(AuditMetadata::test_default())
721 }
722
723 #[tokio::test]
724 #[serial_test::serial]
725 async fn test_live_append_and_load() {
726 let Some(store) = setup_test_store().await else {
727 return;
728 };
729 let event = stamped_event("User", "u1", 1, "Created", json!({}));
730 store
731 .append("u1", VersionCheck::New, vec![event])
732 .await
733 .unwrap();
734 let loaded = store.load("u1").await.unwrap();
735 assert_eq!(loaded.len(), 1);
736 assert_eq!(loaded[0].sequence, 1);
737 }
738
739 #[tokio::test]
740 #[serial_test::serial]
741 async fn test_live_integrity_append_persists_signatures() {
742 let Some(store) = setup_integrity_test_store().await else {
743 return;
744 };
745 store
746 .append(
747 "signed-1",
748 VersionCheck::New,
749 vec![
750 stamped_event("User", "signed-1", 1, "Created", json!({})),
751 stamped_event("User", "signed-1", 2, "Updated", json!({})),
752 ],
753 )
754 .await
755 .unwrap();
756
757 let rows = sqlx::query(
758 "SELECT integrity_signature, integrity_key_id FROM events ORDER BY sequence",
759 )
760 .fetch_all(store.pool())
761 .await
762 .unwrap();
763
764 assert_eq!(rows.len(), 2);
765 for row in rows {
766 let sig: String = row.get("integrity_signature");
767 let kid: String = row.get("integrity_key_id");
768 assert_eq!(sig.len(), 64);
769 assert_eq!(kid, "test-key");
770 }
771 }
772
773 #[tokio::test]
774 #[serial_test::serial]
775 async fn test_live_integrity_load_rejects_tampered_payload() {
776 let Some(store) = setup_integrity_test_store().await else {
777 return;
778 };
779 store
780 .append(
781 "tamper-1",
782 VersionCheck::New,
783 vec![stamped_event(
784 "User",
785 "tamper-1",
786 1,
787 "Created",
788 json!({"ok": true}),
789 )],
790 )
791 .await
792 .unwrap();
793
794 sqlx::query(
795 "UPDATE events SET payload = '{\"ok\": false}' WHERE aggregate_id = 'tamper-1'",
796 )
797 .execute(store.pool())
798 .await
799 .unwrap();
800
801 let err = store.load("tamper-1").await.unwrap_err();
802 assert!(matches!(err, EventStoreError::Integrity { .. }));
803 }
804
805 #[tokio::test]
806 #[serial_test::serial]
807 async fn test_live_integrity_load_rejects_missing_signature() {
808 let Some(store) = setup_integrity_test_store().await else {
809 return;
810 };
811 store
812 .append(
813 "missing-sig",
814 VersionCheck::New,
815 vec![stamped_event(
816 "User",
817 "missing-sig",
818 1,
819 "Created",
820 json!({}),
821 )],
822 )
823 .await
824 .unwrap();
825
826 sqlx::query(
827 "UPDATE events SET integrity_signature = NULL WHERE aggregate_id = 'missing-sig'",
828 )
829 .execute(store.pool())
830 .await
831 .unwrap();
832
833 let err = store.load("missing-sig").await.unwrap_err();
834 assert!(matches!(err, EventStoreError::Integrity { .. }));
835 }
836
837 #[tokio::test]
838 #[serial_test::serial]
839 async fn test_live_integrity_stream_all_verifies_per_aggregate() {
840 let Some(store) = setup_integrity_test_store().await else {
841 return;
842 };
843 store
844 .append(
845 "a",
846 VersionCheck::New,
847 vec![stamped_event("U", "a", 1, "X", json!({}))],
848 )
849 .await
850 .unwrap();
851 store
852 .append(
853 "b",
854 VersionCheck::New,
855 vec![stamped_event("U", "b", 1, "X", json!({}))],
856 )
857 .await
858 .unwrap();
859 store
860 .append(
861 "a",
862 VersionCheck::Expected(1),
863 vec![stamped_event("U", "a", 2, "Y", json!({}))],
864 )
865 .await
866 .unwrap();
867
868 let loaded = store.stream_all(0).await.unwrap();
869 assert_eq!(loaded.len(), 3);
870 }
871}