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        if backend == Backend::Sqlite {
65            // FK enforcement is a framework guarantee, so pin it rather than
66            // inherit it: sqlx-sqlite happens to default `foreign_keys=ON`
67            // today, but a guarantee must not rest on an upstream default.
68            // Set through connect options so EVERY pooled connection carries
69            // it by construction (a post-connect `PRAGMA` would be lost on
70            // pool reconnect). Postgres enforces FKs natively — nothing to pin.
71            opts.map_sqlx_sqlite_opts(|o| o.foreign_keys(true));
72        }
73        let conn = Database::connect(opts).await.map_err(db_error)?;
74        Ok(Self {
75            conn,
76            backend,
77            url: url.to_string(),
78        })
79    }
80
81    /// `JERRYCAN_DATABASE_URL`, defaulting to `sqlite::memory:` for dev.
82    pub async fn from_env() -> Result<Self> {
83        let url = std::env::var("JERRYCAN_DATABASE_URL")
84            .unwrap_or_else(|_| "sqlite::memory:".to_string());
85        Self::connect(&url).await
86    }
87
88    /// The underlying sea-orm connection. Generated repos and migrations execute
89    /// through this handle (`execute_unprepared`, `query_one`, …).
90    pub fn conn(&self) -> &DatabaseConnection {
91        &self.conn
92    }
93
94    pub fn backend(&self) -> Backend {
95        self.backend
96    }
97
98    /// The URL this handle connected with. Extension crates (jerrycan-realtime)
99    /// use it to open sessions the pool cannot serve: LISTEN connections, the
100    /// replication socket, and long-held advisory-lock sessions.
101    pub fn url(&self) -> &str {
102        &self.url
103    }
104
105    /// Backend-correct placeholders for a `?`-style query string.
106    pub fn sql(&self, query: &str) -> String {
107        translate_placeholders(query, self.backend)
108    }
109
110    /// The sea-query builder matching this connection's dialect. Generated repos
111    /// pass it to `build_any` so one builder call renders correct SQL
112    /// (placeholders, RETURNING, quoting) for whichever engine is connected.
113    pub fn query_builder(&self) -> &'static dyn sea_query::QueryBuilder {
114        match self.backend {
115            Backend::Sqlite => &sea_query::SqliteQueryBuilder,
116            Backend::Postgres => &sea_query::PostgresQueryBuilder,
117        }
118    }
119
120    /// The sea-orm backend tag for this connection — selects the dialect when
121    /// constructing a [`Statement`] from raw SQL and bound values.
122    fn backend_db(&self) -> sea_orm::DatabaseBackend {
123        match self.backend {
124            Backend::Sqlite => sea_orm::DatabaseBackend::Sqlite,
125            Backend::Postgres => sea_orm::DatabaseBackend::Postgres,
126        }
127    }
128}
129
130/// One migration, both dialects. Generated apps embed these via the tool-owned
131/// `app/src/migrations.rs`; modules own the .sql files (spec §5 anatomy).
132#[derive(Debug, Clone, Copy)]
133pub struct Migration {
134    pub name: &'static str,
135    pub sqlite: &'static str,
136    pub postgres: &'static str,
137}
138
139/// Runtime-loaded migration (CLI `jerrycan db migrate` reads module files from
140/// disk). The owned twin of [`Migration`]; both delegate to the same runner.
141#[derive(Debug, Clone)]
142pub struct OwnedMigration {
143    pub name: String,
144    pub sqlite: String,
145    pub postgres: String,
146}
147
148impl Db {
149    /// Apply pending migrations in slice order; returns the names applied.
150    /// Tracking table `_jerrycan_migrations` remembers what ran. The whole run
151    /// is **atomic and concurrency-safe**: it runs in one transaction guarded by
152    /// a Postgres advisory lock, so several app instances booting at once can't
153    /// race the not-yet-applied check and double-apply the DDL — a failure rolls
154    /// the entire run back (all-or-nothing; no half-migrated state).
155    pub async fn migrate(&self, migrations: &[Migration]) -> Result<Vec<String>> {
156        self.migrate_iter(migrations.iter().map(|m| (m.name, m.sqlite, m.postgres)))
157            .await
158    }
159
160    /// Owned-migration twin of [`migrate`](Self::migrate) — same runner.
161    pub async fn migrate_owned(&self, migrations: &[OwnedMigration]) -> Result<Vec<String>> {
162        self.migrate_iter(
163            migrations
164                .iter()
165                .map(|m| (m.name.as_str(), m.sqlite.as_str(), m.postgres.as_str())),
166        )
167        .await
168    }
169
170    /// The shared core: apply each `(name, sqlite, postgres)` in order, skipping
171    /// already-recorded names. The whole run is one transaction; on Postgres a
172    /// transaction-scoped advisory lock serializes concurrent migrators so the
173    /// not-yet-applied check and the (non-`IF NOT EXISTS`) DDL can't race. A
174    /// failure rolls the transaction back — all-or-nothing.
175    async fn migrate_iter<'a>(
176        &self,
177        items: impl Iterator<Item = (&'a str, &'a str, &'a str)>,
178    ) -> Result<Vec<String>> {
179        // One transaction for the whole run: atomic, and the pinned connection
180        // lets the Postgres advisory lock span every statement. On SQLite the
181        // single writer (pool max = 1) already serializes; the transaction just
182        // makes the run atomic.
183        let txn = self.conn.begin().await.map_err(db_error)?;
184
185        if self.backend == Backend::Postgres {
186            // Serialize concurrent migrators: the first node holds the lock and
187            // migrates; the rest block here, then proceed and find every name
188            // already recorded (applying nothing). Auto-released at COMMIT.
189            txn.execute(Statement::from_string(
190                sea_orm::DatabaseBackend::Postgres,
191                format!("SELECT pg_advisory_xact_lock({MIGRATION_ADVISORY_KEY})"),
192            ))
193            .await
194            .map_err(db_error)?;
195        }
196
197        txn.execute_unprepared(
198            "CREATE TABLE IF NOT EXISTS _jerrycan_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)",
199        )
200        .await
201        .map_err(db_error)?;
202
203        let mut applied = Vec::new();
204        for (name, sqlite, postgres) in items {
205            let seen = txn
206                .query_one(Statement::from_sql_and_values(
207                    self.backend_db(),
208                    self.sql("SELECT name FROM _jerrycan_migrations WHERE name = ?"),
209                    [name.into()],
210                ))
211                .await
212                .map_err(db_error)?;
213            if seen.is_some() {
214                continue;
215            }
216            let statement = match self.backend {
217                Backend::Sqlite => sqlite,
218                Backend::Postgres => postgres,
219            };
220            txn.execute_unprepared(statement).await.map_err(|e| {
221                eprintln!("jerrycan-db: migration `{name}` failed");
222                db_error(e)
223            })?;
224            txn.execute(Statement::from_sql_and_values(
225                self.backend_db(),
226                self.sql("INSERT INTO _jerrycan_migrations (name, applied_at) VALUES (?, ?)"),
227                [name.into(), chrono_free_timestamp().into()],
228            ))
229            .await
230            .map_err(db_error)?;
231            applied.push(name.to_string());
232        }
233        txn.commit().await.map_err(db_error)?;
234        Ok(applied)
235    }
236}
237
238/// RFC3339-ish UTC timestamp without a chrono dependency (seconds precision).
239fn chrono_free_timestamp() -> String {
240    let secs = std::time::SystemTime::now()
241        .duration_since(std::time::UNIX_EPOCH)
242        .map(|d| d.as_secs())
243        .unwrap_or(0);
244    format!("unix:{secs}")
245}
246
247/// `?` → `$1, $2, …` for Postgres; identity for SQLite. Quote-blind by design:
248/// generated SQL never embeds string literals (binds carry all values).
249pub fn translate_placeholders(query: &str, backend: Backend) -> String {
250    match backend {
251        Backend::Sqlite => query.to_string(),
252        Backend::Postgres => {
253            let mut out = String::with_capacity(query.len() + 8);
254            let mut n = 0;
255            for ch in query.chars() {
256                if ch == '?' {
257                    n += 1;
258                    out.push('$');
259                    out.push_str(&n.to_string());
260                } else {
261                    out.push(ch);
262                }
263            }
264            out
265        }
266    }
267}
268
269/// Map any sea-orm error to a stable JC code without leaking internals; the
270/// underlying detail goes to stderr for the operator. Unique-key violations
271/// are the client's fault (a re-POSTed id), not a server fault — they map to
272/// 409 JC0409 so duplicate writes can't pollute 5xx alerting.
273pub fn db_error(e: sea_orm::DbErr) -> Error {
274    eprintln!("jerrycan-db: {e}");
275    if matches!(
276        e.sql_err(),
277        Some(sea_orm::SqlErr::UniqueConstraintViolation(_))
278    ) {
279        return Error::conflict("conflict: a row with this key already exists");
280    }
281    Error::new(
282        jerrycan_core::http::StatusCode::INTERNAL_SERVER_ERROR,
283        "JC0510",
284        "database error",
285    )
286}
287
288impl Extension for Db {
289    fn register(self, app: App) -> App {
290        app.provide(self)
291    }
292}
293
294/// Re-exported for generated code that still reaches for sqlx types directly;
295/// route crates never declare sqlx themselves.
296pub use sqlx;
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[tokio::test]
303    async fn db_exposes_its_connection_url() {
304        let db = Db::connect("sqlite::memory:").await.unwrap();
305        assert_eq!(db.url(), "sqlite::memory:");
306    }
307
308    #[tokio::test]
309    async fn connects_and_executes_via_sea_orm() {
310        // Decision #4: sqlite connections are single-connection — otherwise every
311        // pooled connection of sqlite::memory: is its OWN empty database.
312        let db = Db::connect("sqlite::memory:").await.unwrap();
313        assert_eq!(db.backend(), Backend::Sqlite);
314        db.conn()
315            .execute_unprepared("CREATE TABLE t (id INTEGER PRIMARY KEY)")
316            .await
317            .unwrap();
318    }
319
320    #[test]
321    fn placeholder_translation_is_backend_aware() {
322        assert_eq!(
323            translate_placeholders("INSERT INTO t (a, b) VALUES (?, ?)", Backend::Postgres),
324            "INSERT INTO t (a, b) VALUES ($1, $2)"
325        );
326        assert_eq!(
327            translate_placeholders("INSERT INTO t (a, b) VALUES (?, ?)", Backend::Sqlite),
328            "INSERT INTO t (a, b) VALUES (?, ?)"
329        );
330    }
331
332    #[tokio::test]
333    async fn from_env_defaults_to_sqlite_memory() {
334        // JERRYCAN_DATABASE_URL unset in the test env → default.
335        let db = Db::from_env().await.unwrap();
336        assert_eq!(db.backend(), Backend::Sqlite);
337    }
338
339    #[test]
340    fn db_errors_are_jc0510_and_leak_nothing() {
341        let e = db_error(sea_orm::DbErr::Custom("boom".into()));
342        assert_eq!(e.code(), "JC0510");
343        assert_eq!(e.message(), "database error");
344    }
345
346    /// The whole generated-repo chain in one place: sea-query renders the SQL
347    /// and binds the values; the connection is only the executor. If this
348    /// breaks, every generated repo breaks with it.
349    #[tokio::test]
350    async fn sea_query_builds_and_executes_via_the_connection() {
351        use sea_query::{Alias, Expr, Query};
352
353        let db = Db::connect("sqlite::memory:").await.unwrap();
354        db.conn()
355            .execute_unprepared("CREATE TABLE sq (id INTEGER PRIMARY KEY, title TEXT NOT NULL)")
356            .await
357            .unwrap();
358
359        let (sql, values) = Query::insert()
360            .into_table(Alias::new("sq"))
361            .columns([Alias::new("id"), Alias::new("title")])
362            .values_panic([7.into(), "hello".into()])
363            .returning(Query::returning().columns([Alias::new("id")]))
364            .build_any(db.query_builder());
365        let row = db
366            .conn()
367            .query_one(Statement::from_sql_and_values(db.backend_db(), sql, values))
368            .await
369            .unwrap()
370            .expect("RETURNING id row");
371        assert_eq!(
372            row.try_get::<i64>("", "id").unwrap(),
373            7,
374            "RETURNING id round-trips"
375        );
376
377        let (sql, values) = Query::select()
378            .columns([Alias::new("id"), Alias::new("title")])
379            .from(Alias::new("sq"))
380            .and_where(Expr::col(Alias::new("id")).eq(7))
381            .build_any(db.query_builder());
382        let row = db
383            .conn()
384            .query_one(Statement::from_sql_and_values(db.backend_db(), sql, values))
385            .await
386            .unwrap()
387            .expect("select row");
388        assert_eq!(row.try_get::<String>("", "title").unwrap(), "hello");
389    }
390
391    /// A duplicate key is the CLIENT's fault: it must surface as 409 JC0409,
392    /// not a 500 — a re-POSTed id must never trip server-fault alerting.
393    #[tokio::test]
394    async fn unique_violations_map_to_409_conflict() {
395        let db = Db::connect("sqlite::memory:").await.unwrap();
396        db.conn()
397            .execute_unprepared("CREATE TABLE u (id INTEGER PRIMARY KEY, t TEXT)")
398            .await
399            .unwrap();
400        db.conn()
401            .execute_unprepared("INSERT INTO u VALUES (1, 'a')")
402            .await
403            .unwrap();
404        let dup = db
405            .conn()
406            .execute_unprepared("INSERT INTO u VALUES (1, 'b')")
407            .await
408            .expect_err("duplicate pk must fail");
409        let e = db_error(dup);
410        assert_eq!(e.code(), "JC0409");
411        assert_eq!(e.status().as_u16(), 409);
412        // Still no internals in the message.
413        assert!(!e.message().contains("sqlite"), "{}", e.message());
414    }
415
416    /// SQLite FK enforcement is a FRAMEWORK guarantee, pinned in `Db::connect`
417    /// via `map_sqlx_sqlite_opts(|o| o.foreign_keys(true))` — not an inherited
418    /// sqlx default. This test is the proof: if the pin is ever lost (or an
419    /// upstream default flip would otherwise silently disable enforcement),
420    /// it turns red. Asserts all three faces of the guarantee through the
421    /// pooled connection: the pragma reads ON, an orphan insert is rejected
422    /// as an FK violation, and `ON DELETE CASCADE` fires.
423    #[tokio::test]
424    async fn sqlite_foreign_keys_are_enforced_through_the_pool() {
425        let db = Db::connect("sqlite::memory:").await.unwrap();
426
427        // (a) The pragma is ON on the pooled connection itself.
428        let row = db
429            .conn()
430            .query_one(Statement::from_string(
431                sea_orm::DatabaseBackend::Sqlite,
432                "PRAGMA foreign_keys",
433            ))
434            .await
435            .unwrap()
436            .expect("PRAGMA foreign_keys returns a row");
437        let on: i64 = row
438            .try_get::<i64>("", "foreign_keys")
439            .or_else(|_| row.try_get::<i32>("", "foreign_keys").map(i64::from))
440            .unwrap();
441        assert_eq!(on, 1, "foreign_keys must be ON through the pool");
442
443        db.conn()
444            .execute_unprepared("CREATE TABLE parents (id INTEGER PRIMARY KEY)")
445            .await
446            .unwrap();
447        db.conn()
448            .execute_unprepared(
449                "CREATE TABLE children (id INTEGER PRIMARY KEY, \
450                 parent_id INTEGER NOT NULL REFERENCES parents(id) ON DELETE CASCADE)",
451            )
452            .await
453            .unwrap();
454        db.conn()
455            .execute_unprepared("INSERT INTO parents (id) VALUES (1)")
456            .await
457            .unwrap();
458
459        // (b) A child pointing at a nonexistent parent is REJECTED — and
460        // specifically as an FK violation, not some other failure.
461        let orphan = db
462            .conn()
463            .execute_unprepared("INSERT INTO children (id, parent_id) VALUES (10, 999)")
464            .await
465            .expect_err("orphan insert must violate the FK");
466        assert!(
467            matches!(
468                orphan.sql_err(),
469                Some(sea_orm::SqlErr::ForeignKeyConstraintViolation(_))
470            ),
471            "must be an FK violation, got: {orphan}"
472        );
473
474        // (c) Deleting the parent CASCADE-removes its children.
475        db.conn()
476            .execute_unprepared("INSERT INTO children (id, parent_id) VALUES (11, 1)")
477            .await
478            .unwrap();
479        db.conn()
480            .execute_unprepared("DELETE FROM parents WHERE id = 1")
481            .await
482            .unwrap();
483        let row = db
484            .conn()
485            .query_one(Statement::from_string(
486                sea_orm::DatabaseBackend::Sqlite,
487                "SELECT COUNT(*) AS n FROM children",
488            ))
489            .await
490            .unwrap()
491            .unwrap();
492        let n: i64 = row
493            .try_get::<i64>("", "n")
494            .or_else(|_| row.try_get::<i32>("", "n").map(i64::from))
495            .unwrap();
496        assert_eq!(n, 0, "ON DELETE CASCADE must remove the child rows");
497    }
498
499    fn demo_migrations() -> Vec<Migration> {
500        vec![
501            Migration {
502                name: "0001_create_todos",
503                sqlite: "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)",
504                postgres: "CREATE TABLE todos (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL)",
505            },
506            Migration {
507                name: "0002_add_done",
508                sqlite: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT 0",
509                postgres: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT FALSE",
510            },
511        ]
512    }
513
514    #[tokio::test]
515    async fn migrations_apply_in_order_and_only_once() {
516        let db = Db::connect("sqlite::memory:").await.unwrap();
517        let applied = db.migrate(&demo_migrations()).await.unwrap();
518        assert_eq!(applied, vec!["0001_create_todos", "0002_add_done"]);
519
520        // Re-running applies nothing (tracking table remembers).
521        let applied = db.migrate(&demo_migrations()).await.unwrap();
522        assert!(applied.is_empty());
523
524        // The schema is genuinely there.
525        db.conn()
526            .execute_unprepared("INSERT INTO todos (title, done) VALUES ('x', 1)")
527            .await
528            .unwrap();
529    }
530
531    #[tokio::test]
532    async fn owned_migrations_apply_in_order_and_only_once() {
533        let db = Db::connect("sqlite::memory:").await.unwrap();
534        let owned = vec![
535            OwnedMigration {
536                name: "0001_create_todos".into(),
537                sqlite:
538                    "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)"
539                        .into(),
540                postgres: "CREATE TABLE todos (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL)"
541                    .into(),
542            },
543            OwnedMigration {
544                name: "0002_add_done".into(),
545                sqlite: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT 0".into(),
546                postgres: "ALTER TABLE todos ADD COLUMN done BOOLEAN NOT NULL DEFAULT FALSE".into(),
547            },
548        ];
549        let applied = db.migrate_owned(&owned).await.unwrap();
550        assert_eq!(applied, vec!["0001_create_todos", "0002_add_done"]);
551        // Re-running applies nothing (shares the tracking table with `migrate`).
552        let applied = db.migrate_owned(&owned).await.unwrap();
553        assert!(applied.is_empty());
554    }
555
556    /// The transaction idiom is the framework's atomicity guarantee: a closure
557    /// returning `Err` must roll back EVERY statement it issued, leaving no
558    /// partial writes. If this fails, the sea-orm feature set is wrong — fix the
559    /// Cargo features, never weaken the test.
560    #[tokio::test]
561    async fn transactions_roll_back_on_error() {
562        use sea_orm::TransactionTrait;
563        let db = Db::connect("sqlite::memory:").await.unwrap();
564        db.conn()
565            .execute_unprepared("CREATE TABLE t (id INTEGER PRIMARY KEY)")
566            .await
567            .unwrap();
568        let r = db
569            .conn()
570            .transaction::<_, (), sea_orm::DbErr>(|txn| {
571                Box::pin(async move {
572                    txn.execute_unprepared("INSERT INTO t VALUES (1)").await?;
573                    Err(sea_orm::DbErr::Custom("boom".into()))
574                })
575            })
576            .await;
577        assert!(r.is_err());
578        let rows = db
579            .conn()
580            .query_all(sea_orm::Statement::from_string(
581                sea_orm::DatabaseBackend::Sqlite,
582                "SELECT id FROM t",
583            ))
584            .await
585            .unwrap();
586        assert!(rows.is_empty(), "rollback must leave no rows");
587    }
588
589    #[tokio::test]
590    async fn a_failing_migration_surfaces_jc0510_and_is_not_recorded() {
591        let db = Db::connect("sqlite::memory:").await.unwrap();
592        let bad = vec![Migration {
593            name: "0001_broken",
594            sqlite: "CREATE GARBAGE",
595            postgres: "CREATE GARBAGE",
596        }];
597        let err = db.migrate(&bad).await.unwrap_err();
598        assert_eq!(err.code(), "JC0510");
599
600        // Fixing it lets the same name apply afresh — failures are not recorded.
601        let good = vec![Migration {
602            name: "0001_broken",
603            sqlite: "CREATE TABLE ok (x BIGINT)",
604            postgres: "CREATE TABLE ok (x BIGINT)",
605        }];
606        let applied = db.migrate(&good).await.unwrap();
607        assert_eq!(applied, vec!["0001_broken"]);
608    }
609
610    /// Several app instances booting at once all call `migrate()` against the
611    /// same Postgres. Without the advisory-lock serialization they race the
612    /// not-yet-applied check and double-apply the (non-`IF NOT EXISTS`) DDL —
613    /// one node crashes with a unique violation (JC0409/JC0510). With it, every
614    /// migrator succeeds and the migration is applied EXACTLY once. Needs a live
615    /// Postgres; run with `JERRYCAN_TEST_PG_URL=… cargo test -p jerrycan-db -- --ignored`.
616    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
617    #[ignore = "needs a local postgres (set JERRYCAN_TEST_PG_URL)"]
618    async fn concurrent_migrators_do_not_race() {
619        let Ok(url) = std::env::var("JERRYCAN_TEST_PG_URL") else {
620            eprintln!("SKIP: JERRYCAN_TEST_PG_URL not set");
621            return;
622        };
623        // A run-unique table so repeated runs against a persistent DB don't
624        // collide (the tracking row is keyed by the unique migration name).
625        let nanos = std::time::SystemTime::now()
626            .duration_since(std::time::UNIX_EPOCH)
627            .unwrap()
628            .as_nanos();
629        let table = format!("mig_race_{nanos}");
630        let name = format!("{table}_0001");
631        let migrations = vec![Migration {
632            name: Box::leak(name.clone().into_boxed_str()),
633            sqlite: "",
634            postgres: Box::leak(
635                format!("CREATE TABLE {table} (id BIGSERIAL PRIMARY KEY, v TEXT NOT NULL)")
636                    .into_boxed_str(),
637            ),
638        }];
639        let migrations = std::sync::Arc::new(migrations);
640
641        // 8 separate connection pools = 8 genuine concurrent "nodes".
642        let mut handles = Vec::new();
643        for _ in 0..8 {
644            let url = url.clone();
645            let migrations = migrations.clone();
646            handles.push(tokio::spawn(async move {
647                let db = Db::connect(&url).await.expect("connect");
648                db.migrate(&migrations).await
649            }));
650        }
651
652        let mut total_applied = 0usize;
653        for h in handles {
654            let applied = h.await.expect("task").expect("migrate must not error");
655            total_applied += applied.len();
656        }
657        assert_eq!(
658            total_applied, 1,
659            "exactly one migrator applies the migration; the rest see it recorded"
660        );
661
662        // The table exists and is usable.
663        let db = Db::connect(&url).await.unwrap();
664        db.conn()
665            .execute_unprepared(&format!("INSERT INTO {table} (v) VALUES ('ok')"))
666            .await
667            .unwrap();
668        db.conn()
669            .execute_unprepared(&format!("DROP TABLE {table}"))
670            .await
671            .unwrap();
672    }
673
674    // ---- #108: atomic reserve-if-capacity, proven under concurrency ----
675    // The SAFE pattern documented in `docs/ai/08-database.md`: reserve one unit
676    // in a SINGLE conditional UPDATE that both checks and reserves. The `WHERE`
677    // carries the capacity guard, so the row locks for the write and no two
678    // callers can both pass the check. Returns true iff THIS caller reserved
679    // (exactly 1 row affected); false means the row was already at capacity.
680
681    /// The safe atomic reservation: one conditional UPDATE, affected-row count
682    /// is the verdict. Atomic on BOTH backends.
683    async fn atomic_reserve_one(db: &Db, id: i64) -> Result<bool> {
684        let stmt = Statement::from_sql_and_values(
685            db.backend_db(),
686            db.sql("UPDATE seats SET used = used + 1 WHERE id = ? AND used + 1 <= capacity"),
687            [id.into()],
688        );
689        let res = db.conn().execute(stmt).await.map_err(db_error)?;
690        Ok(res.rows_affected() == 1)
691    }
692
693    async fn seats_used(db: &Db, id: i64) -> i64 {
694        let row = db
695            .conn()
696            .query_one(Statement::from_sql_and_values(
697                db.backend_db(),
698                db.sql("SELECT used FROM seats WHERE id = ?"),
699                [id.into()],
700            ))
701            .await
702            .unwrap()
703            .expect("seats row");
704        row.try_get::<i64>("", "used")
705            .or_else(|_| row.try_get::<i32>("", "used").map(i64::from))
706            .unwrap()
707    }
708
709    /// The LANDMINE the docs warn about: read the current count, then insert if
710    /// "under capacity". Two concurrent callers both read the same count, both
711    /// pass, both insert → oversell. The small gap between the read and the
712    /// write (every real handler has one) makes the race fire deterministically.
713    async fn naive_reserve_one(db: &Db, resource_id: i64, capacity: i64) -> Result<bool> {
714        let row = db
715            .conn()
716            .query_one(Statement::from_sql_and_values(
717                db.backend_db(),
718                db.sql("SELECT COUNT(*) AS n FROM bookings WHERE resource_id = ?"),
719                [resource_id.into()],
720            ))
721            .await
722            .map_err(db_error)?
723            .expect("count row");
724        let count = row
725            .try_get::<i64>("", "n")
726            .or_else(|_| row.try_get::<i32>("", "n").map(i64::from))
727            .unwrap();
728        if count >= capacity {
729            return Ok(false);
730        }
731        // The window a real handler has between deciding and writing.
732        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
733        db.conn()
734            .execute(Statement::from_sql_and_values(
735                db.backend_db(),
736                db.sql("INSERT INTO bookings (resource_id) VALUES (?)"),
737                [resource_id.into()],
738            ))
739            .await
740            .map_err(db_error)?;
741        Ok(true)
742    }
743
744    async fn booking_count(db: &Db, resource_id: i64) -> i64 {
745        let row = db
746            .conn()
747            .query_one(Statement::from_sql_and_values(
748                db.backend_db(),
749                db.sql("SELECT COUNT(*) AS n FROM bookings WHERE resource_id = ?"),
750                [resource_id.into()],
751            ))
752            .await
753            .unwrap()
754            .expect("count row");
755        row.try_get::<i64>("", "n")
756            .or_else(|_| row.try_get::<i32>("", "n").map(i64::from))
757            .unwrap()
758    }
759
760    /// SQLite: the documented atomic conditional-UPDATE reservation grants
761    /// EXACTLY capacity under concurrency — never oversells. SQLite's single
762    /// writer (pool max = 1) serializes writes, so this is also the backend where
763    /// a naive read-then-insert would *accidentally* be safe; the pattern is what
764    /// makes it correct on Postgres too (proven in the PG leg below).
765    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
766    async fn atomic_reservation_reserves_exactly_capacity_on_sqlite() {
767        let db = Db::connect("sqlite::memory:").await.unwrap();
768        db.conn()
769            .execute_unprepared(
770                "CREATE TABLE seats (id INTEGER PRIMARY KEY, \
771                 used INTEGER NOT NULL DEFAULT 0, capacity INTEGER NOT NULL)",
772            )
773            .await
774            .unwrap();
775        db.conn()
776            .execute_unprepared("INSERT INTO seats (id, used, capacity) VALUES (1, 0, 5)")
777            .await
778            .unwrap();
779
780        let capacity = 5i64;
781        let contenders = 40;
782        let mut handles = Vec::new();
783        for _ in 0..contenders {
784            let db = db.clone();
785            handles.push(tokio::spawn(async move {
786                atomic_reserve_one(&db, 1).await.unwrap()
787            }));
788        }
789        let mut reserved = 0i64;
790        for h in handles {
791            if h.await.unwrap() {
792                reserved += 1;
793            }
794        }
795        assert_eq!(reserved, capacity, "atomic reserve grants exactly capacity");
796        assert_eq!(
797            seats_used(&db, 1).await,
798            capacity,
799            "used must never exceed capacity"
800        );
801    }
802
803    /// Postgres — the executable proof, on a pool with REAL concurrent writers:
804    /// (i) the naive read-then-insert OVERSELLS (count > capacity), the landmine
805    /// the docs warn about; and (ii) the atomic conditional UPDATE reserves
806    /// EXACTLY capacity. Needs a live Postgres; reset the schema first
807    /// (`DROP SCHEMA public CASCADE; CREATE SCHEMA public`) then run with
808    /// `JERRYCAN_TEST_PG_URL=… cargo test -p jerrycan-db -- --ignored`.
809    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
810    #[ignore = "needs a local postgres (set JERRYCAN_TEST_PG_URL)"]
811    async fn atomic_reservation_beats_the_oversell_race_on_postgres() {
812        let Ok(url) = std::env::var("JERRYCAN_TEST_PG_URL") else {
813            eprintln!("SKIP: JERRYCAN_TEST_PG_URL not set");
814            return;
815        };
816        let capacity = 5i64;
817        let contenders = 40;
818
819        let db = Db::connect(&url).await.unwrap();
820        db.conn()
821            .execute_unprepared(
822                "CREATE TABLE bookings (id BIGSERIAL PRIMARY KEY, resource_id BIGINT NOT NULL)",
823            )
824            .await
825            .unwrap();
826        db.conn()
827            .execute_unprepared(
828                "CREATE TABLE seats (id BIGINT PRIMARY KEY, \
829                 used BIGINT NOT NULL DEFAULT 0, capacity BIGINT NOT NULL)",
830            )
831            .await
832            .unwrap();
833        db.conn()
834            .execute_unprepared("INSERT INTO seats (id, used, capacity) VALUES (1, 0, 5)")
835            .await
836            .unwrap();
837
838        // (i) LANDMINE: every contender reads "under capacity" before any insert
839        // lands (real pool, real concurrency), so they ALL insert → oversell.
840        let mut handles = Vec::new();
841        for _ in 0..contenders {
842            let db = db.clone();
843            handles.push(tokio::spawn(async move {
844                naive_reserve_one(&db, 1, capacity).await.unwrap()
845            }));
846        }
847        for h in handles {
848            let _ = h.await.unwrap();
849        }
850        let oversold = booking_count(&db, 1).await;
851        assert!(
852            oversold > capacity,
853            "naive read-then-insert must oversell on Postgres: got {oversold} bookings \
854             for capacity {capacity}"
855        );
856
857        // (ii) SAFE: the atomic conditional UPDATE grants EXACTLY capacity.
858        let mut handles = Vec::new();
859        for _ in 0..contenders {
860            let db = db.clone();
861            handles.push(tokio::spawn(async move {
862                atomic_reserve_one(&db, 1).await.unwrap()
863            }));
864        }
865        let mut reserved = 0i64;
866        for h in handles {
867            if h.await.unwrap() {
868                reserved += 1;
869            }
870        }
871        assert_eq!(
872            reserved, capacity,
873            "atomic reserve grants exactly capacity on Postgres"
874        );
875        assert_eq!(
876            seats_used(&db, 1).await,
877            capacity,
878            "used must never exceed capacity"
879        );
880
881        // Leave the schema clean for the next run.
882        db.conn()
883            .execute_unprepared("DROP TABLE bookings")
884            .await
885            .unwrap();
886        db.conn()
887            .execute_unprepared("DROP TABLE seats")
888            .await
889            .unwrap();
890    }
891}