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}