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 db::traits::DatabaseAdapter,
12 runtime::Executor,
13 schema::{CompiledSchema, TenancyMode},
14};
15use fraiseql_error::{FraiseQLError, Result};
16use serde::Deserialize;
17use tracing::info;
18
19use super::schema_isolation;
20
21/// Connection configuration for a tenant database pool.
22#[derive(Debug, Clone, Deserialize)]
23pub struct TenantPoolConfig {
24 /// Database connection string (e.g. `postgres://user:pass@host:5432/db`).
25 pub connection_string: String,
26 /// Maximum number of connections in the pool.
27 #[serde(default = "default_max_connections")]
28 pub max_connections: u32,
29 /// Connection timeout in seconds.
30 #[serde(default = "default_connect_timeout")]
31 pub connect_timeout_secs: u64,
32 /// Idle connection timeout in seconds.
33 #[serde(default = "default_idle_timeout")]
34 pub idle_timeout_secs: u64,
35}
36
37const fn default_max_connections() -> u32 {
38 10
39}
40const fn default_connect_timeout() -> u64 {
41 5
42}
43const fn default_idle_timeout() -> u64 {
44 300
45}
46
47/// Trait for database adapters that can be created from a connection string.
48///
49/// Implemented by adapters that support dynamic pool creation at runtime
50/// (as opposed to static initialization at server startup).
51#[async_trait::async_trait]
52pub trait FromPoolConfig: DatabaseAdapter + Sized {
53 /// Create a new adapter from connection configuration.
54 ///
55 /// # Errors
56 ///
57 /// Returns `FraiseQLError::ConnectionPool` or `FraiseQLError::Database`
58 /// if the connection cannot be established.
59 async fn from_pool_config(config: &TenantPoolConfig) -> Result<Self>;
60}
61
62/// Creates a complete tenant executor from a compiled schema JSON string and
63/// connection configuration.
64///
65/// This is the primary entry point for tenant registration: it parses the schema,
66/// validates its format version, creates a database pool, and assembles an
67/// `Executor<A>` with both baked in.
68///
69/// When the compiled schema specifies `tenancy.mode = "schema"`, this function
70/// also provisions the tenant's PostgreSQL schema (`CREATE SCHEMA IF NOT EXISTS
71/// tenant_{key}`) and configures the adapter's search path.
72///
73/// # Arguments
74///
75/// * `tenant_key` - The tenant identifier used for schema naming
76/// * `schema_json` - Compiled schema JSON string
77/// * `pool_config` - Database connection configuration
78///
79/// # Errors
80///
81/// Returns `FraiseQLError::Parse` if the schema JSON is invalid.
82/// Returns `FraiseQLError::Validation` if the schema format version is unsupported
83/// or the tenant key would produce an invalid PostgreSQL schema name.
84/// Returns `FraiseQLError::ConnectionPool` / `FraiseQLError::Database` if the pool
85/// cannot be created or schema DDL fails.
86#[doc(hidden)] // Internal-pub: tenant pool builder used by TenantExecutorRegistry; downstream wires tenants via TenancyConfig, not this fn directly.
87pub async fn create_tenant_executor<A: FromPoolConfig>(
88 tenant_key: &str,
89 schema_json: &str,
90 pool_config: &TenantPoolConfig,
91) -> Result<Arc<Executor<A>>> {
92 // 1. Parse and validate schema
93 let schema =
94 CompiledSchema::from_json(schema_json, false).map_err(|e| FraiseQLError::Parse {
95 message: format!("Invalid compiled schema JSON: {e}"),
96 location: String::new(),
97 })?;
98
99 schema
100 .validate_format_version()
101 .map_err(|msg| FraiseQLError::validation(format!("Incompatible compiled schema: {msg}")))?;
102
103 let tenancy_mode = schema.tenancy_mode();
104
105 // 2. Create database adapter/pool
106 let adapter = A::from_pool_config(pool_config).await?;
107
108 // 3. Schema isolation: provision schema + configure search_path
109 if tenancy_mode == TenancyMode::Schema {
110 info!(tenant_key, "provisioning schema for tenant (schema isolation mode)");
111 schema_isolation::provision_tenant_schema(tenant_key, &adapter).await?;
112 schema_isolation::configure_search_path(tenant_key, &adapter).await?;
113 }
114
115 // 4. Assemble executor
116 Ok(Arc::new(Executor::new(schema, Arc::new(adapter))))
117}
118
119/// Drop a tenant's PostgreSQL schema if schema isolation mode is active.
120///
121/// Executes `DROP SCHEMA IF EXISTS tenant_{key} CASCADE` against the provided
122/// adapter. This is a no-op if the tenant key does not correspond to an existing
123/// schema. Called from the delete tenant handler when `tenancy.mode = "schema"`.
124///
125/// # Errors
126///
127/// Returns `FraiseQLError::Validation` if the tenant key is invalid.
128/// Returns `FraiseQLError::Database` if the DDL execution fails.
129pub async fn destroy_tenant_schema(tenant_key: &str, adapter: &dyn DatabaseAdapter) -> Result<()> {
130 schema_isolation::drop_tenant_schema(tenant_key, adapter).await
131}