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