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_type, aggregate_id, sequence)
41);
42DO $$
43BEGIN
44 IF EXISTS (
45 SELECT 1 FROM pg_constraint
46 WHERE conname = 'events_aggregate_id_sequence_key'
47 ) THEN
48 ALTER TABLE events
49 DROP CONSTRAINT events_aggregate_id_sequence_key;
50 END IF;
51 IF NOT EXISTS (
52 SELECT 1 FROM pg_constraint
53 WHERE conname = 'events_aggregate_type_aggregate_id_sequence_key'
54 ) THEN
55 ALTER TABLE events
56 ADD CONSTRAINT events_aggregate_type_aggregate_id_sequence_key
57 UNIQUE (aggregate_type, aggregate_id, sequence);
58 END IF;
59END $$;
60DROP INDEX IF EXISTS idx_events_aggregate;
61CREATE INDEX idx_events_aggregate
62 ON events(aggregate_type, aggregate_id, sequence);
63CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
64CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events("timestamp");
65CREATE INDEX IF NOT EXISTS idx_events_actor_id ON events(actor_id);
66CREATE INDEX IF NOT EXISTS idx_events_correlation_id ON events(correlation_id);
67"#;
68
69const SNAPSHOTS_SCHEMA: &str = r#"
71CREATE TABLE IF NOT EXISTS snapshots (
72 aggregate_type TEXT NOT NULL,
73 aggregate_id TEXT NOT NULL,
74 version BIGINT NOT NULL,
75 state JSONB NOT NULL,
76 created_at BIGINT NOT NULL,
77 PRIMARY KEY (aggregate_type, aggregate_id)
78);
79DO $$
80BEGIN
81 IF EXISTS (
82 SELECT 1
83 FROM pg_constraint
84 WHERE conrelid = 'snapshots'::regclass
85 AND contype = 'p'
86 AND pg_get_constraintdef(oid) = 'PRIMARY KEY (aggregate_id)'
87 ) THEN
88 ALTER TABLE snapshots DROP CONSTRAINT snapshots_pkey;
89 ALTER TABLE snapshots
90 ADD CONSTRAINT snapshots_pkey
91 PRIMARY KEY (aggregate_type, aggregate_id);
92 END IF;
93END $$;
94"#;
95
96#[derive(Debug, Clone, PartialEq)]
100struct EventRow {
101 event_id: String,
102 aggregate_type: String,
103 aggregate_id: String,
104 sequence: i64,
105 event_type: String,
106 payload: serde_json::Value,
107 timestamp: i64,
108 actor_id: String,
109 actor_session_id: Option<String>,
110 source_ip: Option<String>,
111 user_agent: Option<String>,
112 timestamp_utc_us: i64,
113 causation_id: Option<String>,
114 correlation_id: String,
115 integrity_signature: Option<String>,
116 integrity_key_id: Option<String>,
117}
118
119impl EventRow {
120 fn from_event(
121 event: &Event,
122 integrity_signature: Option<String>,
123 integrity_key_id: Option<String>,
124 ) -> EventRow {
125 let timestamp_seconds: i64 = (event.timestamp / 1000) as i64;
127 EventRow {
128 event_id: event.event_id.to_string(),
129 aggregate_type: event.aggregate_type.clone(),
130 aggregate_id: event.aggregate_id.clone(),
131 sequence: event.sequence,
132 event_type: event.event_type.clone(),
133 payload: event.payload.clone(),
134 timestamp: timestamp_seconds,
135 actor_id: event.audit.actor_id.clone(),
136 actor_session_id: event.audit.actor_session_id.clone(),
137 source_ip: event.audit.source_ip.clone(),
138 user_agent: event.audit.user_agent.clone(),
139 timestamp_utc_us: event.audit.timestamp_utc_us,
140 causation_id: event.audit.causation_id.map(|u| u.to_string()),
141 correlation_id: event.audit.correlation_id.to_string(),
142 integrity_signature,
143 integrity_key_id,
144 }
145 }
146
147 fn to_event(&self) -> EventStoreResult<Event> {
148 let event_id = Uuid::parse_str(&self.event_id)
149 .map_err(|e| EventStoreError::serialization(format!("Invalid UUID: {}", e)))?;
150
151 let causation_id = match self.causation_id.as_deref() {
152 Some(s) => Some(Uuid::parse_str(s).map_err(|e| {
153 EventStoreError::serialization(format!("Invalid causation UUID: {}", e))
154 })?),
155 None => None,
156 };
157
158 let correlation_id = Uuid::parse_str(&self.correlation_id).map_err(|e| {
159 EventStoreError::serialization(format!("Invalid correlation UUID: {}", e))
160 })?;
161
162 let audit = AuditMetadata {
163 actor_id: self.actor_id.clone(),
164 actor_session_id: self.actor_session_id.clone(),
165 source_ip: self.source_ip.clone(),
166 user_agent: self.user_agent.clone(),
167 timestamp_utc_us: self.timestamp_utc_us,
168 causation_id,
169 correlation_id,
170 };
171
172 Ok(Event {
173 event_id,
174 aggregate_type: self.aggregate_type.clone(),
175 aggregate_id: self.aggregate_id.clone(),
176 sequence: self.sequence,
177 event_type: self.event_type.clone(),
178 payload: self.payload.clone(),
179 audit,
180 timestamp: (self.timestamp as u64) * 1000,
181 })
182 }
183
184 fn from_pg_row(row: &sqlx::postgres::PgRow) -> EventStoreResult<EventRow> {
185 let map = |e: sqlx::Error| EventStoreError::database(e.to_string());
186 Ok(EventRow {
187 event_id: row.try_get("event_id").map_err(map)?,
188 aggregate_type: row.try_get("aggregate_type").map_err(map)?,
189 aggregate_id: row.try_get("aggregate_id").map_err(map)?,
190 sequence: row.try_get("sequence").map_err(map)?,
191 event_type: row.try_get("event_type").map_err(map)?,
192 payload: row.try_get("payload").map_err(map)?,
193 timestamp: row.try_get("timestamp").map_err(map)?,
194 actor_id: row.try_get("actor_id").map_err(map)?,
195 actor_session_id: row.try_get("actor_session_id").map_err(map)?,
196 source_ip: row.try_get("source_ip").map_err(map)?,
197 user_agent: row.try_get("user_agent").map_err(map)?,
198 timestamp_utc_us: row.try_get("timestamp_utc_us").map_err(map)?,
199 causation_id: row.try_get("causation_id").map_err(map)?,
200 correlation_id: row.try_get("correlation_id").map_err(map)?,
201 integrity_signature: row.try_get("integrity_signature").map_err(map)?,
202 integrity_key_id: row.try_get("integrity_key_id").map_err(map)?,
203 })
204 }
205}
206
207#[derive(Clone)]
209pub struct PostgresEventStore {
210 pool: PgPool,
211 integrity: Option<Arc<IntegrityConfig>>,
212}
213
214struct IntegrityConfig {
215 chain: Arc<dyn IntegrityChain>,
216 key_id: String,
217}
218
219impl PostgresEventStore {
220 pub async fn new(database_url: &str) -> EventStoreResult<Self> {
222 let pool = PgPoolOptions::new()
223 .max_connections(10)
224 .connect(database_url)
225 .await
226 .map_err(|e| EventStoreError::database(format!("Failed to create pool: {}", e)))?;
227 Ok(PostgresEventStore {
228 pool,
229 integrity: None,
230 })
231 }
232
233 pub async fn new_with_integrity_key(
235 database_url: &str,
236 key: impl Into<Vec<u8>>,
237 key_id: impl Into<String>,
238 ) -> EventStoreResult<Self> {
239 let pool = PgPoolOptions::new()
240 .max_connections(10)
241 .connect(database_url)
242 .await
243 .map_err(|e| EventStoreError::database(format!("Failed to create pool: {}", e)))?;
244 Ok(PostgresEventStore {
245 pool,
246 integrity: Some(Arc::new(IntegrityConfig {
247 chain: Arc::new(HmacSha256Chain::new(key).map_err(EventStoreError::from)?),
248 key_id: key_id.into(),
249 })),
250 })
251 }
252
253 pub fn with_pool(pool: PgPool) -> Self {
256 PostgresEventStore {
257 pool,
258 integrity: None,
259 }
260 }
261
262 pub fn with_pool_and_integrity_key(
264 pool: PgPool,
265 key: impl Into<Vec<u8>>,
266 key_id: impl Into<String>,
267 ) -> EventStoreResult<Self> {
268 Ok(PostgresEventStore {
269 pool,
270 integrity: Some(Arc::new(IntegrityConfig {
271 chain: Arc::new(HmacSha256Chain::new(key).map_err(EventStoreError::from)?),
272 key_id: key_id.into(),
273 })),
274 })
275 }
276
277 pub fn pool(&self) -> &PgPool {
280 &self.pool
281 }
282
283 pub async fn initialize_schema(&self) -> EventStoreResult<()> {
286 sqlx::raw_sql(EVENTS_SCHEMA)
287 .execute(&self.pool)
288 .await
289 .map_err(|e| EventStoreError::database(e.to_string()))?;
290 sqlx::raw_sql(SNAPSHOTS_SCHEMA)
291 .execute(&self.pool)
292 .await
293 .map_err(|e| EventStoreError::database(e.to_string()))?;
294 Ok(())
295 }
296
297 async fn required_signature(
298 &self,
299 row: &EventRow,
300 aggregate_id: &str,
301 sequence: i64,
302 ) -> EventStoreResult<EventSignature> {
303 let _key_id = row.integrity_key_id.as_ref().ok_or_else(|| {
304 EventStoreError::from(IntegrityError::BrokenAt {
305 aggregate_id: aggregate_id.to_string(),
306 sequence,
307 })
308 })?;
309
310 row.integrity_signature
311 .as_ref()
312 .map(|s| EventSignature(s.clone()))
313 .ok_or_else(|| {
314 EventStoreError::from(IntegrityError::BrokenAt {
315 aggregate_id: aggregate_id.to_string(),
316 sequence,
317 })
318 })
319 }
320
321 async fn previous_signature_for_aggregate(
322 &self,
323 executor: &mut sqlx::Transaction<'_, sqlx::Postgres>,
324 aggregate_type: Option<&str>,
325 aggregate_id: &str,
326 before_sequence: i64,
327 ) -> EventStoreResult<EventSignature> {
328 if before_sequence <= 1 {
329 return Ok(EventSignature::genesis());
330 }
331
332 let row = sqlx::query(
333 "SELECT * FROM events
334 WHERE ($1::text IS NULL OR aggregate_type = $1)
335 AND aggregate_id = $2 AND sequence < $3
336 ORDER BY sequence DESC LIMIT 1",
337 )
338 .bind(aggregate_type)
339 .bind(aggregate_id)
340 .bind(before_sequence)
341 .fetch_optional(&mut **executor)
342 .await
343 .map_err(|e| EventStoreError::database(e.to_string()))?;
344
345 match row {
346 Some(r) => {
347 let event_row = EventRow::from_pg_row(&r)?;
348 self.required_signature(&event_row, aggregate_id, event_row.sequence)
349 .await
350 }
351 None => Ok(EventSignature::genesis()),
352 }
353 }
354
355 async fn verify_integrity_rows(
356 &self,
357 integrity: &IntegrityConfig,
358 rows: &[EventRow],
359 previous_signature: EventSignature,
360 ) -> EventStoreResult<Vec<Event>> {
361 let mut previous = previous_signature;
362 let mut events = Vec::with_capacity(rows.len());
363
364 for row in rows {
365 let event = row.to_event()?;
366 let expected = integrity.chain.sign_event(&previous, &event)?;
367 let claimed = self
368 .required_signature(row, &event.aggregate_id, event.sequence)
369 .await?;
370
371 if expected != claimed {
372 return Err(EventStoreError::from(IntegrityError::BrokenAt {
373 aggregate_id: event.aggregate_id,
374 sequence: event.sequence,
375 }));
376 }
377
378 previous = claimed;
379 events.push(event);
380 }
381
382 Ok(events)
383 }
384
385 async fn verify_stream_integrity_rows(
386 &self,
387 integrity: &IntegrityConfig,
388 rows: &[EventRow],
389 ) -> EventStoreResult<Vec<Event>> {
390 use std::collections::HashMap;
391
392 let mut previous_by_aggregate: HashMap<(String, String), EventSignature> = HashMap::new();
393 let mut events = Vec::with_capacity(rows.len());
394
395 for row in rows {
396 let event = row.to_event()?;
397 let stream = (event.aggregate_type.clone(), event.aggregate_id.clone());
398 let previous = match previous_by_aggregate.get(&stream) {
399 Some(sig) => sig.clone(),
400 None => {
401 self.previous_signature_no_tx(
402 Some(&event.aggregate_type),
403 &event.aggregate_id,
404 event.sequence,
405 )
406 .await?
407 }
408 };
409
410 let expected = integrity.chain.sign_event(&previous, &event)?;
411 let claimed = self
412 .required_signature(row, &event.aggregate_id, event.sequence)
413 .await?;
414
415 if expected != claimed {
416 return Err(EventStoreError::from(IntegrityError::BrokenAt {
417 aggregate_id: event.aggregate_id,
418 sequence: event.sequence,
419 }));
420 }
421
422 previous_by_aggregate.insert(stream, claimed);
423 events.push(event);
424 }
425
426 Ok(events)
427 }
428
429 async fn previous_signature_no_tx(
430 &self,
431 aggregate_type: Option<&str>,
432 aggregate_id: &str,
433 before_sequence: i64,
434 ) -> EventStoreResult<EventSignature> {
435 if before_sequence <= 1 {
436 return Ok(EventSignature::genesis());
437 }
438
439 let row = sqlx::query(
440 "SELECT * FROM events
441 WHERE ($1::text IS NULL OR aggregate_type = $1)
442 AND aggregate_id = $2 AND sequence < $3
443 ORDER BY sequence DESC LIMIT 1",
444 )
445 .bind(aggregate_type)
446 .bind(aggregate_id)
447 .bind(before_sequence)
448 .fetch_optional(&self.pool)
449 .await
450 .map_err(|e| EventStoreError::database(e.to_string()))?;
451
452 match row {
453 Some(r) => {
454 let event_row = EventRow::from_pg_row(&r)?;
455 self.required_signature(&event_row, aggregate_id, event_row.sequence)
456 .await
457 }
458 None => Ok(EventSignature::genesis()),
459 }
460 }
461}
462
463#[async_trait]
464impl EventStore for PostgresEventStore {
465 async fn append(
466 &self,
467 aggregate_id: &str,
468 version_check: VersionCheck,
469 new_events: Vec<Event>,
470 ) -> EventStoreResult<()> {
471 let aggregate_type = new_events
472 .first()
473 .map(|event| event.aggregate_type.clone())
474 .unwrap_or_default();
475 self.append_to(&aggregate_type, aggregate_id, version_check, new_events)
476 .await
477 }
478
479 async fn append_to(
480 &self,
481 aggregate_type: &str,
482 aggregate_id: &str,
483 version_check: VersionCheck,
484 new_events: Vec<Event>,
485 ) -> EventStoreResult<()> {
486 if new_events.is_empty() {
487 return Ok(());
488 }
489
490 validate_audit_batch(aggregate_id, &new_events)?;
492
493 let mut tx = self
494 .pool
495 .begin()
496 .await
497 .map_err(|e| EventStoreError::database(e.to_string()))?;
498
499 let current_version: i64 = sqlx::query(
500 "SELECT COALESCE(MAX(sequence), 0) AS v FROM events
501 WHERE aggregate_type = $1 AND aggregate_id = $2",
502 )
503 .bind(aggregate_type)
504 .bind(aggregate_id)
505 .fetch_one(&mut *tx)
506 .await
507 .map_err(|e| EventStoreError::database(e.to_string()))?
508 .try_get("v")
509 .map_err(|e| EventStoreError::database(e.to_string()))?;
510
511 if let Some(expected) = version_check.version() {
512 if current_version != expected {
513 return Err(EventStoreError::ConcurrencyConflict {
514 aggregate_id: aggregate_id.to_string(),
515 expected,
516 actual: current_version,
517 });
518 }
519 }
520
521 for (expected_sequence, event) in (current_version + 1..).zip(new_events.iter()) {
522 if event.sequence != expected_sequence {
523 return Err(EventStoreError::InvalidSequence {
524 aggregate_id: aggregate_id.to_string(),
525 expected: expected_sequence,
526 actual: event.sequence,
527 });
528 }
529 }
530
531 let mut previous_signature = if self.integrity.is_some() {
532 self.previous_signature_for_aggregate(
533 &mut tx,
534 Some(aggregate_type),
535 aggregate_id,
536 current_version + 1,
537 )
538 .await?
539 } else {
540 EventSignature::genesis()
541 };
542
543 for event in &new_events {
544 let mut signature_str = None;
545 let mut key_id_str = None;
546
547 if let Some(integrity) = self.integrity.as_ref() {
548 let timestamp_seconds = (event.timestamp / 1000) as i64;
550 let mut persisted_event = event.clone();
551 persisted_event.timestamp = (timestamp_seconds as u64) * 1000;
552
553 let signature = integrity
554 .chain
555 .sign_event(&previous_signature, &persisted_event)
556 .map_err(EventStoreError::from)?;
557 previous_signature = signature.clone();
558 signature_str = Some(signature.0);
559 key_id_str = Some(integrity.key_id.clone());
560 }
561
562 let row = EventRow::from_event(event, signature_str, key_id_str);
563 sqlx::query(
564 r#"INSERT INTO events
565 (event_id, aggregate_type, aggregate_id, sequence, event_type, payload,
566 "timestamp", actor_id, actor_session_id, source_ip, user_agent,
567 timestamp_utc_us, causation_id, correlation_id,
568 integrity_signature, integrity_key_id)
569 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)"#,
570 )
571 .bind(&row.event_id)
572 .bind(&row.aggregate_type)
573 .bind(&row.aggregate_id)
574 .bind(row.sequence)
575 .bind(&row.event_type)
576 .bind(&row.payload)
577 .bind(row.timestamp)
578 .bind(&row.actor_id)
579 .bind(&row.actor_session_id)
580 .bind(&row.source_ip)
581 .bind(&row.user_agent)
582 .bind(row.timestamp_utc_us)
583 .bind(&row.causation_id)
584 .bind(&row.correlation_id)
585 .bind(&row.integrity_signature)
586 .bind(&row.integrity_key_id)
587 .execute(&mut *tx)
588 .await
589 .map_err(|e| EventStoreError::database(e.to_string()))?;
590 }
591
592 tx.commit()
593 .await
594 .map_err(|e| EventStoreError::database(e.to_string()))?;
595 Ok(())
596 }
597
598 async fn load(&self, aggregate_id: &str) -> EventStoreResult<Vec<Event>> {
599 self.load_from(aggregate_id, 1).await
600 }
601
602 async fn load_stream(
603 &self,
604 aggregate_type: &str,
605 aggregate_id: &str,
606 ) -> EventStoreResult<Vec<Event>> {
607 self.load_stream_from(aggregate_type, aggregate_id, 1).await
608 }
609
610 async fn load_from(
611 &self,
612 aggregate_id: &str,
613 from_sequence: i64,
614 ) -> EventStoreResult<Vec<Event>> {
615 let rows = sqlx::query(
616 "SELECT * FROM events WHERE aggregate_id = $1 AND sequence >= $2 ORDER BY sequence ASC",
617 )
618 .bind(aggregate_id)
619 .bind(from_sequence)
620 .fetch_all(&self.pool)
621 .await
622 .map_err(|e| EventStoreError::database(e.to_string()))?;
623
624 let event_rows: Vec<EventRow> = rows
625 .iter()
626 .map(EventRow::from_pg_row)
627 .collect::<EventStoreResult<_>>()?;
628
629 match self.integrity.as_ref() {
630 Some(integrity) => {
631 let mut tx = self
632 .pool
633 .begin()
634 .await
635 .map_err(|e| EventStoreError::database(e.to_string()))?;
636 let previous = self
637 .previous_signature_for_aggregate(&mut tx, None, aggregate_id, from_sequence)
638 .await?;
639 self.verify_integrity_rows(integrity, &event_rows, previous)
640 .await
641 }
642 None => event_rows
643 .iter()
644 .map(|r| r.to_event())
645 .collect::<EventStoreResult<_>>(),
646 }
647 }
648
649 async fn load_stream_from(
650 &self,
651 aggregate_type: &str,
652 aggregate_id: &str,
653 from_sequence: i64,
654 ) -> EventStoreResult<Vec<Event>> {
655 let rows = sqlx::query(
656 "SELECT * FROM events
657 WHERE aggregate_type = $1 AND aggregate_id = $2 AND sequence >= $3
658 ORDER BY sequence ASC",
659 )
660 .bind(aggregate_type)
661 .bind(aggregate_id)
662 .bind(from_sequence)
663 .fetch_all(&self.pool)
664 .await
665 .map_err(|e| EventStoreError::database(e.to_string()))?;
666
667 let event_rows: Vec<EventRow> = rows
668 .iter()
669 .map(EventRow::from_pg_row)
670 .collect::<EventStoreResult<_>>()?;
671
672 match self.integrity.as_ref() {
673 Some(integrity) => {
674 let mut tx = self
675 .pool
676 .begin()
677 .await
678 .map_err(|e| EventStoreError::database(e.to_string()))?;
679 let previous = self
680 .previous_signature_for_aggregate(
681 &mut tx,
682 Some(aggregate_type),
683 aggregate_id,
684 from_sequence,
685 )
686 .await?;
687 self.verify_integrity_rows(integrity, &event_rows, previous)
688 .await
689 }
690 None => event_rows
691 .iter()
692 .map(|row| row.to_event())
693 .collect::<EventStoreResult<_>>(),
694 }
695 }
696
697 async fn stream_all(&self, from_position: i64) -> EventStoreResult<Vec<Event>> {
698 let rows = sqlx::query("SELECT * FROM events WHERE id >= $1 ORDER BY id ASC")
699 .bind(from_position)
700 .fetch_all(&self.pool)
701 .await
702 .map_err(|e| EventStoreError::database(e.to_string()))?;
703
704 let event_rows: Vec<EventRow> = rows
705 .iter()
706 .map(EventRow::from_pg_row)
707 .collect::<EventStoreResult<_>>()?;
708
709 match self.integrity.as_ref() {
710 Some(integrity) => {
711 self.verify_stream_integrity_rows(integrity, &event_rows)
712 .await
713 }
714 None => event_rows
715 .iter()
716 .map(|r| r.to_event())
717 .collect::<EventStoreResult<_>>(),
718 }
719 }
720
721 async fn get_version(&self, aggregate_id: &str) -> EventStoreResult<i64> {
722 let version: i64 = sqlx::query(
723 "SELECT COALESCE(MAX(sequence), 0) AS v FROM events WHERE aggregate_id = $1",
724 )
725 .bind(aggregate_id)
726 .fetch_one(&self.pool)
727 .await
728 .map_err(|e| EventStoreError::database(e.to_string()))?
729 .try_get("v")
730 .map_err(|e| EventStoreError::database(e.to_string()))?;
731 Ok(version)
732 }
733
734 async fn get_stream_version(
735 &self,
736 aggregate_type: &str,
737 aggregate_id: &str,
738 ) -> EventStoreResult<i64> {
739 let version: i64 = sqlx::query(
740 "SELECT COALESCE(MAX(sequence), 0) AS v FROM events
741 WHERE aggregate_type = $1 AND aggregate_id = $2",
742 )
743 .bind(aggregate_type)
744 .bind(aggregate_id)
745 .fetch_one(&self.pool)
746 .await
747 .map_err(|e| EventStoreError::database(e.to_string()))?
748 .try_get("v")
749 .map_err(|e| EventStoreError::database(e.to_string()))?;
750 Ok(version)
751 }
752
753 async fn save_snapshot(&self, snapshot: &Snapshot) -> EventStoreResult<()> {
754 sqlx::query(
757 r#"INSERT INTO snapshots (aggregate_id, aggregate_type, version, state, created_at)
758 VALUES ($1, $2, $3, $4, $5)
759 ON CONFLICT (aggregate_type, aggregate_id) DO UPDATE
760 SET version = EXCLUDED.version,
761 state = EXCLUDED.state,
762 created_at = EXCLUDED.created_at"#,
763 )
764 .bind(&snapshot.aggregate_id)
765 .bind(&snapshot.aggregate_type)
766 .bind(snapshot.version)
767 .bind(&snapshot.state)
768 .bind(snapshot.created_at as i64)
769 .execute(&self.pool)
770 .await
771 .map_err(|e| EventStoreError::database(e.to_string()))?;
772 Ok(())
773 }
774
775 async fn load_snapshot(&self, aggregate_id: &str) -> EventStoreResult<Option<Snapshot>> {
776 let row = sqlx::query(
777 "SELECT aggregate_id, aggregate_type, version, state, created_at \
778 FROM snapshots WHERE aggregate_id = $1",
779 )
780 .bind(aggregate_id)
781 .fetch_optional(&self.pool)
782 .await
783 .map_err(|e| EventStoreError::database(e.to_string()))?;
784
785 match row {
786 Some(r) => {
787 let created_at: i64 = r
788 .try_get("created_at")
789 .map_err(|e| EventStoreError::database(e.to_string()))?;
790 Ok(Some(Snapshot {
791 aggregate_id: r
792 .try_get("aggregate_id")
793 .map_err(|e| EventStoreError::database(e.to_string()))?,
794 aggregate_type: r
795 .try_get("aggregate_type")
796 .map_err(|e| EventStoreError::database(e.to_string()))?,
797 version: r
798 .try_get("version")
799 .map_err(|e| EventStoreError::database(e.to_string()))?,
800 state: r
801 .try_get("state")
802 .map_err(|e| EventStoreError::database(e.to_string()))?,
803 created_at: created_at as u64,
804 }))
805 }
806 None => Ok(None),
807 }
808 }
809
810 async fn load_snapshot_for(
811 &self,
812 aggregate_type: &str,
813 aggregate_id: &str,
814 ) -> EventStoreResult<Option<Snapshot>> {
815 let row = sqlx::query(
816 "SELECT aggregate_id, aggregate_type, version, state, created_at
817 FROM snapshots WHERE aggregate_type = $1 AND aggregate_id = $2",
818 )
819 .bind(aggregate_type)
820 .bind(aggregate_id)
821 .fetch_optional(&self.pool)
822 .await
823 .map_err(|e| EventStoreError::database(e.to_string()))?;
824
825 match row {
826 Some(row) => {
827 let created_at: i64 = row
828 .try_get("created_at")
829 .map_err(|e| EventStoreError::database(e.to_string()))?;
830 Ok(Some(Snapshot {
831 aggregate_id: row
832 .try_get("aggregate_id")
833 .map_err(|e| EventStoreError::database(e.to_string()))?,
834 aggregate_type: row
835 .try_get("aggregate_type")
836 .map_err(|e| EventStoreError::database(e.to_string()))?,
837 version: row
838 .try_get("version")
839 .map_err(|e| EventStoreError::database(e.to_string()))?,
840 state: row
841 .try_get("state")
842 .map_err(|e| EventStoreError::database(e.to_string()))?,
843 created_at: created_at as u64,
844 }))
845 }
846 None => Ok(None),
847 }
848 }
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use arc_core::audit::AuditMetadata;
855 use serde_json::json;
856 use std::env;
857
858 async fn setup_test_store() -> Option<PostgresEventStore> {
859 let url = env::var("ARC_POSTGRES_TEST_DATABASE_URL").ok()?;
860 let store = PostgresEventStore::new(&url).await.unwrap();
861 store.initialize_schema().await.unwrap();
862
863 sqlx::query("TRUNCATE events RESTART IDENTITY")
865 .execute(store.pool())
866 .await
867 .unwrap();
868 sqlx::query("TRUNCATE snapshots")
869 .execute(store.pool())
870 .await
871 .unwrap();
872
873 Some(store)
874 }
875
876 async fn setup_integrity_test_store() -> Option<PostgresEventStore> {
877 let url = env::var("ARC_POSTGRES_TEST_DATABASE_URL").ok()?;
878 let store = PostgresEventStore::new_with_integrity_key(&url, integrity_key(), "test-key")
879 .await
880 .unwrap();
881 store.initialize_schema().await.unwrap();
882
883 sqlx::query("TRUNCATE events RESTART IDENTITY")
884 .execute(store.pool())
885 .await
886 .unwrap();
887 sqlx::query("TRUNCATE snapshots")
888 .execute(store.pool())
889 .await
890 .unwrap();
891
892 Some(store)
893 }
894
895 fn integrity_key() -> Vec<u8> {
896 b"012345678901234567890123456789AB".to_vec()
897 }
898
899 fn stamped_event(
900 agg_type: &str,
901 agg_id: &str,
902 sequence: i64,
903 event_type: &str,
904 payload: serde_json::Value,
905 ) -> Event {
906 Event::new(agg_type, agg_id, sequence, event_type, payload)
907 .with_audit(AuditMetadata::test_default())
908 }
909
910 #[tokio::test]
911 #[serial_test::serial]
912 async fn test_live_append_and_load() {
913 let Some(store) = setup_test_store().await else {
914 return;
915 };
916 let event = stamped_event("User", "u1", 1, "Created", json!({}));
917 store
918 .append("u1", VersionCheck::New, vec![event])
919 .await
920 .unwrap();
921 let loaded = store.load("u1").await.unwrap();
922 assert_eq!(loaded.len(), 1);
923 assert_eq!(loaded[0].sequence, 1);
924 }
925
926 #[tokio::test]
927 #[serial_test::serial]
928 async fn test_live_same_id_is_isolated_by_aggregate_type() {
929 let Some(store) = setup_test_store().await else {
930 return;
931 };
932 store
933 .append_to(
934 "Product",
935 "shared-id",
936 VersionCheck::New,
937 vec![stamped_event(
938 "Product",
939 "shared-id",
940 1,
941 "ProductCreated",
942 json!({}),
943 )],
944 )
945 .await
946 .unwrap();
947 store
948 .append_to(
949 "Order",
950 "shared-id",
951 VersionCheck::New,
952 vec![stamped_event(
953 "Order",
954 "shared-id",
955 1,
956 "OrderPlaced",
957 json!({}),
958 )],
959 )
960 .await
961 .unwrap();
962
963 assert_eq!(
964 store.load_stream("Product", "shared-id").await.unwrap()[0].event_type,
965 "ProductCreated"
966 );
967 assert_eq!(
968 store.load_stream("Order", "shared-id").await.unwrap()[0].event_type,
969 "OrderPlaced"
970 );
971 }
972
973 #[tokio::test]
974 #[serial_test::serial]
975 async fn test_live_integrity_append_persists_signatures() {
976 let Some(store) = setup_integrity_test_store().await else {
977 return;
978 };
979 store
980 .append(
981 "signed-1",
982 VersionCheck::New,
983 vec![
984 stamped_event("User", "signed-1", 1, "Created", json!({})),
985 stamped_event("User", "signed-1", 2, "Updated", json!({})),
986 ],
987 )
988 .await
989 .unwrap();
990
991 let rows = sqlx::query(
992 "SELECT integrity_signature, integrity_key_id FROM events ORDER BY sequence",
993 )
994 .fetch_all(store.pool())
995 .await
996 .unwrap();
997
998 assert_eq!(rows.len(), 2);
999 for row in rows {
1000 let sig: String = row.get("integrity_signature");
1001 let kid: String = row.get("integrity_key_id");
1002 assert_eq!(sig.len(), 64);
1003 assert_eq!(kid, "test-key");
1004 }
1005 }
1006
1007 #[tokio::test]
1008 #[serial_test::serial]
1009 async fn test_live_integrity_load_rejects_tampered_payload() {
1010 let Some(store) = setup_integrity_test_store().await else {
1011 return;
1012 };
1013 store
1014 .append(
1015 "tamper-1",
1016 VersionCheck::New,
1017 vec![stamped_event(
1018 "User",
1019 "tamper-1",
1020 1,
1021 "Created",
1022 json!({"ok": true}),
1023 )],
1024 )
1025 .await
1026 .unwrap();
1027
1028 sqlx::query(
1029 "UPDATE events SET payload = '{\"ok\": false}' WHERE aggregate_id = 'tamper-1'",
1030 )
1031 .execute(store.pool())
1032 .await
1033 .unwrap();
1034
1035 let err = store.load("tamper-1").await.unwrap_err();
1036 assert!(matches!(err, EventStoreError::Integrity { .. }));
1037 }
1038
1039 #[tokio::test]
1040 #[serial_test::serial]
1041 async fn test_live_integrity_load_rejects_missing_signature() {
1042 let Some(store) = setup_integrity_test_store().await else {
1043 return;
1044 };
1045 store
1046 .append(
1047 "missing-sig",
1048 VersionCheck::New,
1049 vec![stamped_event(
1050 "User",
1051 "missing-sig",
1052 1,
1053 "Created",
1054 json!({}),
1055 )],
1056 )
1057 .await
1058 .unwrap();
1059
1060 sqlx::query(
1061 "UPDATE events SET integrity_signature = NULL WHERE aggregate_id = 'missing-sig'",
1062 )
1063 .execute(store.pool())
1064 .await
1065 .unwrap();
1066
1067 let err = store.load("missing-sig").await.unwrap_err();
1068 assert!(matches!(err, EventStoreError::Integrity { .. }));
1069 }
1070
1071 #[tokio::test]
1072 #[serial_test::serial]
1073 async fn test_live_integrity_stream_all_verifies_per_aggregate() {
1074 let Some(store) = setup_integrity_test_store().await else {
1075 return;
1076 };
1077 store
1078 .append(
1079 "a",
1080 VersionCheck::New,
1081 vec![stamped_event("U", "a", 1, "X", json!({}))],
1082 )
1083 .await
1084 .unwrap();
1085 store
1086 .append(
1087 "b",
1088 VersionCheck::New,
1089 vec![stamped_event("U", "b", 1, "X", json!({}))],
1090 )
1091 .await
1092 .unwrap();
1093 store
1094 .append(
1095 "a",
1096 VersionCheck::Expected(1),
1097 vec![stamped_event("U", "a", 2, "Y", json!({}))],
1098 )
1099 .await
1100 .unwrap();
1101
1102 let loaded = store.stream_all(0).await.unwrap();
1103 assert_eq!(loaded.len(), 3);
1104 }
1105}