1use std::sync::Arc;
2use std::sync::atomic::AtomicBool;
3
4use crate::sqlx;
5
6use cratestack_core::{
7 CoolError, CoolEventBus, CoolEventEnvelope, CoolEventFuture, ModelEventKind,
8};
9
10use crate::error::cool_error_from_sqlx;
11
12#[derive(Debug, Clone)]
13pub struct SqlxRuntime {
14 pool: sqlx::PgPool,
15 events: CoolEventBus,
16 audit_table_ensured: Arc<AtomicBool>,
23}
24
25impl SqlxRuntime {
26 pub fn new(pool: sqlx::PgPool) -> Self {
27 Self {
28 pool,
29 events: CoolEventBus::default(),
30 audit_table_ensured: Arc::new(AtomicBool::new(false)),
31 }
32 }
33
34 pub fn pool(&self) -> &sqlx::PgPool {
35 &self.pool
36 }
37
38 pub(crate) fn audit_table_ensured(&self) -> &AtomicBool {
39 &self.audit_table_ensured
40 }
41
42 #[doc(hidden)]
43 pub fn subscribe<F>(&self, model: &'static str, operation: ModelEventKind, handler: F)
44 where
45 F: Fn(CoolEventEnvelope) -> CoolEventFuture + Send + Sync + 'static,
46 {
47 self.events.subscribe(model, operation, handler);
48 }
49
50 #[doc(hidden)]
51 pub async fn drain_event_outbox(&self) -> Result<usize, CoolError> {
52 ensure_event_outbox_table(&self.pool).await?;
53
54 let rows = sqlx::query_as::<_, EventOutboxRow>(
55 "SELECT event_id, model, operation, occurred_at, payload, attempts, last_error \
56 FROM cratestack_event_outbox \
57 WHERE delivered_at IS NULL \
58 ORDER BY occurred_at ASC, event_id ASC",
59 )
60 .fetch_all(&self.pool)
61 .await
62 .map_err(cool_error_from_sqlx)?;
63
64 let mut delivered = 0usize;
65 for row in rows {
66 let event_id = row.event_id;
67 let envelope = row.try_into_envelope()?;
68 match self.events.emit(envelope).await {
69 Ok(()) => {
70 sqlx::query(
71 "UPDATE cratestack_event_outbox \
72 SET delivered_at = NOW(), last_error = NULL, attempts = attempts + 1 \
73 WHERE event_id = $1",
74 )
75 .bind(event_id)
76 .execute(&self.pool)
77 .await
78 .map_err(cool_error_from_sqlx)?;
79 delivered += 1;
80 }
81 Err(error) => {
82 sqlx::query(
83 "UPDATE cratestack_event_outbox \
84 SET attempts = attempts + 1, last_error = $2 \
85 WHERE event_id = $1",
86 )
87 .bind(event_id)
88 .bind(error.to_string())
89 .execute(&self.pool)
90 .await
91 .map_err(cool_error_from_sqlx)?;
92 }
93 }
94 }
95
96 Ok(delivered)
97 }
98}
99
100#[derive(Debug, Clone)]
101pub(crate) struct EventOutboxRow {
102 pub(crate) event_id: uuid::Uuid,
103 pub(crate) model: String,
104 pub(crate) operation: String,
105 pub(crate) occurred_at: chrono::DateTime<chrono::Utc>,
106 pub(crate) payload: serde_json::Value,
107 pub(crate) attempts: i64,
108 pub(crate) last_error: Option<String>,
109}
110
111impl<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> for EventOutboxRow {
115 fn from_row(row: &'r sqlx::postgres::PgRow) -> Result<Self, sqlx::Error> {
116 use sqlx::Row;
117 Ok(Self {
118 event_id: row.try_get("event_id")?,
119 model: row.try_get("model")?,
120 operation: row.try_get("operation")?,
121 occurred_at: row.try_get("occurred_at")?,
122 payload: row.try_get("payload")?,
123 attempts: row.try_get("attempts")?,
124 last_error: row.try_get("last_error")?,
125 })
126 }
127}
128
129impl EventOutboxRow {
130 pub(crate) fn try_into_envelope(self) -> Result<CoolEventEnvelope, CoolError> {
131 let _ = self.attempts;
132 let _ = &self.last_error;
133 Ok(CoolEventEnvelope {
134 event_id: self.event_id,
135 model: self.model,
136 operation: ModelEventKind::parse(&self.operation)?,
137 occurred_at: self.occurred_at,
138 data: self.payload,
139 })
140 }
141}
142
143pub(crate) async fn ensure_event_outbox_table<'e, E>(executor: E) -> Result<(), CoolError>
144where
145 E: sqlx::Executor<'e, Database = sqlx::Postgres>,
146{
147 sqlx::query(
148 "CREATE TABLE IF NOT EXISTS cratestack_event_outbox (\
149 event_id UUID PRIMARY KEY, \
150 model TEXT NOT NULL, \
151 operation TEXT NOT NULL, \
152 occurred_at TIMESTAMPTZ NOT NULL, \
153 payload JSONB NOT NULL, \
154 delivered_at TIMESTAMPTZ, \
155 attempts BIGINT NOT NULL DEFAULT 0, \
156 last_error TEXT\
157 )",
158 )
159 .execute(executor)
160 .await
161 .map_err(cool_error_from_sqlx)?;
162
163 Ok(())
164}
165
166pub(crate) async fn enqueue_event_outbox<'e, E, T>(
167 executor: E,
168 model: &'static str,
169 operation: ModelEventKind,
170 data: &T,
171) -> Result<(), CoolError>
172where
173 E: sqlx::Executor<'e, Database = sqlx::Postgres>,
174 T: serde::Serialize,
175{
176 let payload = serde_json::to_value(data)
177 .map_err(|error| CoolError::Codec(format!("failed to encode event payload: {error}")))?;
178 sqlx::query(
179 "INSERT INTO cratestack_event_outbox (event_id, model, operation, occurred_at, payload) \
180 VALUES ($1, $2, $3, $4, $5)",
181 )
182 .bind(uuid::Uuid::new_v4())
183 .bind(model)
184 .bind(operation.as_str())
185 .bind(chrono::Utc::now())
186 .bind(payload)
187 .execute(executor)
188 .await
189 .map_err(cool_error_from_sqlx)?;
190
191 Ok(())
192}