Skip to main content

arc_es_postgres/
lib.rs

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
16// Re-export for convenience, matching arc-es-sqlite.
17pub use arc_core::{Deserialize, Serialize};
18
19pub mod read_model_store;
20pub use read_model_store::PostgresReadModelStore;
21
22/// DDL for the append-only event log. Idempotent.
23const 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
71/// DDL for the snapshot table. Idempotent.
72const 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/// Plain, DB-free representation of an event row. Splitting conversion out from
99/// the query layer lets the row<->`Event` mapping be unit-tested without a live
100/// Postgres server.
101#[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        // Stored in seconds to match the SQLite store's `timestamp` column unit.
128        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/// Postgres implementation of [`EventStore`].
210#[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    /// Build a store from a Postgres connection URL, creating a small pool.
223    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    /// Build a store from a Postgres connection URL with an integrity key.
236    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    /// Build a store from an existing pool. Lets tests share one pool with the
256    /// read-model store against the same database.
257    pub fn with_pool(pool: PgPool) -> Self {
258        PostgresEventStore {
259            pool,
260            integrity: None,
261        }
262    }
263
264    /// Build a store from an existing pool and an integrity key.
265    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    /// Borrow the underlying pool (e.g. to construct a read-model store that
280    /// shares the same connections).
281    pub fn pool(&self) -> &PgPool {
282        &self.pool
283    }
284
285    /// Create the `events` and `snapshots` tables and their indexes if absent.
286    /// Idempotent; safe to call on every startup.
287    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        // Defense-in-depth: reject any event with invalid audit before touching the DB.
493        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                // Sign based on row-seconds timestamp parity with SQLite.
551                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        // One snapshot per aggregate: replace in place rather than accumulating
757        // stale versions.
758        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        // Clean start for each test
866        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}