use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use time::OffsetDateTime;
use tokio_postgres::GenericClient;
use super::compose::{AppLifecycle, PENDING_FORMAT_VERSION, PendingPlan};
use super::guard::WorkspaceGuard;
use super::ledger::compute_checksum;
use super::naming::{down_filename, up_filename};
use super::projection::BucketKey;
use super::schema::{AppliedSchema, SNAPSHOT_FORMAT_VERSION};
use super::target::{bucket_dir, pending_database_dir, pending_json_path};
pub const DEFAULT_NODE_ID: i32 = 1;
pub const PHASE_ZERO_VERSION: &str = "V00000000000000__phase_zero_bootstrap";
pub(crate) const ALLOWED_EXTENSIONS: &[&str] = &[
"btree_gist",
"pg_trgm",
"pgcrypto",
"pgvector",
"postgis",
"vector",
];
#[derive(Debug)]
pub enum BootstrapError {
InvalidExtensionName {
name: String,
},
UnknownExtension {
name: String,
},
Db {
step: &'static str,
source: tokio_postgres::Error,
},
}
impl std::fmt::Display for BootstrapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BootstrapError::InvalidExtensionName { name } => write!(
f,
"phase 0 bootstrap: extension name `{name}` does not match the \
Postgres-identifier grammar (ASCII letter or underscore followed \
by ASCII alphanumerics or underscores, 1-63 bytes)"
),
BootstrapError::UnknownExtension { name } => write!(
f,
"phase 0 bootstrap: extension `{name}` is not in Djogi's \
known-extension allowlist; add it to ALLOWED_EXTENSIONS in \
migrate/bootstrap.rs to enable installation"
),
BootstrapError::Db { step, source } => {
write!(f, "phase 0 bootstrap: {step} failed: {source}")
}
}
}
}
impl std::error::Error for BootstrapError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
BootstrapError::Db { source, .. } => Some(source),
BootstrapError::InvalidExtensionName { .. }
| BootstrapError::UnknownExtension { .. } => None,
}
}
}
pub(crate) fn compose_heeranjid_install() -> String {
let mut out = String::with_capacity(
heeranjid::postgres_schema::INSTALL_SQL.len()
+ heeranjid::postgres_schema::DESC_FLIP_SQL.len()
+ heeranjid::postgres_schema::DESC_GENERATORS_SQL.len()
+ heeranjid::postgres_schema::BULK_BACKFILL_SQL.len()
+ heeranjid::postgres_schema::SEED_SQL.len()
+ 512,
);
out.push_str("-- HeeRanjID base schema + functions (idempotent).\n");
out.push_str(heeranjid::postgres_schema::INSTALL_SQL);
out.push_str("\n\n-- HeeRanjID desc-flip primitives (heerid_to_desc / ranjid_to_desc / heerid_flip_mask).\n");
out.push_str(heeranjid::postgres_schema::DESC_FLIP_SQL);
out.push_str("\n\n-- HeeRanjID single-row generators + *_next_desc generators.\n");
out.push_str(heeranjid::postgres_schema::DESC_GENERATORS_SQL);
out.push_str("\n\n-- HeeRanjID migration-support procedures (bulk backfill).\n");
out.push_str(heeranjid::postgres_schema::BULK_BACKFILL_SQL);
out.push_str("\n\n-- HeeRanjID default-node seed (node_id = 1, ON CONFLICT DO NOTHING).\n");
out.push_str(heeranjid::postgres_schema::SEED_SQL);
out
}
pub(crate) fn compose_extension_installs(
extensions: &BTreeSet<String>,
) -> Result<String, BootstrapError> {
for name in extensions {
validate_extension_name(name)?;
if ALLOWED_EXTENSIONS.binary_search(&name.as_str()).is_err() {
return Err(BootstrapError::UnknownExtension { name: name.clone() });
}
}
if extensions.is_empty() {
return Ok(String::new());
}
let mut out = String::with_capacity(extensions.iter().map(|s| s.len() + 32).sum());
out.push_str("-- Postgres extensions required by descriptor inventory (idempotent).\n");
for name in extensions {
out.push_str("CREATE EXTENSION IF NOT EXISTS \"");
out.push_str(name);
out.push_str("\";\n");
}
Ok(out)
}
pub(crate) fn compose_node_seed(database: &str, node_id: i32) -> Result<String, BootstrapError> {
validate_extension_name(database)?;
let node_id_str = node_id.to_string();
let mut out = String::with_capacity(database.len() * 2 + node_id_str.len() * 4 + 256);
out.push_str("-- HeeRanjID node-id GUC seed (database-level + session-level).\n");
out.push_str(
"-- `heer.node_id` powers heerid_next(); `heer.ranj_node_id` powers ranjid_next().\n",
);
out.push_str("ALTER DATABASE \"");
out.push_str(database);
out.push_str("\" SET heer.node_id = '");
out.push_str(&node_id_str);
out.push_str("';\n");
out.push_str("ALTER DATABASE \"");
out.push_str(database);
out.push_str("\" SET heer.ranj_node_id = '");
out.push_str(&node_id_str);
out.push_str("';\n");
out.push_str("SET heer.node_id = '");
out.push_str(&node_id_str);
out.push_str("';\n");
out.push_str("SET heer.ranj_node_id = '");
out.push_str(&node_id_str);
out.push_str("';\n");
Ok(out)
}
pub(crate) fn compose_phase_zero(
database: &str,
extensions: &BTreeSet<String>,
node_id: i32,
) -> Result<String, BootstrapError> {
let heeranjid = compose_heeranjid_install();
let exts = compose_extension_installs(extensions)?;
let node = compose_node_seed(database, node_id)?;
let mut out = String::with_capacity(heeranjid.len() + exts.len() + node.len() + 256);
out.push_str("-- ╭───────────────────────────────────────────────────────────────╮\n");
out.push_str("-- │ Djogi Phase 0 bootstrap — HeeRanjID + extensions + node seed │\n");
out.push_str("-- │ Auto-emitted by `djogi migrations compose`. Idempotent. │\n");
out.push_str("-- ╰───────────────────────────────────────────────────────────────╯\n\n");
out.push_str(&heeranjid);
if !exts.is_empty() {
out.push_str("\n\n");
out.push_str(&exts);
}
out.push_str("\n\n");
out.push_str(&node);
Ok(out)
}
#[allow(clippy::disallowed_methods)]
pub async fn run_phase_zero<C>(
client: &C,
database: &str,
extensions: &BTreeSet<String>,
node_id: i32,
) -> Result<(), BootstrapError>
where
C: GenericClient + ?Sized,
{
let sql = compose_phase_zero(database, extensions, node_id)?;
client
.batch_execute(&sql)
.await
.map_err(|source| BootstrapError::Db {
step: "phase_zero",
source,
})?;
Ok(())
}
#[cfg(test)]
fn extension_dependencies_from_models(
models: &BTreeMap<BucketKey, AppliedSchema>,
) -> BTreeSet<String> {
let mut deps = BTreeSet::new();
for schema in models.values() {
for index in &schema.indexes {
if let Some(dep) = &index.extension_dependency {
deps.insert(dep.clone());
}
}
for table in schema.models.values() {
for excl in &table.exclusion_constraints {
if let Some(dep) = &excl.extension_dependency {
deps.insert(dep.clone());
}
}
}
}
deps
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmittedPhaseZero {
pub database: String,
pub up_sql_path: PathBuf,
pub down_sql_path: PathBuf,
pub pending_json_path: PathBuf,
pub extensions: BTreeSet<String>,
}
#[derive(Debug)]
pub enum AutoEmitError {
Compose(BootstrapError),
Io { path: PathBuf, source: io::Error },
PendingJson(serde_json::Error),
}
impl std::fmt::Display for AutoEmitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AutoEmitError::Compose(e) => write!(f, "phase 0 auto-emit: {e}"),
AutoEmitError::Io { path, source } => write!(
f,
"phase 0 auto-emit: i/o failure at {}: {source}",
path.display()
),
AutoEmitError::PendingJson(e) => {
write!(f, "phase 0 auto-emit: pending JSON serialization: {e}")
}
}
}
}
impl std::error::Error for AutoEmitError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AutoEmitError::Compose(e) => Some(e),
AutoEmitError::Io { source, .. } => Some(source),
AutoEmitError::PendingJson(e) => Some(e),
}
}
}
impl From<BootstrapError> for AutoEmitError {
fn from(e: BootstrapError) -> Self {
AutoEmitError::Compose(e)
}
}
pub fn ensure_phase_zero_emitted(
workspace_root: &Path,
models: &BTreeMap<BucketKey, AppliedSchema>,
apps: &[AppLifecycle],
now: OffsetDateTime,
_guard: &WorkspaceGuard,
) -> Result<Vec<EmittedPhaseZero>, AutoEmitError> {
let mut databases: BTreeSet<String> = BTreeSet::new();
for bucket in models.keys() {
databases.insert(bucket.database.clone());
}
for app in apps {
databases.insert(app.database.clone());
}
let mut emitted: Vec<EmittedPhaseZero> = Vec::new();
for database in &databases {
let bucket = BucketKey {
database: database.clone(),
app: String::new(),
};
let dir = bucket_dir(workspace_root, &bucket);
let up_path = dir.join(up_filename(PHASE_ZERO_VERSION));
let down_path = dir.join(down_filename(PHASE_ZERO_VERSION));
let pending_path = pending_json_path(workspace_root, &bucket);
if up_path.exists() && down_path.exists() && pending_path.exists() {
continue;
}
let extensions = extensions_for_database(models, database);
let up_sql = compose_phase_zero(database, &extensions, DEFAULT_NODE_ID)?;
let down_sql = compose_phase_zero_down_text();
let pending = PendingPlan {
format_version: PENDING_FORMAT_VERSION.to_string(),
bucket_database: database.clone(),
bucket_app: String::new(),
version: PHASE_ZERO_VERSION.to_string(),
slug: PHASE_ZERO_SLUG.to_string(),
model_snapshot: empty_schema_for(&bucket),
checksum_up: compute_checksum([up_sql.as_str()]),
checksum_down: None, composed_at: format_rfc3339_seconds(now),
};
let pending_bytes =
serde_json::to_vec_pretty(&pending).map_err(AutoEmitError::PendingJson)?;
ensure_parent(&up_path)?;
ensure_parent(&pending_path)?;
fs::create_dir_all(&dir).map_err(|e| AutoEmitError::Io {
path: dir.clone(),
source: e,
})?;
fs::create_dir_all(pending_database_dir(workspace_root, database)).map_err(|e| {
AutoEmitError::Io {
path: pending_database_dir(workspace_root, database),
source: e,
}
})?;
fs::write(&up_path, up_sql.as_bytes()).map_err(|e| AutoEmitError::Io {
path: up_path.clone(),
source: e,
})?;
fs::write(&down_path, down_sql.as_bytes()).map_err(|e| AutoEmitError::Io {
path: down_path.clone(),
source: e,
})?;
fs::write(&pending_path, &pending_bytes).map_err(|e| AutoEmitError::Io {
path: pending_path.clone(),
source: e,
})?;
emitted.push(EmittedPhaseZero {
database: database.clone(),
up_sql_path: up_path,
down_sql_path: down_path,
pending_json_path: pending_path,
extensions,
});
}
Ok(emitted)
}
const PHASE_ZERO_SLUG: &str = "phase_zero_bootstrap";
fn compose_phase_zero_down_text() -> String {
let mut out = String::with_capacity(512);
out.push_str("-- Djogi Phase 0 bootstrap — down (no-op).\n");
out.push_str("--\n");
out.push_str("-- Phase 0 installs framework dependencies (HeeRanjID schema +\n");
out.push_str("-- Postgres extensions + node-id GUC) that every subsequent\n");
out.push_str("-- migration depends on. Rolling those back would invalidate the\n");
out.push_str("-- entire schema, so Phase 0 has no meaningful down side.\n");
out.push_str("--\n");
out.push_str("-- `djogi db reset` re-replays Phase 0 from scratch on the\n");
out.push_str("-- recreated database. The migration ledger tracks Phase 0 like\n");
out.push_str("-- any other migration; rolling it back is not a supported flow.\n");
out
}
fn extensions_for_database(
models: &BTreeMap<BucketKey, AppliedSchema>,
database: &str,
) -> BTreeSet<String> {
let mut deps = BTreeSet::new();
for (bucket, schema) in models {
if bucket.database != database {
continue;
}
for index in &schema.indexes {
if let Some(dep) = &index.extension_dependency {
deps.insert(dep.clone());
}
}
for table in schema.models.values() {
for excl in &table.exclusion_constraints {
if let Some(dep) = &excl.extension_dependency {
deps.insert(dep.clone());
}
}
}
}
deps
}
fn ensure_parent(path: &Path) -> Result<(), AutoEmitError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).map_err(|e| AutoEmitError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
Ok(())
}
fn empty_schema_for(bucket: &BucketKey) -> AppliedSchema {
AppliedSchema {
djogi_version: env!("CARGO_PKG_VERSION").to_string(),
enums: BTreeMap::new(),
format_version: SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: format_rfc3339_seconds(OffsetDateTime::now_utc()),
indexes: Vec::new(),
models: BTreeMap::new(),
registered_apps: vec![bucket.app.clone()],
}
}
fn format_rfc3339_seconds(instant: OffsetDateTime) -> String {
let utc = instant.to_offset(time::UtcOffset::UTC);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
utc.year(),
utc.month() as u8,
utc.day(),
utc.hour(),
utc.minute(),
utc.second()
)
}
fn validate_extension_name(name: &str) -> Result<(), BootstrapError> {
let bytes = name.as_bytes();
if bytes.is_empty() || bytes.len() > 63 {
return Err(BootstrapError::InvalidExtensionName {
name: name.to_string(),
});
}
let first = bytes[0];
let first_ok = first.is_ascii_alphabetic() || first == b'_';
if !first_ok {
return Err(BootstrapError::InvalidExtensionName {
name: name.to_string(),
});
}
for &b in &bytes[1..] {
let ok = b.is_ascii_alphanumeric() || b == b'_';
if !ok {
return Err(BootstrapError::InvalidExtensionName {
name: name.to_string(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_extension_name_accepts_real_names() {
assert!(validate_extension_name("postgis").is_ok());
assert!(validate_extension_name("pg_trgm").is_ok());
assert!(validate_extension_name("pgcrypto").is_ok());
assert!(validate_extension_name("pgvector").is_ok());
assert!(validate_extension_name("vector").is_ok());
assert!(validate_extension_name("_internal").is_ok());
assert!(validate_extension_name(&"a".repeat(63)).is_ok());
}
#[test]
fn validate_extension_name_rejects_bad_inputs() {
assert!(validate_extension_name("").is_err());
assert!(validate_extension_name(&"a".repeat(64)).is_err());
assert!(validate_extension_name("1starts_with_digit").is_err());
assert!(validate_extension_name("has-dash").is_err());
assert!(validate_extension_name("has space").is_err());
assert!(validate_extension_name("has\"quote").is_err());
assert!(validate_extension_name("has;semicolon").is_err());
assert!(validate_extension_name("naïve").is_err()); }
#[test]
fn compose_extension_installs_empty_set_returns_empty_string() {
let empty = BTreeSet::new();
assert_eq!(compose_extension_installs(&empty).unwrap(), "");
}
#[test]
fn compose_extension_installs_emits_sorted_idempotent_statements() {
let mut s = BTreeSet::new();
s.insert("postgis".to_string());
s.insert("pg_trgm".to_string());
s.insert("pgvector".to_string());
let sql = compose_extension_installs(&s).unwrap();
let pg_trgm = sql.find("\"pg_trgm\"").expect("pg_trgm present");
let pgvector = sql.find("\"pgvector\"").expect("pgvector present");
let postgis = sql.find("\"postgis\"").expect("postgis present");
assert!(pg_trgm < pgvector, "pg_trgm should sort before pgvector");
assert!(pgvector < postgis, "pgvector should sort before postgis");
assert!(sql.contains("CREATE EXTENSION IF NOT EXISTS"));
}
#[test]
fn compose_extension_installs_rejects_bad_name() {
let mut s = BTreeSet::new();
s.insert("good_name".to_string());
s.insert("bad name".to_string());
match compose_extension_installs(&s) {
Err(BootstrapError::InvalidExtensionName { name }) => {
assert_eq!(name, "bad name");
}
other => panic!("expected InvalidExtensionName, got {other:?}"),
}
}
#[test]
fn compose_node_seed_emits_alter_and_session_set() {
let sql = compose_node_seed("djogi_test_abc", 7).unwrap();
assert!(sql.contains("ALTER DATABASE \"djogi_test_abc\" SET heer.node_id = '7'"));
assert!(sql.contains("ALTER DATABASE \"djogi_test_abc\" SET heer.ranj_node_id = '7'"));
assert!(sql.contains("SET heer.node_id = '7'"));
assert!(sql.contains("SET heer.ranj_node_id = '7'"));
}
#[test]
fn compose_node_seed_rejects_bad_database_name() {
match compose_node_seed("bad name", 1) {
Err(BootstrapError::InvalidExtensionName { name }) => {
assert_eq!(name, "bad name");
}
other => panic!("expected InvalidExtensionName, got {other:?}"),
}
}
#[test]
fn compose_heeranjid_install_includes_all_blobs() {
let sql = compose_heeranjid_install();
assert!(sql.contains("HeeRanjID base schema"));
assert!(sql.contains("desc-flip primitives"));
assert!(sql.contains("single-row generators"));
assert!(sql.contains("bulk backfill"));
assert!(sql.contains("default-node seed"));
assert!(sql.contains("heer_nodes") || sql.contains("generate_id"));
}
#[test]
fn compose_phase_zero_orders_install_then_extensions_then_seed() {
let mut exts = BTreeSet::new();
exts.insert("postgis".to_string());
let sql = compose_phase_zero("djogi_test_db", &exts, 1).unwrap();
let install_idx = sql.find("HeeRanjID base schema").expect("install present");
let ext_idx = sql.find("CREATE EXTENSION").expect("extension present");
let seed_idx = sql.find("ALTER DATABASE").expect("seed present");
assert!(install_idx < ext_idx, "install must precede extensions");
assert!(ext_idx < seed_idx, "extensions must precede node seed");
}
#[test]
fn compose_phase_zero_omits_extension_section_when_empty() {
let exts = BTreeSet::new();
let sql = compose_phase_zero("djogi_test_db", &exts, 1).unwrap();
assert!(!sql.contains("CREATE EXTENSION"));
assert!(sql.contains("ALTER DATABASE"));
}
#[test]
fn extension_dependencies_from_models_dedups_across_buckets() {
use crate::migrate::schema::{
IndexKindSchema, IndexSchema, IndexTargetSchema, IndexTypeSchema,
};
let mk_index = |name: &str, dep: Option<&str>| IndexSchema {
extension_dependency: dep.map(|s| s.to_string()),
include: Vec::new(),
index_type: IndexTypeSchema::BTree,
kind: IndexKindSchema::NonUnique,
name: name.to_string(),
nulls_not_distinct: false,
predicate: None,
requires_out_of_transaction: false,
table: "t".to_string(),
target: IndexTargetSchema::Columns(Vec::new()),
};
let mk_schema = |indexes: Vec<IndexSchema>| AppliedSchema {
djogi_version: "0.1.0".to_string(),
enums: BTreeMap::new(),
format_version: super::super::schema::SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: "2026-05-04T00:00:00Z".to_string(),
indexes,
models: BTreeMap::new(),
registered_apps: vec!["".to_string()],
};
let mut models = BTreeMap::new();
models.insert(
BucketKey {
database: "main".to_string(),
app: "billing".to_string(),
},
mk_schema(vec![
mk_index("idx_a", Some("postgis")),
mk_index("idx_b", Some("pg_trgm")),
]),
);
models.insert(
BucketKey {
database: "main".to_string(),
app: "shipping".to_string(),
},
mk_schema(vec![
mk_index("idx_c", Some("postgis")), mk_index("idx_d", None), mk_index("idx_e", Some("pgvector")),
]),
);
let deps = extension_dependencies_from_models(&models);
let expected: BTreeSet<String> = ["pg_trgm", "pgvector", "postgis"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(deps, expected);
}
#[cfg(test)]
fn schema_with_exclusion(
table: &str,
constraint_name: &str,
ext: Option<&str>,
) -> AppliedSchema {
use crate::migrate::schema::{
ExclusionConstraintSchema, ExclusionElementSchema, PkKindSchema, PrimaryKeySchema,
TableSchema,
};
let mut models = BTreeMap::new();
models.insert(
table.to_string(),
TableSchema {
app: None,
columns: Vec::new(),
exclusion_constraints: vec![ExclusionConstraintSchema {
deferrable: false,
elements: vec![ExclusionElementSchema {
expr: "x".to_string(),
with_operator: "=".to_string(),
}],
extension_dependency: ext.map(|s| s.to_string()),
initially_deferred: false,
name: constraint_name.to_string(),
using: "gist".to_string(),
where_clause: None,
}],
fts: None,
is_through: false,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerId,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
storage_params: None,
table: table.to_string(),
table_comment: None,
tablespace: None,
tenant_key: None,
},
);
AppliedSchema {
djogi_version: "0.1.0".to_string(),
enums: BTreeMap::new(),
format_version: super::super::schema::SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: "2026-05-18T00:00:00Z".to_string(),
indexes: Vec::new(),
models,
registered_apps: vec!["".to_string()],
}
}
#[test]
fn extension_dependencies_from_models_includes_exclusion_constraints() {
use crate::migrate::schema::{
IndexKindSchema, IndexSchema, IndexTargetSchema, IndexTypeSchema,
};
let mut models = BTreeMap::new();
models.insert(
BucketKey {
database: "main".to_string(),
app: "billing".to_string(),
},
schema_with_exclusion("invoices", "no_overlap_inv", Some("btree_gist")),
);
let mut shipping_schema = AppliedSchema {
djogi_version: "0.1.0".to_string(),
enums: BTreeMap::new(),
format_version: super::super::schema::SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: "2026-05-18T00:00:00Z".to_string(),
indexes: vec![IndexSchema {
extension_dependency: Some("postgis".to_string()),
include: Vec::new(),
index_type: IndexTypeSchema::Gist,
kind: IndexKindSchema::NonUnique,
name: "ship_geom_idx".to_string(),
nulls_not_distinct: false,
predicate: None,
requires_out_of_transaction: false,
table: "shipments".to_string(),
target: IndexTargetSchema::Columns(Vec::new()),
}],
models: BTreeMap::new(),
registered_apps: vec!["shipping".to_string()],
};
shipping_schema.models.extend(
schema_with_exclusion("appointments", "no_overlap_appt", Some("btree_gist")).models,
);
models.insert(
BucketKey {
database: "main".to_string(),
app: "shipping".to_string(),
},
shipping_schema,
);
let deps = extension_dependencies_from_models(&models);
let expected: BTreeSet<String> = ["btree_gist", "postgis"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
deps, expected,
"btree_gist (from EXCLUDE) and postgis (from index) must merge",
);
}
#[test]
fn extension_dependencies_from_models_skips_none_exclusion_deps() {
let mut models = BTreeMap::new();
models.insert(
BucketKey {
database: "main".to_string(),
app: "billing".to_string(),
},
schema_with_exclusion("period_only", "no_overlap_period", None),
);
let deps = extension_dependencies_from_models(&models);
assert!(
deps.is_empty(),
"EXCLUDE with extension_dependency=None must not request any install: {deps:?}",
);
}
#[test]
fn extensions_for_database_includes_exclusion_constraints_per_database() {
let mut models = BTreeMap::new();
models.insert(
BucketKey {
database: "main".to_string(),
app: "billing".to_string(),
},
schema_with_exclusion("bookings", "no_overlap", Some("btree_gist")),
);
models.insert(
BucketKey {
database: "crud_log".to_string(),
app: "audit".to_string(),
},
schema_with_exclusion("audit_periods", "no_audit_overlap", Some("btree_gist")),
);
let main_deps = extensions_for_database(&models, "main");
assert_eq!(
main_deps,
["btree_gist"].iter().map(|s| s.to_string()).collect(),
);
let crud_deps = extensions_for_database(&models, "crud_log");
assert_eq!(
crud_deps,
["btree_gist"].iter().map(|s| s.to_string()).collect(),
);
let event_deps = extensions_for_database(&models, "event_log");
assert!(
event_deps.is_empty(),
"unreferenced database must have no extension installs: {event_deps:?}",
);
}
use crate::migrate::guard::WorkspaceGuard;
use crate::migrate::guard::acquire as acquire_workspace_lock;
use std::time::Duration;
fn temp_workspace_with_guard(label: &str) -> (PathBuf, WorkspaceGuard) {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("djogi-bootstrap-test-{label}-{stamp}"));
std::fs::create_dir_all(&root).expect("create workspace root");
let lock_path = root.join(crate::migrate::guard::LOCK_FILE_NAME);
let guard = acquire_workspace_lock(&lock_path, Duration::from_secs(5))
.expect("acquire workspace lock");
(root, guard)
}
fn fixed_now() -> OffsetDateTime {
let date = time::Date::from_calendar_date(2026, time::Month::May, 4).unwrap();
let t = time::Time::from_hms(12, 0, 0).unwrap();
date.with_time(t).assume_utc()
}
#[test]
fn ensure_phase_zero_emits_for_empty_workspace_with_apps() {
let (work, guard) = temp_workspace_with_guard("auto_empty_apps");
let apps = vec![AppLifecycle {
label: String::new(),
database: "main".to_string(),
renamed_from: None,
tombstone: false,
}];
let models = BTreeMap::new();
let emitted =
ensure_phase_zero_emitted(&work, &models, &apps, fixed_now(), &guard).expect("emit");
assert_eq!(emitted.len(), 1, "one Phase 0 per database");
assert_eq!(emitted[0].database, "main");
assert!(emitted[0].extensions.is_empty(), "no extensions in models");
assert!(emitted[0].up_sql_path.exists());
assert!(emitted[0].down_sql_path.exists());
assert!(emitted[0].pending_json_path.exists());
let up = fs::read_to_string(&emitted[0].up_sql_path).unwrap();
assert!(up.contains("HeeRanjID base schema"));
assert!(up.contains("ALTER DATABASE \"main\" SET heer.node_id"));
assert!(!up.contains("CREATE EXTENSION"));
let down = fs::read_to_string(&emitted[0].down_sql_path).unwrap();
assert!(down.contains("Phase 0 bootstrap — down"));
assert!(!down.contains("DROP "), "down must not contain real DDL");
let pending_bytes = fs::read(&emitted[0].pending_json_path).unwrap();
let pending: PendingPlan = serde_json::from_slice(&pending_bytes).expect("parse");
assert_eq!(pending.version, PHASE_ZERO_VERSION);
assert_eq!(pending.bucket_database, "main");
assert_eq!(pending.bucket_app, "");
assert!(pending.checksum_up.starts_with("V1:"));
let _ = std::fs::remove_dir_all(&work);
}
#[test]
fn ensure_phase_zero_idempotent_on_second_run() {
let (work, guard) = temp_workspace_with_guard("auto_idempotent");
let apps = vec![AppLifecycle {
label: String::new(),
database: "main".to_string(),
renamed_from: None,
tombstone: false,
}];
let models = BTreeMap::new();
let first =
ensure_phase_zero_emitted(&work, &models, &apps, fixed_now(), &guard).expect("first");
assert_eq!(first.len(), 1);
let second =
ensure_phase_zero_emitted(&work, &models, &apps, fixed_now(), &guard).expect("second");
assert!(
second.is_empty(),
"second run must be a no-op once Phase 0 exists"
);
let _ = std::fs::remove_dir_all(&work);
}
#[test]
fn ensure_phase_zero_aggregates_extensions_per_database() {
use crate::migrate::schema::{
IndexKindSchema, IndexSchema, IndexTargetSchema, IndexTypeSchema,
};
let (work, guard) = temp_workspace_with_guard("auto_extensions");
let mk_index = |name: &str, dep: &str| IndexSchema {
extension_dependency: Some(dep.to_string()),
include: Vec::new(),
index_type: IndexTypeSchema::BTree,
kind: IndexKindSchema::NonUnique,
name: name.to_string(),
nulls_not_distinct: false,
predicate: None,
requires_out_of_transaction: false,
table: "t".to_string(),
target: IndexTargetSchema::Columns(Vec::new()),
};
let mk_schema = |indexes: Vec<IndexSchema>| AppliedSchema {
djogi_version: "0.1.0".to_string(),
enums: BTreeMap::new(),
format_version: SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: "2026-05-04T00:00:00Z".to_string(),
indexes,
models: BTreeMap::new(),
registered_apps: vec!["billing".to_string()],
};
let mut models = BTreeMap::new();
models.insert(
BucketKey {
database: "main".to_string(),
app: "billing".to_string(),
},
mk_schema(vec![mk_index("idx_geom", "postgis")]),
);
models.insert(
BucketKey {
database: "main".to_string(),
app: "shipping".to_string(),
},
mk_schema(vec![mk_index("idx_geom2", "postgis")]),
);
models.insert(
BucketKey {
database: "crud_log".to_string(),
app: "audit".to_string(),
},
mk_schema(vec![mk_index("idx_text", "pg_trgm")]),
);
let apps = vec![
AppLifecycle {
label: "billing".to_string(),
database: "main".to_string(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "shipping".to_string(),
database: "main".to_string(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "audit".to_string(),
database: "crud_log".to_string(),
renamed_from: None,
tombstone: false,
},
];
let emitted =
ensure_phase_zero_emitted(&work, &models, &apps, fixed_now(), &guard).expect("emit");
assert_eq!(emitted.len(), 2, "one Phase 0 per database");
let main_emit = emitted
.iter()
.find(|e| e.database == "main")
.expect("main emitted");
assert_eq!(
main_emit.extensions,
["postgis"].iter().map(|s| s.to_string()).collect()
);
let crud_emit = emitted
.iter()
.find(|e| e.database == "crud_log")
.expect("crud_log emitted");
assert_eq!(
crud_emit.extensions,
["pg_trgm"].iter().map(|s| s.to_string()).collect()
);
let main_up = fs::read_to_string(&main_emit.up_sql_path).unwrap();
assert!(main_up.contains("CREATE EXTENSION IF NOT EXISTS \"postgis\""));
assert!(
!main_up.contains("\"pg_trgm\""),
"cross-database extension bled into main"
);
let crud_up = fs::read_to_string(&crud_emit.up_sql_path).unwrap();
assert!(crud_up.contains("CREATE EXTENSION IF NOT EXISTS \"pg_trgm\""));
assert!(
!crud_up.contains("\"postgis\""),
"cross-database extension bled into crud_log"
);
let _ = std::fs::remove_dir_all(&work);
}
#[test]
fn ensure_phase_zero_aggregates_btree_gist_from_exclusion_constraints() {
let (work, guard) = temp_workspace_with_guard("auto_extensions_excl");
let mut models = BTreeMap::new();
models.insert(
BucketKey {
database: "main".to_string(),
app: "scheduling".to_string(),
},
schema_with_exclusion("bookings", "bookings_no_overlap", Some("btree_gist")),
);
let apps = vec![AppLifecycle {
label: "scheduling".to_string(),
database: "main".to_string(),
renamed_from: None,
tombstone: false,
}];
let emitted =
ensure_phase_zero_emitted(&work, &models, &apps, fixed_now(), &guard).expect("emit");
assert_eq!(emitted.len(), 1, "exactly one Phase 0 (main)");
let main_emit = emitted.iter().find(|e| e.database == "main").unwrap();
assert_eq!(
main_emit.extensions,
["btree_gist"].iter().map(|s| s.to_string()).collect(),
"EXCLUDE-derived btree_gist must surface in Phase 0 extensions",
);
let up = fs::read_to_string(&main_emit.up_sql_path).unwrap();
assert!(
up.contains("CREATE EXTENSION IF NOT EXISTS \"btree_gist\""),
"Phase 0 up SQL must auto-install btree_gist: {up}",
);
let _ = std::fs::remove_dir_all(&work);
}
#[test]
fn ensure_phase_zero_skips_databases_with_existing_marker() {
let (work, guard) = temp_workspace_with_guard("auto_skip_marker");
let apps = vec![AppLifecycle {
label: String::new(),
database: "main".to_string(),
renamed_from: None,
tombstone: false,
}];
let models = BTreeMap::new();
let bucket = BucketKey {
database: "main".to_string(),
app: String::new(),
};
let dir = bucket_dir(&work, &bucket);
fs::create_dir_all(&dir).unwrap();
fs::create_dir_all(pending_database_dir(&work, "main")).unwrap();
fs::write(
dir.join(up_filename(PHASE_ZERO_VERSION)),
"-- existing Phase 0 up",
)
.unwrap();
fs::write(
dir.join(down_filename(PHASE_ZERO_VERSION)),
"-- existing Phase 0 down",
)
.unwrap();
fs::write(pending_json_path(&work, &bucket), b"{}").unwrap();
let emitted =
ensure_phase_zero_emitted(&work, &models, &apps, fixed_now(), &guard).expect("emit");
assert!(
emitted.is_empty(),
"main was skipped (all three artifacts present); no other databases in inputs"
);
let content = fs::read_to_string(dir.join(up_filename(PHASE_ZERO_VERSION))).unwrap();
assert_eq!(content, "-- existing Phase 0 up");
let _ = std::fs::remove_dir_all(&work);
}
}