djogi 0.1.0-alpha.2

Model-first web framework for Rust — web-framework-agnostic core; Axum integration opt-in via the `axum` feature flag
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! Configuration via `Djogi.toml` + environment variables.
//!
//! `DATABASE_URL` env var always overrides `[database].url`.
//! Secrets live in env vars, never in `Djogi.toml`.

use figment::{
    Figment,
    providers::{Env, Format, Serialized, Toml},
};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
pub struct DjogiConfig {
    pub database: DatabaseConfig,
    pub server: ServerConfig,
    /// Migration-engine settings. See [`MigrateConfig`].
    #[serde(default)]
    pub migrate: MigrateConfig,
    /// Deployment profile. Drives the migration engine's
    /// out-of-order policy default (production/CI rejects out-of-order
    /// applies; development warns and proceeds) and gates destructive
    /// `attune --squash` operations.
    ///
    /// Recognised values today: `"development"` (default), `"production"`,
    /// `"staging"`, `"test"`. Anything that is not the literal
    /// `"production"` string is treated as non-production.
    #[serde(default = "default_profile")]
    pub profile: String,
    /// Migration policy knobs — orthogonal to [`MigrateConfig`]
    /// (which controls runner behaviour like the relpages probe).
    /// Policy fields gate which apply paths the runner accepts and how
    /// loud `verify` is about historical drift.
    #[serde(default)]
    pub policy: PolicyConfig,
}

/// Default value for [`DjogiConfig::profile`] when the field is absent
/// from `Djogi.toml`. Development is the safe default for new
/// projects — production environments must opt in explicitly.
fn default_profile() -> String {
    "development".to_string()
}

#[derive(Debug, Deserialize, Serialize)]
pub struct DatabaseConfig {
    pub url: String,
    /// CRUD/audit database URL. When `None`, audit surfaces derive
    /// `crud_log` from [`url`](Self::url) unless an environment variable
    /// override is present.
    #[serde(default)]
    pub crud_log_url: Option<String>,
    /// Event/observability database URL. Reserved for the event-log
    /// pool surface; stored here so `Djogi.toml` matches the documented
    /// three-database architecture even before every consumer is wired.
    #[serde(default)]
    pub event_log_url: Option<String>,
    /// Connection-pool size override. `None` (or absent in TOML) means
    /// the env > Djogi.toml > builder-default chain falls through to
    /// the builder default; an explicit non-zero value here overrides
    /// the default. Zero is treated identically to `None` so a user
    /// typo cannot silently zero the pool.
    #[serde(default)]
    pub max_connections: Option<u32>,
    pub dev_mode: bool,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct ServerConfig {
    pub host: String,
    pub port: u16,
}

/// Migration-engine settings. Controls the runner's relpages-probe
/// behaviour for `CREATE INDEX` statements that lack
/// `requires_out_of_transaction = true`.
///
/// Per Phase 7-Zero v3 §6.5 — when a transactional `CREATE INDEX` is
/// about to run against a table whose `pg_class.relpages` exceeds
/// `concurrent_warn_relpages`, the runner emits a
/// `tracing::warn!` advising the operator to opt the index into
/// `CREATE INDEX CONCURRENTLY` (which would set
/// `requires_out_of_transaction = true` upstream). When
/// `strict_concurrent_warnings` is `true` the runner aborts the apply
/// with a `RunnerError::RelpagesThresholdExceeded` instead — failing
/// loudly is the production stance for environments that cannot
/// tolerate a long ACCESS-EXCLUSIVE lock.
#[derive(Debug, Deserialize, Serialize)]
pub struct MigrateConfig {
    /// `pg_class.relpages` threshold above which a transactional
    /// `CREATE INDEX` triggers the advisory probe. Default `128`
    /// (≈1 MB at the standard 8 kB page size). Set to `u32::MAX` to
    /// disable the probe entirely.
    pub concurrent_warn_relpages: u32,

    /// When `true`, the relpages probe upgrades from a `tracing::warn!`
    /// to a hard error (`RunnerError::RelpagesThresholdExceeded`).
    /// Default `false` so dev iteration is unblocked; production
    /// configs typically flip this on.
    pub strict_concurrent_warnings: bool,

    /// Threshold (in seconds) above which an open transaction
    /// triggers the pre-flight refusal in T9's PK-flip orchestration.
    /// The runner enumerates `pg_stat_activity` rows whose
    /// `xact_start` is older than `now() - INTERVAL <threshold>`
    /// before opening the cutover transaction; any rows found refuse
    /// the cutover with `RunnerError::PkFlipHazardLongRunningTx`.
    /// Default `60` seconds. Set to `0` to disable the check.
    #[serde(default = "default_pk_flip_long_tx_threshold_secs")]
    pub pk_flip_long_tx_threshold_secs: u32,

    /// Join-table cutover layout for T9's PK-flip orchestration.
    /// `'A'` (default — uppercase ASCII letter A) emits a single
    /// mega-transaction across both parents and the join table per
    /// playbook §7. `'B'` emits sequential per-parent migrations
    /// each of which is a self-contained PkTypeFlipGroup. The
    /// compose pipeline reads this knob to decide between the two
    /// layouts. Operators flip to `'B'` when their reviewers prefer
    /// narrower windows over the atomic mega-tx invariant.
    #[serde(default = "default_pk_flip_join_table_option")]
    pub pk_flip_join_table_option: char,
}

fn default_pk_flip_long_tx_threshold_secs() -> u32 {
    60
}

fn default_pk_flip_join_table_option() -> char {
    'A'
}

impl Default for MigrateConfig {
    fn default() -> Self {
        Self {
            concurrent_warn_relpages: 128,
            strict_concurrent_warnings: false,
            pk_flip_long_tx_threshold_secs: default_pk_flip_long_tx_threshold_secs(),
            pk_flip_join_table_option: default_pk_flip_join_table_option(),
        }
    }
}

/// Migration policy knobs — controls how the runner reacts to
/// out-of-order applies and how `verify` reports historical
/// out-of-order rows.
///
/// These fields are intentionally separate from [`MigrateConfig`].
/// `MigrateConfig` controls runner mechanics (relpages probe, strict
/// warnings) — `PolicyConfig` controls policy decisions (allow vs
/// reject). The split lets an operator dial mechanics independently
/// from policy.
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct PolicyConfig {
    /// When `true`, [`crate::migrate::verify`] surfaces out-of-order
    /// rows as `D622` Error diagnostics (verify exits non-zero).
    /// When `false` (the default), the same rows surface as `D622`
    /// Warning — verify still reports the drift, but does not fail
    /// the run.
    ///
    /// Pair with the runner-side [`crate::migrate::OutOfOrderPolicy`]:
    /// the runner gates whether out-of-order applies are PERMITTED;
    /// `strict_out_of_order` gates whether already-applied out-of-order
    /// rows count as a verify-time ERROR or just a warning. Production
    /// environments that have already cleaned up historical drift
    /// flip this on to make new drift hard to ignore.
    #[serde(default)]
    pub strict_out_of_order: bool,
}

impl Default for DjogiConfig {
    fn default() -> Self {
        Self {
            database: DatabaseConfig {
                url: String::new(),
                crud_log_url: None,
                event_log_url: None,
                max_connections: None,
                dev_mode: false,
            },
            server: ServerConfig {
                host: "0.0.0.0".into(),
                port: 8000,
            },
            migrate: MigrateConfig::default(),
            profile: default_profile(),
            policy: PolicyConfig::default(),
        }
    }
}

impl DjogiConfig {
    /// Returns `true` when this configuration represents a production
    /// deployment. Used by the migration engine to set the default
    /// [`crate::migrate::OutOfOrderPolicy`] to `Reject` and to gate
    /// `attune --squash` against accidental destructive history
    /// rewrites.
    ///
    /// **Definition.** `profile` literally equal to the lowercase
    /// string `"production"`. Anything else (including
    /// `"Production"`, `"PROD"`, `"prod"`) is treated as
    /// non-production. The strictness is intentional — a typo in the
    /// profile field should fall back to the safe-for-dev default,
    /// not silently flip the runner into reject-mode.
    pub fn is_production(&self) -> bool {
        self.profile == "production"
    }
}

impl DjogiConfig {
    /// Load configuration from `Djogi.toml` (if present) merged with
    /// environment variables. `DATABASE_URL` overrides `database.url`.
    #[allow(clippy::result_large_err)]
    pub fn load() -> Result<Self, figment::Error> {
        let mut config: DjogiConfig = Figment::new()
            .merge(Serialized::defaults(DjogiConfig::default()))
            .merge(Toml::file("Djogi.toml"))
            .merge(Env::prefixed("DJOGI_").split("_"))
            .extract()?;

        // DATABASE_URL env var always wins (not prefixed with DJOGI_)
        if let Ok(url) = std::env::var("DATABASE_URL") {
            config.database.url = url;
        }
        if let Ok(url) = std::env::var("CRUD_LOG_URL")
            && !url.is_empty()
        {
            config.database.crud_log_url = Some(url);
        }
        if let Ok(url) = std::env::var("EVENT_LOG_URL")
            && !url.is_empty()
        {
            config.database.event_log_url = Some(url);
        }

        Ok(config)
    }

    /// Load configuration from `<workspace>/Djogi.toml` instead of the
    /// cwd-relative `Djogi.toml`.
    ///
    /// This is the path-aware loader used by CLI subcommands that
    /// accept `--workspace <path>`. The default
    /// [`load`](Self::load) reads `Djogi.toml` from the current
    /// working directory; callers that want to operate against a
    /// different workspace pass the resolved path here. Environment-
    /// variable overrides (`DATABASE_URL`, `DJOGI_*`) still win, so
    /// secrets stay out of the workspace config.
    #[allow(clippy::result_large_err)]
    pub fn load_from_workspace(workspace: &std::path::Path) -> Result<Self, figment::Error> {
        let toml_path = workspace.join("Djogi.toml");
        let mut config: DjogiConfig = Figment::new()
            .merge(Serialized::defaults(DjogiConfig::default()))
            .merge(Toml::file(toml_path))
            .merge(Env::prefixed("DJOGI_").split("_"))
            .extract()?;

        // DATABASE_URL env var always wins (not prefixed with DJOGI_)
        if let Ok(url) = std::env::var("DATABASE_URL") {
            config.database.url = url;
        }
        if let Ok(url) = std::env::var("CRUD_LOG_URL")
            && !url.is_empty()
        {
            config.database.crud_log_url = Some(url);
        }
        if let Ok(url) = std::env::var("EVENT_LOG_URL")
            && !url.is_empty()
        {
            config.database.event_log_url = Some(url);
        }

        Ok(config)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_profile_is_development() {
        let cfg = DjogiConfig::default();
        assert_eq!(cfg.profile, "development");
        assert!(!cfg.is_production());
    }

    #[test]
    fn profile_eq_production_only_for_exact_lowercase_match() {
        // Helper: build a default config with the supplied profile string.
        let with_profile = |s: &str| DjogiConfig {
            profile: s.to_string(),
            ..DjogiConfig::default()
        };
        assert!(with_profile("production").is_production());

        // Strict — typos must fall back to non-production.
        assert!(!with_profile("Production").is_production());
        assert!(!with_profile("PROD").is_production());
        assert!(!with_profile("prod").is_production());
        assert!(!with_profile("staging").is_production());
        assert!(!with_profile("test").is_production());
        assert!(!with_profile("").is_production());
    }

    #[test]
    fn database_max_connections_default_is_none() {
        let cfg = DjogiConfig::default();
        assert!(cfg.database.max_connections.is_none());
    }

    /// Loading a TOML that omits `[database].max_connections` keeps
    /// `None` rather than silently substituting a non-zero default —
    /// the `from_database_config` path must be able to fall through to
    /// the builder default.
    #[test]
    #[allow(clippy::result_large_err)] // Jail returns figment::Error
    fn loaded_config_without_max_connections_is_none() {
        figment::Jail::expect_with(|jail| {
            jail.create_file(
                "Djogi.toml",
                r#"
                [database]
                url = "postgres://localhost/test"
                dev_mode = false

                [server]
                host = "0.0.0.0"
                port = 8000
                "#,
            )?;
            // No DATABASE_URL or DJOGI_DATABASE_MAX_CONNECTIONS in jail.
            let cfg = DjogiConfig::load().expect("load should succeed");
            assert!(
                cfg.database.max_connections.is_none(),
                "TOML without max_connections must remain None"
            );
            Ok(())
        });
    }

    #[test]
    #[allow(clippy::result_large_err)] // Jail returns figment::Error
    fn loaded_config_reads_three_database_urls_from_toml() {
        let _guard = crate::migrate::audit::AUDIT_URL_ENV_MUTEX
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        figment::Jail::expect_with(|jail| {
            // Empty process env values are treated as unset, so this
            // test pins the TOML surface without inheriting a developer
            // shell's CRUD_LOG_URL / EVENT_LOG_URL.
            jail.set_env("CRUD_LOG_URL", "");
            jail.set_env("EVENT_LOG_URL", "");
            jail.create_file(
                "Djogi.toml",
                r#"
                [database]
                url = "postgres://localhost/app"
                crud_log_url = "postgres://localhost/crud_log"
                event_log_url = "postgres://localhost/event_log"
                dev_mode = false

                [server]
                host = "0.0.0.0"
                port = 8000
                "#,
            )?;

            let cfg = DjogiConfig::load().expect("load should succeed");
            assert_eq!(
                cfg.database.crud_log_url.as_deref(),
                Some("postgres://localhost/crud_log")
            );
            assert_eq!(
                cfg.database.event_log_url.as_deref(),
                Some("postgres://localhost/event_log")
            );
            Ok(())
        });
    }

    #[test]
    #[allow(clippy::result_large_err)] // Jail returns figment::Error
    fn unprefixed_log_url_env_vars_override_toml() {
        let _guard = crate::migrate::audit::AUDIT_URL_ENV_MUTEX
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        figment::Jail::expect_with(|jail| {
            jail.set_env("CRUD_LOG_URL", "postgres://localhost/env_crud_log");
            jail.set_env("EVENT_LOG_URL", "postgres://localhost/env_event_log");
            jail.create_file(
                "Djogi.toml",
                r#"
                [database]
                url = "postgres://localhost/app"
                crud_log_url = "postgres://localhost/toml_crud_log"
                event_log_url = "postgres://localhost/toml_event_log"
                dev_mode = false

                [server]
                host = "0.0.0.0"
                port = 8000
                "#,
            )?;

            let cfg = DjogiConfig::load().expect("load should succeed");
            assert_eq!(
                cfg.database.crud_log_url.as_deref(),
                Some("postgres://localhost/env_crud_log")
            );
            assert_eq!(
                cfg.database.event_log_url.as_deref(),
                Some("postgres://localhost/env_event_log")
            );
            Ok(())
        });
    }

    #[test]
    fn policy_config_default_is_lenient() {
        let cfg = DjogiConfig::default();
        assert!(!cfg.policy.strict_out_of_order);
    }
}