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}