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): fail a wasm build of this
57// crate fast, with a clear message, before sqlx is even attempted.
58#[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))]
59compile_error!(
60    "cratefield-adapter-postgres is native-only: the sqlx Postgres driver does not \
61     compile to wasm (issue #18, ADR 0004). A wasm target must not depend on this \
62     crate — check the wasm dependency graph of the venture (modules see ports, \
63     never adapters, and runtime-native is the only consumer)."
64);
65
66mod convert;
67mod migrate;
68mod reconcile;
69mod registry;
70pub mod testing;
71
72pub use cratefield_core::TenantStatus;
73pub use migrate::select_set;
74pub use reconcile::{ModulePlan, TenantPlan, TenantRecord, TenantReport, tenant_lock_key};
75pub use registry::{PoolRegistry, TENANT_POOL_IDLE, TENANT_POOL_TOTAL, TenantDsns};
76
77use async_trait::async_trait;
78use cratefield_core::{Database, DbError, Rows, Statement};
79use sqlx::postgres::PgArguments;
80
81/// An open Postgres connection pool (`sqlx` 0.8, `runtime-tokio`,
82/// `tls-rustls`).
83///
84/// One pool per tenant database: ADR 0008 keeps tenants isolated at the
85/// database boundary, and the native runtime (#19) holds one adapter per
86/// tenant.
87/// What one tenant's pool may take (TENANT-ROUTING.md §4).
88///
89/// The per-tenant cap is what stops one busy tenant taking every
90/// connection on a shared cluster; the idle timeout is what stops a
91/// replica's cost scaling with the tenant count rather than with its
92/// traffic, since a pool that no request has touched should not hold
93/// connections open.
94///
95/// `acquire_timeout` is the fail-closed half: a checkout that cannot be
96/// served refuses with a deadline rather than queueing unboundedly, which
97/// is the same instinct as the outbound budget in #136.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub struct PoolLimits {
100    /// Connections one tenant's pool may hold. Default 8.
101    pub max_connections: u32,
102    /// A pool with no checkout for this long closes its connections.
103    /// Default 5 minutes.
104    pub idle_timeout: Option<std::time::Duration>,
105    /// How long a checkout waits before refusing. Default 5 seconds.
106    pub acquire_timeout: std::time::Duration,
107}
108
109impl Default for PoolLimits {
110    fn default() -> Self {
111        Self {
112            max_connections: 8,
113            idle_timeout: Some(std::time::Duration::from_secs(300)),
114            acquire_timeout: std::time::Duration::from_secs(5),
115        }
116    }
117}
118
119#[derive(Clone)]
120pub struct Postgres {
121    pool: sqlx::PgPool,
122}
123
124impl Postgres {
125    /// Opens a pool and establishes at least one connection, failing fast
126    /// on an unreachable server or bad credentials.
127    ///
128    /// Uses [`PoolLimits::default`]. A multi-tenant deployment wants
129    /// [`Postgres::connect_with`] instead: one pool per tenant with no cap
130    /// is how one busy tenant takes every connection on a shared cluster.
131    ///
132    /// # Errors
133    ///
134    /// [`DbError::Execute`] when the pool cannot connect.
135    pub async fn connect(url: &str) -> Result<Self, DbError> {
136        Self::connect_with(url, PoolLimits::default()).await
137    }
138
139    /// Opens a pool with explicit limits (TENANT-ROUTING.md §4).
140    ///
141    /// `sqlx` caps and times out *per pool*, which is exactly the
142    /// per-tenant boundary: a pool belongs to one tenant. The **total**
143    /// across pools is not something `sqlx` can express, so it is not
144    /// pretended to here — it needs a semaphore held by whatever owns the
145    /// pools, acquired around every `execute`, `query` and `batch`. A
146    /// config key that silently did nothing would be worse than its
147    /// absence.
148    ///
149    /// # Errors
150    ///
151    /// [`DbError::Execute`] when the pool cannot connect.
152    pub async fn connect_with(url: &str, limits: PoolLimits) -> Result<Self, DbError> {
153        let pool = sqlx::postgres::PgPoolOptions::new()
154            .max_connections(limits.max_connections)
155            .idle_timeout(limits.idle_timeout)
156            .acquire_timeout(limits.acquire_timeout)
157            .connect(url)
158            .await
159            .map_err(|err| DbError::Execute(err.to_string()))?;
160        Ok(Self { pool })
161    }
162
163    /// Prepares a port statement for execution: rewrites `?` placeholders
164    /// to `$n` and binds the sea-query values.
165    fn prepare(stmt: &Statement) -> Result<(String, PgArguments), DbError> {
166        let sql = convert::rebind_placeholders(&stmt.sql);
167        let mut args = PgArguments::default();
168        convert::bind_values(&mut args, &stmt.values.0)?;
169        Ok((sql, args))
170    }
171
172    /// Closes the pool: waits for in-flight queries, then closes every
173    /// connection. Dropping the adapter eventually closes the pool too;
174    /// `close` makes shutdown deterministic for the parity kit and the
175    /// native runtime.
176    ///
177    /// # Errors
178    ///
179    /// [`DbError::Execute`] when the pool cannot be closed.
180    pub async fn close(&self) -> Result<(), DbError> {
181        self.pool.close().await;
182        Ok(())
183    }
184}
185
186#[async_trait]
187impl Database for Postgres {
188    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
189        let (sql, args) = Self::prepare(stmt)?;
190        let result = sqlx::query_with(sql.as_str(), args)
191            .execute(&self.pool)
192            .await
193            .map_err(|err| DbError::Execute(err.to_string()))?;
194        Ok(result.rows_affected())
195    }
196
197    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
198        let (sql, args) = Self::prepare(stmt)?;
199        let rows = sqlx::query_with(sql.as_str(), args)
200            .fetch_all(&self.pool)
201            .await
202            .map_err(|err| DbError::Query(err.to_string()))?;
203        Ok(Rows::new(rows.iter().map(convert::pg_row_to_row).collect()))
204    }
205
206    /// Runs all statements in one transaction (atomic on Postgres).
207    async fn batch_atomic(&self, stmts: &[Statement]) -> Result<(), DbError> {
208        let mut tx = self
209            .pool
210            .begin()
211            .await
212            .map_err(|err| DbError::Batch(err.to_string()))?;
213        for stmt in stmts {
214            let (sql, args) = Self::prepare(stmt)?;
215            sqlx::query_with(sql.as_str(), args)
216                .execute(&mut *tx)
217                .await
218                .map_err(|err| DbError::Batch(err.to_string()))?;
219        }
220        tx.commit()
221            .await
222            .map_err(|err| DbError::Batch(err.to_string()))?;
223        Ok(())
224    }
225}