Skip to main content

a3s_flow/store/
sqlite.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use a3s_orm::{
5    sql_query, Database, Executor, FromRow, Migrator, Query, SqlQuery, SqliteDialect, SqliteError,
6    SqliteExecutor, SqliteRow, SqliteTransaction, SqliteTransactionError,
7};
8use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use uuid::Uuid;
11
12use crate::error::{FlowError, Result};
13use crate::model::{
14    ActiveHookSnapshot, FlowEvent, FlowEventEnvelope, HookSnapshot, HookStatus, ScheduledWakeup,
15};
16
17use super::{scheduled_wakeup_from_row, scheduled_wakeup_key, sqlite_migrations, FlowEventStore};
18
19mod retention;
20
21/// A3S ORM-backed SQLite event store for single-node durable hosts.
22///
23/// The store keeps one row per [`FlowEventEnvelope`] and uses an ORM-managed
24/// immediate transaction for expected-sequence append safety and audit-safe
25/// whole-history retention.
26#[derive(Clone)]
27pub struct SqliteEventStore {
28    executor: SqliteExecutor,
29}
30
31impl fmt::Debug for SqliteEventStore {
32    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33        formatter
34            .debug_struct("SqliteEventStore")
35            .finish_non_exhaustive()
36    }
37}
38
39impl SqliteEventStore {
40    /// Open a SQLite database and run Flow migrations.
41    ///
42    /// The common `sqlite::memory:`, `sqlite://:memory:`, and `:memory:` forms
43    /// create an in-memory database. Other values may be paths or use a
44    /// `sqlite:` or `sqlite://` prefix.
45    pub async fn connect(database_url: impl AsRef<str>) -> Result<Self> {
46        let database_url = database_url.as_ref().trim();
47        let executor = if matches!(
48            database_url,
49            "sqlite::memory:" | "sqlite://:memory:" | ":memory:"
50        ) {
51            SqliteExecutor::open_in_memory()
52                .await
53                .map_err(sqlite_driver_error)?
54        } else {
55            let path = sqlite_path(database_url)?;
56            ensure_sqlite_parent_dir(&path).await?;
57            SqliteExecutor::open(path)
58                .await
59                .map_err(sqlite_driver_error)?
60        };
61        Self::from_executor(executor).await
62    }
63
64    /// Create a store from a configured executor and run Flow migrations.
65    pub async fn from_executor(executor: SqliteExecutor) -> Result<Self> {
66        Migrator::new(executor.clone())
67            .run(sqlite_migrations())
68            .await
69            .map_err(|error| FlowError::Store(format!("SQLite Flow migration failed: {error}")))?;
70        Ok(Self { executor })
71    }
72
73    /// Return the executor used by this store.
74    pub fn executor(&self) -> &SqliteExecutor {
75        &self.executor
76    }
77
78    async fn append_with_expected_sequence(
79        &self,
80        run_id: &str,
81        expected_sequence: Option<u64>,
82        event: FlowEvent,
83    ) -> Result<FlowEventEnvelope> {
84        let run_id = run_id.to_string();
85        let result = self
86            .executor
87            .transaction(|transaction| {
88                Box::pin(async move {
89                    let linked_run_id =
90                        retention::required_linked_flow_run_id(&event).map(str::to_string);
91                    retention::ensure_sqlite_history_not_tombstoned(transaction, &run_id).await?;
92                    if let Some(linked_run_id) = linked_run_id.as_deref() {
93                        retention::ensure_sqlite_history_not_tombstoned(transaction, linked_run_id)
94                            .await?;
95                        if latest_sqlite_sequence(transaction, linked_run_id).await? == 0 {
96                            return Err(FlowError::RunNotFound(linked_run_id.to_string()));
97                        }
98                    }
99                    let actual_sequence = latest_sqlite_sequence(transaction, &run_id).await?;
100                    if let Some(expected_sequence) = expected_sequence {
101                        if actual_sequence != expected_sequence {
102                            return Err(FlowError::EventConflict {
103                                run_id,
104                                expected_sequence,
105                                actual_sequence,
106                            });
107                        }
108                    }
109                    if let FlowEvent::HookCreated { hook_id, token, .. } = &event {
110                        ensure_sqlite_active_hook_available(transaction, &run_id, hook_id, token)
111                            .await?;
112                    }
113
114                    let envelope = FlowEventEnvelope {
115                        run_id,
116                        sequence: actual_sequence + 1,
117                        event_id: Uuid::new_v4(),
118                        timestamp: Utc::now(),
119                        event,
120                    };
121                    insert_sqlite_envelope(transaction, &envelope).await?;
122                    Ok(envelope)
123                })
124            })
125            .await;
126        map_sqlite_transaction(result)
127    }
128}
129
130#[async_trait]
131impl FlowEventStore for SqliteEventStore {
132    async fn append(&self, run_id: &str, event: FlowEvent) -> Result<FlowEventEnvelope> {
133        self.append_with_expected_sequence(run_id, None, event)
134            .await
135    }
136
137    async fn append_if_sequence(
138        &self,
139        run_id: &str,
140        expected_sequence: u64,
141        event: FlowEvent,
142    ) -> Result<FlowEventEnvelope> {
143        self.append_with_expected_sequence(run_id, Some(expected_sequence), event)
144            .await
145    }
146
147    async fn list(&self, run_id: &str) -> Result<Vec<FlowEventEnvelope>> {
148        let database = Database::new(SqliteDialect, self.executor.clone());
149        let rows = database
150            .fetch_all_as(
151                sql_query::<(String, i64, String, String, String)>(
152                    "SELECT run_id, sequence, event_id, timestamp, event_json \
153                     FROM flow_events WHERE run_id = ",
154                )
155                .bind(run_id)
156                .append(" ORDER BY sequence ASC"),
157            )
158            .await
159            .map_err(sqlite_orm_error)?
160            .rows;
161        if rows.is_empty() {
162            return Err(FlowError::RunNotFound(run_id.to_string()));
163        }
164        rows.into_iter().map(row_to_envelope).collect()
165    }
166
167    async fn list_run_ids(&self) -> Result<Vec<String>> {
168        let database = Database::new(SqliteDialect, self.executor.clone());
169        Ok(database
170            .fetch_all_as(sql_query::<String>(
171                "SELECT DISTINCT run_id FROM flow_events ORDER BY run_id ASC",
172            ))
173            .await
174            .map_err(sqlite_orm_error)?
175            .rows)
176    }
177
178    async fn list_due_wakeups(&self, now: DateTime<Utc>) -> Result<Vec<ScheduledWakeup>> {
179        let database = Database::new(SqliteDialect, self.executor.clone());
180        database
181            .fetch_all_as(
182                sql_query::<(String, i64, String, String, Option<String>)>(
183                    "SELECT wakeup.run_id, wakeup.wakeup_kind, wakeup.subject_id, \
184                     wakeup.scheduled_at_key, \
185                     json_extract(created.event_json, '$.spec.runtime_build_id') \
186                     FROM flow_scheduled_wakeups AS wakeup \
187                     JOIN flow_events AS created \
188                       ON created.run_id = wakeup.run_id AND created.sequence = 1 \
189                     WHERE wakeup.scheduled_at_key <= ",
190                )
191                .bind(scheduled_wakeup_key(now))
192                .append(" ORDER BY wakeup.wakeup_kind, wakeup.run_id, wakeup.subject_id"),
193            )
194            .await
195            .map_err(sqlite_orm_error)?
196            .rows
197            .into_iter()
198            .map(scheduled_wakeup_from_row)
199            .collect()
200    }
201
202    async fn next_scheduled_wakeup(&self) -> Result<Option<ScheduledWakeup>> {
203        let database = Database::new(SqliteDialect, self.executor.clone());
204        database
205            .fetch_all_as(sql_query::<(String, i64, String, String, Option<String>)>(
206                "SELECT wakeup.run_id, wakeup.wakeup_kind, wakeup.subject_id, \
207                 wakeup.scheduled_at_key, \
208                 json_extract(created.event_json, '$.spec.runtime_build_id') \
209                 FROM flow_scheduled_wakeups AS wakeup \
210                 JOIN flow_events AS created \
211                   ON created.run_id = wakeup.run_id AND created.sequence = 1 \
212                 ORDER BY wakeup.scheduled_at_key, wakeup.run_id, \
213                          wakeup.wakeup_kind, wakeup.subject_id LIMIT 1",
214            ))
215            .await
216            .map_err(sqlite_orm_error)?
217            .rows
218            .into_iter()
219            .next()
220            .map(scheduled_wakeup_from_row)
221            .transpose()
222    }
223
224    async fn find_active_hooks_by_token(&self, token: &str) -> Result<Vec<ActiveHookSnapshot>> {
225        let database = Database::new(SqliteDialect, self.executor.clone());
226        database
227            .fetch_all_as(
228                sql_query::<(String, String, String, String)>(
229                    "SELECT run_id, hook_id, token, metadata_json \
230                     FROM flow_active_hooks WHERE token = ",
231                )
232                .bind(token)
233                .append(" ORDER BY run_id, hook_id"),
234            )
235            .await
236            .map_err(sqlite_orm_error)?
237            .rows
238            .into_iter()
239            .map(active_hook_from_row)
240            .collect()
241    }
242
243    async fn list_active_hooks(&self) -> Result<Vec<ActiveHookSnapshot>> {
244        let database = Database::new(SqliteDialect, self.executor.clone());
245        database
246            .fetch_all_as(sql_query::<(String, String, String, String)>(
247                "SELECT run_id, hook_id, token, metadata_json \
248                 FROM flow_active_hooks ORDER BY run_id, hook_id",
249            ))
250            .await
251            .map_err(sqlite_orm_error)?
252            .rows
253            .into_iter()
254            .map(active_hook_from_row)
255            .collect()
256    }
257}
258
259pub(super) async fn execute_sqlite<E>(executor: &E, query: SqlQuery<()>) -> Result<u64>
260where
261    E: Executor<Row = SqliteRow, Error = SqliteError>,
262{
263    let query = query.compile(&SqliteDialect).map_err(sqlite_query_error)?;
264    Ok(executor
265        .execute(&query)
266        .await
267        .map_err(sqlite_driver_error)?
268        .rows_affected)
269}
270
271pub(super) async fn fetch_all_sqlite<T, E>(executor: &E, query: SqlQuery<T>) -> Result<Vec<T>>
272where
273    T: FromRow + Send,
274    E: Executor<Row = SqliteRow, Error = SqliteError>,
275{
276    let query = query.compile(&SqliteDialect).map_err(sqlite_query_error)?;
277    executor
278        .fetch_all(&query)
279        .await
280        .map_err(sqlite_driver_error)?
281        .rows
282        .iter()
283        .map(T::from_row)
284        .collect::<std::result::Result<Vec<_>, _>>()
285        .map_err(sqlite_decode_error)
286}
287
288pub(super) async fn fetch_optional_sqlite<T, E>(
289    executor: &E,
290    query: SqlQuery<T>,
291) -> Result<Option<T>>
292where
293    T: FromRow + Send,
294    E: Executor<Row = SqliteRow, Error = SqliteError>,
295{
296    let mut rows = fetch_all_sqlite(executor, query).await?;
297    match rows.len() {
298        0 => Ok(None),
299        1 => Ok(rows.pop()),
300        actual => Err(FlowError::Store(format!(
301            "SQLite Flow query returned {actual} rows where at most one was expected"
302        ))),
303    }
304}
305
306pub(super) async fn latest_sqlite_sequence(
307    transaction: &SqliteTransaction,
308    run_id: &str,
309) -> Result<u64> {
310    let rows = fetch_all_sqlite(
311        transaction,
312        sql_query::<i64>("SELECT COALESCE(MAX(sequence), 0) FROM flow_events WHERE run_id = ")
313            .bind(run_id),
314    )
315    .await?;
316    let sequence = rows
317        .first()
318        .copied()
319        .ok_or_else(|| FlowError::Store("SQLite sequence query returned no row".to_string()))?;
320    u64::try_from(sequence)
321        .map_err(|error| FlowError::Store(format!("invalid SQLite sequence {sequence}: {error}")))
322}
323
324async fn ensure_sqlite_active_hook_available(
325    transaction: &SqliteTransaction,
326    run_id: &str,
327    hook_id: &str,
328    token: &str,
329) -> Result<()> {
330    let owners = fetch_all_sqlite::<(String, String), _>(
331        transaction,
332        sql_query::<(String, String)>(
333            "SELECT run_id, hook_id FROM flow_active_hooks WHERE token = ",
334        )
335        .bind(token),
336    )
337    .await?;
338    if let Some((existing_run_id, existing_hook_id)) = owners.into_iter().next() {
339        if existing_run_id == run_id && existing_hook_id == hook_id {
340            return Ok(());
341        }
342        return Err(FlowError::HookTokenConflict {
343            token: token.to_string(),
344            existing_run_id,
345            existing_hook_id,
346        });
347    }
348
349    let existing_tokens = fetch_all_sqlite::<String, _>(
350        transaction,
351        sql_query::<String>("SELECT token FROM flow_active_hooks WHERE run_id = ")
352            .bind(run_id)
353            .append(" AND hook_id = ")
354            .bind(hook_id),
355    )
356    .await?;
357    if existing_tokens
358        .first()
359        .is_some_and(|existing_token| existing_token != token)
360    {
361        return Err(FlowError::InvalidTransition(format!(
362            "active hook {hook_id} for run {run_id} already uses a different token (value redacted)"
363        )));
364    }
365    Ok(())
366}
367
368async fn insert_sqlite_envelope(
369    transaction: &SqliteTransaction,
370    envelope: &FlowEventEnvelope,
371) -> Result<()> {
372    let sequence = i64::try_from(envelope.sequence).map_err(|error| {
373        FlowError::Store(format!(
374            "event sequence {} exceeds SQLite integer range: {error}",
375            envelope.sequence
376        ))
377    })?;
378    let query = sql_query::<()>(
379        "INSERT INTO flow_events (run_id, sequence, event_id, timestamp, event_json) VALUES (",
380    )
381    .bind(envelope.run_id.clone())
382    .append(", ")
383    .bind(sequence)
384    .append(", ")
385    .bind(envelope.event_id.to_string())
386    .append(", ")
387    .bind(envelope.timestamp.to_rfc3339())
388    .append(", ")
389    .bind(serde_json::to_string(&envelope.event)?)
390    .append(")")
391    .compile(&SqliteDialect)
392    .map_err(sqlite_query_error)?;
393    transaction
394        .execute(&query)
395        .await
396        .map_err(sqlite_driver_error)?;
397    Ok(())
398}
399
400pub(super) fn row_to_envelope(
401    (run_id, sequence, event_id, timestamp, event_json): (String, i64, String, String, String),
402) -> Result<FlowEventEnvelope> {
403    Ok(FlowEventEnvelope {
404        run_id,
405        sequence: u64::try_from(sequence).map_err(|error| {
406            FlowError::Store(format!("invalid SQLite sequence {sequence}: {error}"))
407        })?,
408        event_id: event_id.parse().map_err(|error| {
409            FlowError::Store(format!("invalid SQLite event id {event_id}: {error}"))
410        })?,
411        timestamp: timestamp.parse().map_err(|error| {
412            FlowError::Store(format!(
413                "invalid SQLite event timestamp {timestamp}: {error}"
414            ))
415        })?,
416        event: serde_json::from_str(&event_json)?,
417    })
418}
419
420fn active_hook_from_row(
421    (run_id, hook_id, token, metadata_json): (String, String, String, String),
422) -> Result<ActiveHookSnapshot> {
423    Ok(ActiveHookSnapshot {
424        run_id,
425        hook: HookSnapshot {
426            hook_id,
427            token,
428            status: HookStatus::Active,
429            metadata: serde_json::from_str(&metadata_json)?,
430            payload: None,
431        },
432    })
433}
434
435fn sqlite_path(database_url: &str) -> Result<PathBuf> {
436    let path = database_url
437        .strip_prefix("sqlite://")
438        .or_else(|| database_url.strip_prefix("sqlite:"))
439        .unwrap_or(database_url)
440        .trim();
441    if path.is_empty() {
442        return Err(FlowError::Store(format!(
443            "invalid SQLite database URL: {database_url}"
444        )));
445    }
446    Ok(PathBuf::from(path))
447}
448
449async fn ensure_sqlite_parent_dir(path: &Path) -> Result<()> {
450    let Some(parent) = path
451        .parent()
452        .filter(|parent| !parent.as_os_str().is_empty())
453    else {
454        return Ok(());
455    };
456    tokio::fs::create_dir_all(parent).await?;
457    Ok(())
458}
459
460pub(super) fn map_sqlite_transaction<T>(
461    result: std::result::Result<T, SqliteTransactionError<FlowError>>,
462) -> Result<T> {
463    match result {
464        Ok(value) => Ok(value),
465        Err(SqliteTransactionError::Operation(error)) => Err(error),
466        Err(error) => Err(FlowError::Store(format!(
467            "SQLite Flow transaction failed: {error}"
468        ))),
469    }
470}
471
472fn sqlite_query_error(error: a3s_orm::Error) -> FlowError {
473    FlowError::Store(format!("SQLite Flow query build failed: {error}"))
474}
475
476fn sqlite_driver_error(error: a3s_orm::SqliteError) -> FlowError {
477    FlowError::Store(format!("SQLite Flow storage failed: {error}"))
478}
479
480fn sqlite_decode_error(error: a3s_orm::DecodeError) -> FlowError {
481    FlowError::Store(format!("SQLite Flow row decoding failed: {error}"))
482}
483
484fn sqlite_orm_error(error: a3s_orm::DatabaseError<a3s_orm::SqliteError>) -> FlowError {
485    FlowError::Store(format!("SQLite Flow storage failed: {error}"))
486}