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;
68pub mod testing;
69
70pub use migrate::select_set;
71
72use async_trait::async_trait;
73use cratefield_core::{Database, DbError, Rows, Statement};
74use sqlx::postgres::PgArguments;
75
76/// An open Postgres connection pool (`sqlx` 0.8, `runtime-tokio`,
77/// `tls-rustls`).
78///
79/// One pool per tenant database: ADR 0008 keeps tenants isolated at the
80/// database boundary, and the native runtime (#19) holds one adapter per
81/// tenant.
82pub struct Postgres {
83 pool: sqlx::PgPool,
84}
85
86impl Postgres {
87 /// Opens a pool and establishes at least one connection, failing fast
88 /// on an unreachable server or bad credentials.
89 ///
90 /// # Errors
91 ///
92 /// [`DbError::Execute`] when the pool cannot connect.
93 pub async fn connect(url: &str) -> Result<Self, DbError> {
94 let pool = sqlx::PgPool::connect(url)
95 .await
96 .map_err(|err| DbError::Execute(err.to_string()))?;
97 Ok(Self { pool })
98 }
99
100 /// Prepares a port statement for execution: rewrites `?` placeholders
101 /// to `$n` and binds the sea-query values.
102 fn prepare(stmt: &Statement) -> Result<(String, PgArguments), DbError> {
103 let sql = convert::rebind_placeholders(&stmt.sql);
104 let mut args = PgArguments::default();
105 convert::bind_values(&mut args, &stmt.values.0)?;
106 Ok((sql, args))
107 }
108
109 /// Closes the pool: waits for in-flight queries, then closes every
110 /// connection. Dropping the adapter eventually closes the pool too;
111 /// `close` makes shutdown deterministic for the parity kit and the
112 /// native runtime.
113 ///
114 /// # Errors
115 ///
116 /// [`DbError::Execute`] when the pool cannot be closed.
117 pub async fn close(&self) -> Result<(), DbError> {
118 self.pool.close().await;
119 Ok(())
120 }
121}
122
123#[async_trait]
124impl Database for Postgres {
125 async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
126 let (sql, args) = Self::prepare(stmt)?;
127 let result = sqlx::query_with(sql.as_str(), args)
128 .execute(&self.pool)
129 .await
130 .map_err(|err| DbError::Execute(err.to_string()))?;
131 Ok(result.rows_affected())
132 }
133
134 async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
135 let (sql, args) = Self::prepare(stmt)?;
136 let rows = sqlx::query_with(sql.as_str(), args)
137 .fetch_all(&self.pool)
138 .await
139 .map_err(|err| DbError::Query(err.to_string()))?;
140 Ok(Rows::new(rows.iter().map(convert::pg_row_to_row).collect()))
141 }
142
143 /// Runs all statements in one transaction (atomic on Postgres).
144 async fn batch(&self, stmts: &[Statement]) -> Result<(), DbError> {
145 let mut tx = self
146 .pool
147 .begin()
148 .await
149 .map_err(|err| DbError::Batch(err.to_string()))?;
150 for stmt in stmts {
151 let (sql, args) = Self::prepare(stmt)?;
152 sqlx::query_with(sql.as_str(), args)
153 .execute(&mut *tx)
154 .await
155 .map_err(|err| DbError::Batch(err.to_string()))?;
156 }
157 tx.commit()
158 .await
159 .map_err(|err| DbError::Batch(err.to_string()))?;
160 Ok(())
161 }
162}