Skip to main content

jerrycan_db/
lib.rs

1//! Database extension: one URL-driven `Db` over SQLite and Postgres
2//! (sea-orm's `DatabaseConnection`), module-owned dual-dialect migrations, and a
3//! deterministic `?`→`$n` translator (placeholders are library-owned; ours is
4//! quote-blind and safe because generated SQL never embeds string literals).
5#![forbid(unsafe_code)]
6
7use jerrycan_core::{App, Error, Extension, Result};
8use sea_orm::{ConnectionTrait, Database, DatabaseConnection, Statement, TransactionTrait};
9
10/// The reserved 64-bit Postgres advisory-lock key that serializes a migration
11/// run. Concurrent migrators (e.g. several app nodes booting at once) all take
12/// `pg_advisory_xact_lock(MIGRATION_ADVISORY_KEY)` at the top of the migration
13/// transaction; the first holder applies the DDL and the others block, then
14/// proceed and find every migration already recorded (applying nothing). The
15/// lock auto-releases at COMMIT. Distinct from `jerrycan_jobs`'
16/// `JOBS_CRON_ADVISORY_KEY` so a migration and a cron tick never contend.
17/// Value is an arbitrary jerrycan-migrate magic constant ("jCmig" + 0001).
18pub const MIGRATION_ADVISORY_KEY: i64 = 0x6A_43_6D_69_67_00_00_01;
19
20// Connections are driven by sea-orm; generated repos build ALL SQL through
21// sea-query (dialect rendering is library-owned: placeholders, RETURNING,
22// quoting). Re-exported so generated crates depend on `jerrycan` alone.
23pub use sea_orm;
24pub use sea_query;
25pub use sea_query_binder;
26
27/// Which engine the connection speaks. Generated code branches on this for the
28/// few statements that genuinely differ (insert-id strategies, DDL).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Backend {
31    Sqlite,
32    Postgres,
33}
34
35/// The database dependency: a cloneable connection handle. Register app-wide
36/// with `App::new().extend(db)` (or `.provide(db)` — `extend` is the §6 seam).
37#[derive(Clone)]
38pub struct Db {
39    conn: DatabaseConnection,
40    backend: Backend,
41    url: String,
42}
43
44impl Db {
45    /// Connect by URL: `sqlite::memory:`, `sqlite://path.db`, `postgres://…`.
46    pub async fn connect(url: &str) -> Result<Self> {
47        let backend = if url.starts_with("postgres") {
48            Backend::Postgres
49        } else if url.starts_with("sqlite") {
50            Backend::Sqlite
51        } else {
52            return Err(Error::internal(format!(
53                "unsupported database url scheme: `{url}` (sqlite:// or postgres:// in v0)"
54            )));
55        };
56        // Decision #4: one connection for sqlite (memory correctness + writer lock),
57        // small default pool for postgres.
58        let max = match backend {
59            Backend::Sqlite => 1,
60            Backend::Postgres => 5,
61        };
62        let mut opts = sea_orm::ConnectOptions::new(url.to_string());
63        opts.max_connections(max);
64        let conn = Database::connect(opts).await.map_err(db_error)?;
65        Ok(Self {
66            conn,
67            backend,
68            url: url.to_string(),
69        })
70    }
71
72    /// `JERRYCAN_DATABASE_URL`, defaulting to `sqlite::memory:` for dev.
73    pub async fn from_env() -> Result<Self> {
74        let url = std::env::var("JERRYCAN_DATABASE_URL")
75            .unwrap_or_else(|_| "sqlite::memory:".to_string());
76        Self::connect(&url).await
77    }
78
79    /// The underlying sea-orm connection. Generated repos and migrations execute
80    /// through this handle (`execute_unprepared`, `query_one`, …).
81    pub fn conn(&self) -> &DatabaseConnection {
82        &self.conn
83    }
84
85    pub fn backend(&self) -> Backend {
86        self.backend
87    }
88
89    /// The URL this handle connected with. Extension crates (jerrycan-realtime)
90    /// use it to open sessions the pool cannot serve: LISTEN connections, the
91    /// replication socket, and long-held advisory-lock sessions.
92    pub fn url(&self) -> &str {
93        &self.url
94    }
95
96    /// Backend-correct placeholders for a `?`-style query string.
97    pub fn sql(&self, query: &str) -> String {
98        translate_placeholders(query, self.backend)
99    }
100
101    /// The sea-query builder matching this connection's dialect. Generated repos
102    /// pass it to `build_any` so one builder call renders correct SQL
103    /// (placeholders, RETURNING, quoting) for whichever engine is connected.
104    pub fn query_builder(&self) -> &'static dyn sea_query::QueryBuilder {
105        match self.backend {
106            Backend::Sqlite => &sea_query::SqliteQueryBuilder,
107            Backend::Postgres => &sea_query::PostgresQueryBuilder,
108        }
109    }
110
111    /// The sea-orm backend tag for this connection — selects the dialect when
112    /// constructing a [`Statement`] from raw SQL and bound values.
113    fn backend_db(&self) -> sea_orm::DatabaseBackend {
114        match self.backend {
115            Backend::Sqlite => sea_orm::DatabaseBackend::Sqlite,
116            Backend::Postgres => sea_orm::DatabaseBackend::Postgres,
117        }
118    }
119}
120
121/// One migration, both dialects. Generated apps embed these via the tool-owned
122/// `app/src/migrations.rs`; modules own the .sql files (spec §5 anatomy).
123#[derive(Debug, Clone, Copy)]
124pub struct Migration {
125    pub name: &'static str,
126    pub sqlite: &'static str,
127    pub postgres: &'static str,
128}
129
130/// Runtime-loaded migration (CLI `jerrycan db migrate` reads module files from
131/// disk). The owned twin of [`Migration`]; both delegate to the same runner.
132#[derive(Debug, Clone)]
133pub struct OwnedMigration {
134    pub name: String,
135    pub sqlite: String,
136    pub postgres: String,
137}
138
139impl Db {
140    /// Apply pending migrations in slice order; returns the names applied.
141    /// Tracking table `_jerrycan_migrations` remembers what ran. The whole run
142    /// is **atomic and concurrency-safe**: it runs in one transaction guarded by
143    /// a Postgres advisory lock, so several app instances booting at once can't
144    /// race the not-yet-applied check and double-apply the DDL — a failure rolls
145    /// the entire run back (all-or-nothing; no half-migrated state).
146    pub async fn migrate(&self, migrations: &[Migration]) -> Result<Vec<String>> {
147        self.migrate_iter(migrations.iter().map(|m| (m.name, m.sqlite, m.postgres)))
148            .await
149    }
150
151    /// Owned-migration twin of [`migrate`](Self::migrate) — same runner.
152    pub async fn migrate_owned(&self, migrations: &[OwnedMigration]) -> Result<Vec<String>> {
153        self.migrate_iter(
154            migrations
155                .iter()
156                .map(|m| (m.name.as_str(), m.sqlite.as_str(), m.postgres.as_str())),
157        )
158        .await
159    }
160
161    /// The shared core: apply each `(name, sqlite, postgres)` in order, skipping
162    /// already-recorded names. The whole run is one transaction; on Postgres a
163    /// transaction-scoped advisory lock serializes concurrent migrators so the
164    /// not-yet-applied check and the (non-`IF NOT EXISTS`) DDL can't race. A
165    /// failure rolls the transaction back — all-or-nothing.
166    async fn migrate_iter<'a>(
167        &self,
168        items: impl Iterator<Item = (&'a str, &'a str, &'a str)>,
169    ) -> Result<Vec<String>> {
170        // One transaction for the whole run: atomic, and the pinned connection
171        // lets the Postgres advisory lock span every statement. On SQLite the
172        // single writer (pool max = 1) already serializes; the transaction just
173        // makes the run atomic.
174        let txn = self.conn.begin().await.map_err(db_error)?;
175
176        if self.backend == Backend::Postgres {
177            // Serialize concurrent migrators: the first node holds the lock and
178            // migrates; the rest block here, then proceed and find every name
179            // already recorded (applying nothing). Auto-released at COMMIT.
180            txn.execute(Statement::from_string(
181                sea_orm::DatabaseBackend::Postgres,
182                format!("SELECT pg_advisory_xact_lock({MIGRATION_ADVISORY_KEY})"),
183            ))
184            .await
185            .map_err(db_error)?;
186        }
187
188        txn.execute_unprepared(
189            "CREATE TABLE IF NOT EXISTS _jerrycan_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)",
190        )
191        .await
192        .map_err(db_error)?;
193
194        let mut applied = Vec::new();
195        for (name, sqlite, postgres) in items {
196            let seen = txn
197                .query_one(Statement::from_sql_and_values(
198                    self.backend_db(),
199                    self.sql("SELECT name FROM _jerrycan_migrations WHERE name = ?"),
200                    [name.into()],
201                ))
202                .await
203                .map_err(db_error)?;
204            if seen.is_some() {
205                continue;
206            }
207            let statement = match self.backend {
208                Backend::Sqlite => sqlite,
209                Backend::Postgres => postgres,
210            };
211            txn.execute_unprepared(statement).await.map_err(|e| {
212                eprintln!("jerrycan-db: migration `{name}` failed");
213                db_error(e)
214            })?;
215            txn.execute(Statement::from_sql_and_values(
216                self.backend_db(),
217                self.sql("INSERT INTO _jerrycan_migrations (name, applied_at) VALUES (?, ?)"),
218                [name.into(), chrono_free_timestamp().into()],
219            ))
220            .await
221            .map_err(db_error)?;
222            applied.push(name.to_string());
223        }
224        txn.commit().await.map_err(db_error)?;
225        Ok(applied)
226    }
227}
228
229/// RFC3339-ish UTC timestamp without a chrono dependency (seconds precision).
230fn chrono_free_timestamp() -> String {
231    let secs = std::time::SystemTime::now()
232        .duration_since(std::time::UNIX_EPOCH)
233        .map(|d| d.as_secs())
234        .unwrap_or(0);
235    format!("unix:{secs}")
236}
237
238/// `?` → `$1, $2, …` for Postgres; identity for SQLite. Quote-blind by design:
239/// generated SQL never embeds string literals (binds carry all values).
240pub fn translate_placeholders(query: &str, backend: Backend) -> String {
241    match backend {
242        Backend::Sqlite => query.to_string(),
243        Backend::Postgres => {
244            let mut out = String::with_capacity(query.len() + 8);
245            let mut n = 0;
246            for ch in query.chars() {
247                if ch == '?' {
248                    n += 1;
249                    out.push('$');
250                    out.push_str(&n.to_string());
251                } else {
252                    out.push(ch);
253                }
254            }
255            out
256        }
257    }
258}
259
260/// Map any sea-orm error to a stable JC code without leaking internals; the
261/// underlying detail goes to stderr for the operator. Unique-key violations
262/// are the client's fault (a re-POSTed id), not a server fault — they map to
263/// 409 JC0409 so duplicate writes can't pollute 5xx alerting.
264pub fn db_error(e: sea_orm::DbErr) -> Error {
265    eprintln!("jerrycan-db: {e}");
266    if matches!(
267        e.sql_err(),
268        Some(sea_orm::SqlErr::UniqueConstraintViolation(_))
269    ) {
270        return Error::conflict("conflict: a row with this key already exists");
271    }
272    Error::new(
273        jerrycan_core::http::StatusCode::INTERNAL_SERVER_ERROR,
274        "JC0510",
275        "database error",
276    )
277}
278
279impl Extension for Db {
280    fn register(self, app: App) -> App {
281        app.provide(self)
282    }
283}
284
285/// Re-exported for generated code that still reaches for sqlx types directly;
286/// route crates never declare sqlx themselves.
287pub use sqlx;
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[tokio::test]
294    async fn db_exposes_its_connection_url() {
295        let db = Db::connect("sqlite::memory:").await.unwrap();
296        assert_eq!(db.url(), "sqlite::memory:");
297    }
298
299    #[tokio::test]
300    async fn connects_and_executes_via_sea_orm() {
301        // Decision #4: sqlite connections are single-connection — otherwise every
302        // pooled connection of sqlite::memory: is its OWN empty database.
303        let db = Db::connect("sqlite::memory:").await.unwrap();
304        assert_eq!(db.backend(), Backend::Sqlite);
305        db.conn()
306            .execute_unprepared("CREATE TABLE t (id INTEGER PRIMARY KEY)")
307            .await
308            .unwrap();
309    }
310
311    #[test]
312    fn placeholder_translation_is_backend_aware() {
313        assert_eq!(
314            translate_placeholders("INSERT INTO t (a, b) VALUES (?, ?)", Backend::Postgres),
315            "INSERT INTO t (a, b) VALUES ($1, $2)"
316        );
317        assert_eq!(
318            translate_placeholders("INSERT INTO t (a, b) VALUES (?, ?)", Backend::Sqlite),
319            "INSERT INTO t (a, b) VALUES (?, ?)"
320        );
321    }
322
323    #[tokio::test]
324    async fn from_env_defaults_to_sqlite_memory() {
325        // JERRYCAN_DATABASE_URL unset in the test env → default.
326        let db = Db::from_env().await.unwrap();
327        assert_eq!(db.backend(), Backend::Sqlite);
328    }
329
330    #[test]
331    fn db_errors_are_jc0510_and_leak_nothing() {
332        let e = db_error(sea_orm::DbErr::Custom("boom".into()));
333        assert_eq!(e.code(), "JC0510");
334        assert_eq!(e.message(), "database error");
335    }
336
337    /// The whole generated-repo chain in one place: sea-query renders the SQL
338    /// and binds the values; the connection is only the executor. If this
339    /// breaks, every generated repo breaks with it.
340    #[tokio::test]
341    async fn sea_query_builds_and_executes_via_the_connection() {
342        use sea_query::{Alias, Expr, Query};
343
344        let db = Db::connect("sqlite::memory:").await.unwrap();
345        db.conn()
346            .execute_unprepared("CREATE TABLE sq (id INTEGER PRIMARY KEY, title TEXT NOT NULL)")
347            .await
348            .unwrap();
349
350        let (sql, values) = Query::insert()
351            .into_table(Alias::new("sq"))
352            .columns([Alias::new("id"), Alias::new("title")])
353            .values_panic([7.into(), "hello".into()])
354            .returning(Query::returning().columns([Alias::new("id")]))
355            .build_any(db.query_builder());
356        let row = db
357            .conn()
358            .query_one(Statement::from_sql_and_values(db.backend_db(), sql, values))
359            .await
360            .unwrap()
361            .expect("RETURNING id row");
362        assert_eq!(
363            row.try_get::<i64>("", "id").unwrap(),
364            7,
365            "RETURNING id round-trips"
366        );
367
368        let (sql, values) = Query::select()
369            .columns([Alias::new("id"), Alias::new("title")])
370            .from(Alias::new("sq"))
371            .and_where(Expr::col(Alias::new("id")).eq(7))
372            .build_any(db.query_builder());
373        let row = db
374            .conn()
375            .query_one(Statement::from_sql_and_values(db.backend_db(), sql, values))
376            .await
377            .unwrap()
378            .expect("select row");
379        assert_eq!(row.try_get::<String>("", "title").unwrap(), "hello");
380    }
381
382    /// A duplicate key is the CLIENT's fault: it must surface as 409 JC0409,
383    /// not a 500 — a re-POSTed id must never trip server-fault alerting.
384    #[tokio::test]
385    async fn unique_violations_map_to_409_conflict() {
386        let db = Db::connect("sqlite::memory:").await.unwrap();
387        db.conn()
388            .execute_unprepared("CREATE TABLE u (id INTEGER PRIMARY KEY, t TEXT)")
389            .await
390            .unwrap();
391        db.conn()
392            .execute_unprepared("INSERT INTO u VALUES (1, 'a')")
393            .await
394            .unwrap();
395        let dup = db
396            .conn()
397            .execute_unprepared("INSERT INTO u VALUES (1, 'b')")
398            .await
399            .expect_err("duplicate pk must fail");
400        let e = db_error(dup);
401        assert_eq!(e.code(), "JC0409");
402        assert_eq!(e.status().as_u16(), 409);
403        // Still no internals in the message.
404        assert!(!e.message().contains("sqlite"), "{}", e.message());
405    }
406
407    fn demo_migrations() -> Vec<Migration> {
408        vec![
409            Migration {
410                name: "0001_create_todos",
411                sqlite: "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)",
412                postgres: "CREATE TABLE todos (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL)",
413            },
414            Migration {
415                name: "0002_add_done",
416                sqlite: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT 0",
417                postgres: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT FALSE",
418            },
419        ]
420    }
421
422    #[tokio::test]
423    async fn migrations_apply_in_order_and_only_once() {
424        let db = Db::connect("sqlite::memory:").await.unwrap();
425        let applied = db.migrate(&demo_migrations()).await.unwrap();
426        assert_eq!(applied, vec!["0001_create_todos", "0002_add_done"]);
427
428        // Re-running applies nothing (tracking table remembers).
429        let applied = db.migrate(&demo_migrations()).await.unwrap();
430        assert!(applied.is_empty());
431
432        // The schema is genuinely there.
433        db.conn()
434            .execute_unprepared("INSERT INTO todos (title, done) VALUES ('x', 1)")
435            .await
436            .unwrap();
437    }
438
439    #[tokio::test]
440    async fn owned_migrations_apply_in_order_and_only_once() {
441        let db = Db::connect("sqlite::memory:").await.unwrap();
442        let owned = vec![
443            OwnedMigration {
444                name: "0001_create_todos".into(),
445                sqlite:
446                    "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)"
447                        .into(),
448                postgres: "CREATE TABLE todos (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL)"
449                    .into(),
450            },
451            OwnedMigration {
452                name: "0002_add_done".into(),
453                sqlite: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT 0".into(),
454                postgres: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT FALSE".into(),
455            },
456        ];
457        let applied = db.migrate_owned(&owned).await.unwrap();
458        assert_eq!(applied, vec!["0001_create_todos", "0002_add_done"]);
459        // Re-running applies nothing (shares the tracking table with `migrate`).
460        let applied = db.migrate_owned(&owned).await.unwrap();
461        assert!(applied.is_empty());
462    }
463
464    /// The transaction idiom is the framework's atomicity guarantee: a closure
465    /// returning `Err` must roll back EVERY statement it issued, leaving no
466    /// partial writes. If this fails, the sea-orm feature set is wrong — fix the
467    /// Cargo features, never weaken the test.
468    #[tokio::test]
469    async fn transactions_roll_back_on_error() {
470        use sea_orm::TransactionTrait;
471        let db = Db::connect("sqlite::memory:").await.unwrap();
472        db.conn()
473            .execute_unprepared("CREATE TABLE t (id INTEGER PRIMARY KEY)")
474            .await
475            .unwrap();
476        let r = db
477            .conn()
478            .transaction::<_, (), sea_orm::DbErr>(|txn| {
479                Box::pin(async move {
480                    txn.execute_unprepared("INSERT INTO t VALUES (1)").await?;
481                    Err(sea_orm::DbErr::Custom("boom".into()))
482                })
483            })
484            .await;
485        assert!(r.is_err());
486        let rows = db
487            .conn()
488            .query_all(sea_orm::Statement::from_string(
489                sea_orm::DatabaseBackend::Sqlite,
490                "SELECT id FROM t",
491            ))
492            .await
493            .unwrap();
494        assert!(rows.is_empty(), "rollback must leave no rows");
495    }
496
497    #[tokio::test]
498    async fn a_failing_migration_surfaces_jc0510_and_is_not_recorded() {
499        let db = Db::connect("sqlite::memory:").await.unwrap();
500        let bad = vec![Migration {
501            name: "0001_broken",
502            sqlite: "CREATE GARBAGE",
503            postgres: "CREATE GARBAGE",
504        }];
505        let err = db.migrate(&bad).await.unwrap_err();
506        assert_eq!(err.code(), "JC0510");
507
508        // Fixing it lets the same name apply afresh — failures are not recorded.
509        let good = vec![Migration {
510            name: "0001_broken",
511            sqlite: "CREATE TABLE ok (x BIGINT)",
512            postgres: "CREATE TABLE ok (x BIGINT)",
513        }];
514        let applied = db.migrate(&good).await.unwrap();
515        assert_eq!(applied, vec!["0001_broken"]);
516    }
517
518    /// Several app instances booting at once all call `migrate()` against the
519    /// same Postgres. Without the advisory-lock serialization they race the
520    /// not-yet-applied check and double-apply the (non-`IF NOT EXISTS`) DDL —
521    /// one node crashes with a unique violation (JC0409/JC0510). With it, every
522    /// migrator succeeds and the migration is applied EXACTLY once. Needs a live
523    /// Postgres; run with `JERRYCAN_TEST_PG_URL=… cargo test -p jerrycan-db -- --ignored`.
524    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
525    #[ignore = "needs a local postgres (set JERRYCAN_TEST_PG_URL)"]
526    async fn concurrent_migrators_do_not_race() {
527        let Ok(url) = std::env::var("JERRYCAN_TEST_PG_URL") else {
528            eprintln!("SKIP: JERRYCAN_TEST_PG_URL not set");
529            return;
530        };
531        // A run-unique table so repeated runs against a persistent DB don't
532        // collide (the tracking row is keyed by the unique migration name).
533        let nanos = std::time::SystemTime::now()
534            .duration_since(std::time::UNIX_EPOCH)
535            .unwrap()
536            .as_nanos();
537        let table = format!("mig_race_{nanos}");
538        let name = format!("{table}_0001");
539        let migrations = vec![Migration {
540            name: Box::leak(name.clone().into_boxed_str()),
541            sqlite: "",
542            postgres: Box::leak(
543                format!("CREATE TABLE {table} (id BIGSERIAL PRIMARY KEY, v TEXT NOT NULL)")
544                    .into_boxed_str(),
545            ),
546        }];
547        let migrations = std::sync::Arc::new(migrations);
548
549        // 8 separate connection pools = 8 genuine concurrent "nodes".
550        let mut handles = Vec::new();
551        for _ in 0..8 {
552            let url = url.clone();
553            let migrations = migrations.clone();
554            handles.push(tokio::spawn(async move {
555                let db = Db::connect(&url).await.expect("connect");
556                db.migrate(&migrations).await
557            }));
558        }
559
560        let mut total_applied = 0usize;
561        for h in handles {
562            let applied = h.await.expect("task").expect("migrate must not error");
563            total_applied += applied.len();
564        }
565        assert_eq!(
566            total_applied, 1,
567            "exactly one migrator applies the migration; the rest see it recorded"
568        );
569
570        // The table exists and is usable.
571        let db = Db::connect(&url).await.unwrap();
572        db.conn()
573            .execute_unprepared(&format!("INSERT INTO {table} (v) VALUES ('ok')"))
574            .await
575            .unwrap();
576        db.conn()
577            .execute_unprepared(&format!("DROP TABLE {table}"))
578            .await
579            .unwrap();
580    }
581}