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