Skip to main content

cratefield_adapter_postgres/
lib.rs

1//! `cratefield-adapter-postgres`: the [`Database`] port over `sqlx`
2//! Postgres for the native runtime (ADR 0004, issue #18). Native only —
3//! it must never enter a wasm build.
4//!
5//! # Native-only gate
6//!
7//! The sqlx driver does not compile to wasm, so this crate is impossible
8//! to build for a wasm target: `src/lib.rs` fails compilation with a
9//! clear message on `wasm32`/`wasm64`, and the sqlx dependency itself is
10//! target-gated to non-wasm builds in `Cargo.toml`. The wasm graph of
11//! `examples/venture` must stay free of this crate (CI asserts it with
12//! `cargo tree --target wasm32-unknown-unknown -p venture | grep -c
13//! sqlx` → 0).
14//!
15//! # Statement pipeline
16//!
17//! Modules build queries with sea-query; the port's [`Statement`] carries
18//! them rendered with `SqliteQueryBuilder` (the portable `?`-placeholder
19//! wire form D1 and rusqlite execute directly). This adapter rewrites `?`
20//! to Postgres `$n` placeholders — skipping string literals, quoted
21//! identifiers and comments — and binds the sea-query values positionally
22//! (`src/convert.rs`). The migration runner's own
23//! bookkeeping statements are rendered by sea-query's
24//! `PostgresQueryBuilder` directly.
25//!
26//! # The SQLite → Postgres mapping actually used
27//!
28//! The portable subset (ADR 0004, architecture section 7) is deliberately
29//! the intersection of SQLite and Postgres, so migration files run
30//! verbatim on both engines:
31//!
32//! | Portable concept | SQLite | Postgres | Notes |
33//! |---|---|---|---|
34//! | ULID ids | `TEXT PRIMARY KEY` | `TEXT PRIMARY KEY` | no `AUTOINCREMENT`/`SERIAL` anywhere |
35//! | timestamps | ISO-8601 `TEXT` | ISO-8601 `TEXT` | computed in Rust, never `NOW()`/`datetime()` |
36//! | counters | `INTEGER` | `INTEGER` (`int4`) | `referrals`, `position` |
37//! | booleans | `INTEGER 0/1` by convention | `INTEGER 0/1` by convention | no SQL `BOOLEAN` columns; `SeaValue::Bool` binds as `SMALLINT` 0/1, and rows read back through `Row::get::<bool>` accept both |
38//! | upserts | `INSERT … ON CONFLICT …` | identical syntax | rendered by sea-query for both dialects |
39//! | read-back writes | `INSERT … RETURNING …` | identical syntax | rendered by sea-query for both dialects |
40//!
41//! `batch` runs all statements in one transaction (atomic, ADR 0004).
42//!
43//! ```no_run
44//! # async fn demo() -> Result<(), cratefield_core::DbError> {
45//! use cratefield_adapter_postgres::Postgres;
46//!
47//! let db = Postgres::connect("postgres://user:pass@host:5432/venture").await?;
48//! // db.apply_harness_migrations(&harness).await?;   // library entry point
49//! // … or through Arc<dyn Database> as with any adapter
50//! # Ok(())
51//! # }
52//! ```
53
54#![forbid(unsafe_code)]
55
56// The whole crate is native-only (issue #18).
57//
58// **This message has never been printed, and cannot be.** A wasm build of
59// this crate dies in getrandom — "the wasm32/64-unknown-unknown are not
60// supported by default" — before this line is evaluated, because cargo
61// builds dependencies before the crate that declares them. The guard
62// stays because it states the rule where the rule belongs, but the check
63// that runs is `no_wasm_package_can_reach_a_native_only_crate` in
64// `crates/cli-acceptance/tests/native_only_off_wasm.rs`, which reads the
65// dependency graph rather than compiling it — and which finds this crate
66// *by* the guard below, so deleting it turns that check off.
67#[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))]
68compile_error!(
69    "cratefield-adapter-postgres is native-only: the sqlx Postgres driver does not \
70     compile to wasm (issue #18, ADR 0004). A wasm target must not depend on this \
71     crate — check the wasm dependency graph of the venture (modules see ports, \
72     never adapters, and runtime-native is the only consumer)."
73);
74
75mod convert;
76mod lifecycle;
77mod migrate;
78mod reconcile;
79mod registry;
80pub mod testing;
81
82pub use cratefield_core::TenantStatus;
83pub use migrate::select_set;
84pub use reconcile::{ModulePlan, TenantPlan, TenantRecord, TenantReport, tenant_lock_key};
85pub use registry::{PoolRegistry, TENANT_POOL_IDLE, TENANT_POOL_TOTAL, TenantDsns};
86
87use async_trait::async_trait;
88use cratefield_core::{Database, DbError, Rows, Statement};
89use sqlx::postgres::PgArguments;
90
91/// An open Postgres connection pool (`sqlx` 0.8, `runtime-tokio`,
92/// `tls-rustls`).
93///
94/// One pool per tenant database: ADR 0008 keeps tenants isolated at the
95/// database boundary, and the native runtime (#19) holds one adapter per
96/// tenant.
97/// What one tenant's pool may take (TENANT-ROUTING.md §4).
98///
99/// The per-tenant cap is what stops one busy tenant taking every
100/// connection on a shared cluster; the idle timeout is what stops a
101/// replica's cost scaling with the tenant count rather than with its
102/// traffic, since a pool that no request has touched should not hold
103/// connections open.
104///
105/// `acquire_timeout` is the fail-closed half: a checkout that cannot be
106/// served refuses with a deadline rather than queueing unboundedly, which
107/// is the same instinct as the outbound budget in #136.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct PoolLimits {
110    /// Connections one tenant's pool may hold. Default 8.
111    pub max_connections: u32,
112    /// A pool with no checkout for this long closes its connections.
113    /// Default 5 minutes.
114    pub idle_timeout: Option<std::time::Duration>,
115    /// How long a checkout waits before refusing. Default 5 seconds.
116    pub acquire_timeout: std::time::Duration,
117}
118
119impl Default for PoolLimits {
120    fn default() -> Self {
121        Self {
122            max_connections: 8,
123            idle_timeout: Some(std::time::Duration::from_secs(300)),
124            acquire_timeout: std::time::Duration::from_secs(5),
125        }
126    }
127}
128
129#[derive(Clone)]
130pub struct Postgres {
131    pool: sqlx::PgPool,
132}
133
134impl Postgres {
135    /// Opens a pool and establishes at least one connection, failing fast
136    /// on an unreachable server or bad credentials.
137    ///
138    /// Uses [`PoolLimits::default`]. A multi-tenant deployment wants
139    /// [`Postgres::connect_with`] instead: one pool per tenant with no cap
140    /// is how one busy tenant takes every connection on a shared cluster.
141    ///
142    /// # Errors
143    ///
144    /// [`DbError::Execute`] when the pool cannot connect.
145    pub async fn connect(url: &str) -> Result<Self, DbError> {
146        Self::connect_with(url, PoolLimits::default()).await
147    }
148
149    /// Opens a pool with explicit limits (TENANT-ROUTING.md §4).
150    ///
151    /// `sqlx` caps and times out *per pool*, which is exactly the
152    /// per-tenant boundary: a pool belongs to one tenant. The **total**
153    /// across pools is not something `sqlx` can express, so it is not
154    /// pretended to here — it needs a semaphore held by whatever owns the
155    /// pools, acquired around every `execute`, `query` and `batch`. A
156    /// config key that silently did nothing would be worse than its
157    /// absence.
158    ///
159    /// # Errors
160    ///
161    /// [`DbError::Execute`] when the pool cannot connect.
162    pub async fn connect_with(url: &str, limits: PoolLimits) -> Result<Self, DbError> {
163        let pool = sqlx::postgres::PgPoolOptions::new()
164            .max_connections(limits.max_connections)
165            .idle_timeout(limits.idle_timeout)
166            .acquire_timeout(limits.acquire_timeout)
167            .connect(url)
168            .await
169            .map_err(|err| DbError::Execute(err.to_string()))?;
170        Ok(Self { pool })
171    }
172
173    /// Prepares a port statement for execution: rewrites `?` placeholders
174    /// to `$n` and binds the sea-query values.
175    fn prepare(stmt: &Statement) -> Result<(String, PgArguments), DbError> {
176        let sql = convert::rebind_placeholders(&stmt.sql);
177        let mut args = PgArguments::default();
178        convert::bind_values(&mut args, &stmt.values.0)?;
179        Ok((sql, args))
180    }
181
182    /// Closes the pool: waits for in-flight queries, then closes every
183    /// connection. Dropping the adapter eventually closes the pool too;
184    /// `close` makes shutdown deterministic for the parity kit and the
185    /// native runtime.
186    ///
187    /// # Errors
188    ///
189    /// [`DbError::Execute`] when the pool cannot be closed.
190    pub async fn close(&self) -> Result<(), DbError> {
191        self.pool.close().await;
192        Ok(())
193    }
194}
195
196#[async_trait]
197impl Database for Postgres {
198    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
199        let (sql, args) = Self::prepare(stmt)?;
200        let result = sqlx::query_with(sql.as_str(), args)
201            .execute(&self.pool)
202            .await
203            .map_err(|err| DbError::Execute(err.to_string()))?;
204        Ok(result.rows_affected())
205    }
206
207    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
208        let (sql, args) = Self::prepare(stmt)?;
209        let rows = sqlx::query_with(sql.as_str(), args)
210            .fetch_all(&self.pool)
211            .await
212            .map_err(|err| DbError::Query(err.to_string()))?;
213        Ok(Rows::new(rows.iter().map(convert::pg_row_to_row).collect()))
214    }
215
216    /// Runs all statements in one transaction (atomic on Postgres).
217    async fn batch_atomic(&self, stmts: &[Statement]) -> Result<(), DbError> {
218        let mut tx = self
219            .pool
220            .begin()
221            .await
222            .map_err(|err| DbError::Batch(err.to_string()))?;
223        for stmt in stmts {
224            let (sql, args) = Self::prepare(stmt)?;
225            sqlx::query_with(sql.as_str(), args)
226                .execute(&mut *tx)
227                .await
228                .map_err(|err| DbError::Batch(err.to_string()))?;
229        }
230        tx.commit()
231            .await
232            .map_err(|err| DbError::Batch(err.to_string()))?;
233        Ok(())
234    }
235}