Skip to main content

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}