fraiseql_server/tenancy/pool_factory.rs
1//! Tenant pool creation and executor construction.
2//!
3//! Provides [`TenantPoolConfig`] and [`create_tenant_executor`] to build a
4//! fully-formed `Executor<A>` from a compiled schema JSON string and database
5//! connection configuration. Used by the management API to register
6//! tenants at runtime.
7
8use std::sync::Arc;
9
10use fraiseql_core::{
11 cache::{CacheConfig, CachedDatabaseAdapter, QueryResultCache},
12 db::{
13 postgres::{PoolPrewarmConfig, PostgresAdapter},
14 traits::DatabaseAdapter,
15 },
16 runtime::Executor,
17 schema::{CompiledSchema, TenancyMode},
18};
19use fraiseql_error::{FraiseQLError, Result};
20use serde::Deserialize;
21use tracing::info;
22
23use super::schema_isolation;
24
25/// Connection configuration for a tenant database pool.
26#[derive(Debug, Clone, Deserialize)]
27pub struct TenantPoolConfig {
28 /// Database connection string (e.g. `postgres://user:pass@host:5432/db`).
29 pub connection_string: String,
30 /// Maximum number of connections in the pool.
31 #[serde(default = "default_max_connections")]
32 pub max_connections: u32,
33 /// Connection timeout in seconds.
34 #[serde(default = "default_connect_timeout")]
35 pub connect_timeout_secs: u64,
36 /// Idle connection timeout in seconds.
37 #[serde(default = "default_idle_timeout")]
38 pub idle_timeout_secs: u64,
39}
40
41const fn default_max_connections() -> u32 {
42 10
43}
44const fn default_connect_timeout() -> u64 {
45 5
46}
47const fn default_idle_timeout() -> u64 {
48 300
49}
50
51/// Trait for database adapters that can be created from a connection string.
52///
53/// Implemented by adapters that support dynamic pool creation at runtime
54/// (as opposed to static initialization at server startup).
55#[async_trait::async_trait]
56pub trait FromPoolConfig: DatabaseAdapter + Sized {
57 /// Create a new adapter from connection configuration.
58 ///
59 /// # Errors
60 ///
61 /// Returns `FraiseQLError::ConnectionPool` or `FraiseQLError::Database`
62 /// if the connection cannot be established.
63 async fn from_pool_config(config: &TenantPoolConfig) -> Result<Self>;
64}
65
66#[async_trait::async_trait]
67impl FromPoolConfig for PostgresAdapter {
68 async fn from_pool_config(config: &TenantPoolConfig) -> Result<Self> {
69 Self::with_pool_config(
70 &config.connection_string,
71 PoolPrewarmConfig {
72 // Per-tenant pools are created on demand at registration; don't eagerly
73 // pre-warm (min_size = 0). `with_pool_config` still opens one connection
74 // for the startup health check, validating the connection string.
75 min_size: 0,
76 // Reason: `max_connections` is a small operator-set pool bound; usize is
77 // at least 32-bit on every supported target, so the cast cannot truncate.
78 #[allow(clippy::cast_possible_truncation)]
79 max_size: config.max_connections as usize,
80 timeout_secs: Some(config.connect_timeout_secs),
81 },
82 )
83 .await
84 }
85}
86
87/// The binary's `Server` wraps its adapter in a [`CachedDatabaseAdapter`], so the
88/// per-tenant executor registry stores `Executor<CachedDatabaseAdapter<A>>` and the
89/// factory must build that wrapped type. Each tenant gets its own fresh, isolated
90/// [`QueryResultCache`]; on a schema update the whole executor is replaced
91/// (`TenantExecutorRegistry::upsert`), so the cache is rebuilt rather than
92/// version-invalidated — an empty `schema_version` namespace is sufficient and
93/// collision-free. Per-tenant caches use `CacheConfig::default()` (they do not
94/// inherit the server's tuned view-TTL / cacheable-view configuration).
95#[async_trait::async_trait]
96impl<A: FromPoolConfig> FromPoolConfig for CachedDatabaseAdapter<A> {
97 async fn from_pool_config(config: &TenantPoolConfig) -> Result<Self> {
98 let inner = A::from_pool_config(config).await?;
99 let cache = QueryResultCache::new(CacheConfig::default());
100 Ok(Self::new(inner, cache, String::new()))
101 }
102}
103
104/// Creates a complete tenant executor from a compiled schema JSON string and
105/// connection configuration.
106///
107/// This is the primary entry point for tenant registration: it parses the schema,
108/// validates its format version, creates a database pool, and assembles an
109/// `Executor<A>` with both baked in.
110///
111/// When the compiled schema specifies `tenancy.mode = "schema"`, this function
112/// also provisions the tenant's PostgreSQL schema (`CREATE SCHEMA IF NOT EXISTS
113/// tenant_{key}`) and configures the adapter's search path.
114///
115/// # Arguments
116///
117/// * `tenant_key` - The tenant identifier used for schema naming
118/// * `schema_json` - Compiled schema JSON string
119/// * `pool_config` - Database connection configuration
120///
121/// # Errors
122///
123/// Returns `FraiseQLError::Parse` if the schema JSON is invalid.
124/// Returns `FraiseQLError::Validation` if the schema format version is unsupported
125/// or the tenant key would produce an invalid PostgreSQL schema name.
126/// Returns `FraiseQLError::ConnectionPool` / `FraiseQLError::Database` if the pool
127/// cannot be created or schema DDL fails.
128#[doc(hidden)] // Internal-pub: tenant pool builder used by TenantExecutorRegistry; downstream wires tenants via TenancyConfig, not this fn directly.
129pub async fn create_tenant_executor<A: FromPoolConfig>(
130 tenant_key: &str,
131 schema_json: &str,
132 pool_config: &TenantPoolConfig,
133) -> Result<Arc<Executor<A>>> {
134 // 1. Parse and validate schema
135 let schema =
136 CompiledSchema::from_json(schema_json, false).map_err(|e| FraiseQLError::Parse {
137 message: format!("Invalid compiled schema JSON: {e}"),
138 location: String::new(),
139 })?;
140
141 schema
142 .validate_format_version()
143 .map_err(|msg| FraiseQLError::validation(format!("Incompatible compiled schema: {msg}")))?;
144
145 let tenancy_mode = schema.tenancy_mode();
146
147 // 2. Create database adapter/pool
148 let adapter = A::from_pool_config(pool_config).await?;
149
150 // 3. Schema isolation: provision schema + configure search_path
151 if tenancy_mode == TenancyMode::Schema {
152 info!(tenant_key, "provisioning schema for tenant (schema isolation mode)");
153 schema_isolation::provision_tenant_schema(tenant_key, &adapter).await?;
154 schema_isolation::configure_search_path(tenant_key, &adapter).await?;
155 }
156
157 // 4. Assemble executor
158 Ok(Arc::new(Executor::new(schema, Arc::new(adapter))))
159}
160
161/// Drop a tenant's PostgreSQL schema if schema isolation mode is active.
162///
163/// Executes `DROP SCHEMA IF EXISTS tenant_{key} CASCADE` against the provided
164/// adapter. This is a no-op if the tenant key does not correspond to an existing
165/// schema. Called from the delete tenant handler when `tenancy.mode = "schema"`.
166///
167/// # Errors
168///
169/// Returns `FraiseQLError::Validation` if the tenant key is invalid.
170/// Returns `FraiseQLError::Database` if the DDL execution fails.
171pub async fn destroy_tenant_schema(tenant_key: &str, adapter: &dyn DatabaseAdapter) -> Result<()> {
172 schema_isolation::drop_tenant_schema(tenant_key, adapter).await
173}