Skip to main content

arc_es_postgres/
lib.rs

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
14// Re-export for convenience, matching arc-es-sqlite.
15pub use arc_core::{Deserialize, Serialize};
16
17pub mod read_model_store;
18pub use read_model_store::PostgresReadModelStore;
19
20/// DDL for the append-only event log. Idempotent.
21const 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
49/// DDL for the snapshot table. Idempotent.
50const 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/// Plain, DB-free representation of an event row. Splitting conversion out from
61/// the query layer lets the row<->`Event` mapping be unit-tested without a live
62/// Postgres server.
63#[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        // Stored in seconds to match the SQLite store's `timestamp` column unit.
90        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/// Postgres implementation of [`EventStore`].
172#[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    /// Build a store from a Postgres connection URL, creating a small pool.
185    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    /// Build a store from a Postgres connection URL with an integrity key.
198    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    /// Build a store from an existing pool. Lets tests share one pool with the
218    /// read-model store against the same database.
219    pub fn with_pool(pool: PgPool) -> Self {
220        PostgresEventStore {
221            pool,
222            integrity: None,
223        }
224    }
225
226    /// Build a store from an existing pool and an integrity key.
227    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    /// Borrow the underlying pool (e.g. to construct a read-model store that
242    /// shares the same connections).
243    pub fn pool(&self) -> &PgPool {
244        &self.pool
245    }
246
247    /// Create the `events` and `snapshots` tables and their indexes if absent.
248    /// Idempotent; safe to call on every startup.
249    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        // Defense-in-depth: reject any event with invalid audit before touching the DB.
425        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                // Sign based on row-seconds timestamp parity with SQLite.
476                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        // One snapshot per aggregate: replace in place rather than accumulating
607        // stale versions.
608        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        // Clean start for each test
677        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}