#![allow(clippy::result_large_err)]
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use super::diff::{Classification, SchemaDelta, SchemaOperation, diff_bucket_maps};
use super::guard::WorkspaceGuard;
use super::ledger::compute_checksum;
use super::naming::{down_filename, sanitize_slug, up_filename, version_id, version_prefix};
use super::projection::BucketKey;
use super::replay_plan::{committed_replay_plan_path, serialize_committed_replay_plan};
use super::schema::AppliedSchema;
use super::segment::{Segment, SegmentKind, plan_delta};
use super::snapshot::SnapshotError;
use super::sql::{OperationSql, lower_delta};
use super::target::{bucket_dir, pending_database_dir, pending_json_path};
struct RestorePoint {
final_path: PathBuf,
backup_path: Option<PathBuf>,
}
struct WriteRollback {
tmps: Vec<PathBuf>,
restore_points: Vec<RestorePoint>,
entry_renames: Vec<(PathBuf, PathBuf)>,
}
impl WriteRollback {
fn new() -> Self {
Self {
tmps: Vec::new(),
restore_points: Vec::new(),
entry_renames: Vec::new(),
}
}
fn track_tmp(&mut self, path: PathBuf) {
self.tmps.push(path);
}
fn promote(&mut self, tmp: &Path, final_path: PathBuf, backup_path: Option<PathBuf>) {
if let Some(idx) = self.tmps.iter().position(|p| p == tmp) {
self.tmps.remove(idx);
}
self.restore_points.push(RestorePoint {
final_path,
backup_path,
});
}
fn track_entry_rename(&mut self, from: PathBuf, to: PathBuf) {
self.entry_renames.push((from, to));
}
fn commit(mut self) {
self.tmps.clear();
for rp in self.restore_points.drain(..) {
if let Some(backup) = rp.backup_path {
let _ = fs::remove_file(&backup);
}
}
self.entry_renames.clear();
}
}
impl Drop for WriteRollback {
fn drop(&mut self) {
for p in self.tmps.drain(..) {
let _ = fs::remove_file(&p);
}
for rp in self.restore_points.drain(..).rev() {
match rp.backup_path {
Some(backup) => {
if fs::rename(&backup, &rp.final_path).is_err() {
let _ = fs::remove_file(&rp.final_path);
let _ = fs::remove_file(&backup);
}
}
None => {
let _ = fs::remove_file(&rp.final_path);
}
}
}
for (from, to) in self.entry_renames.drain(..).rev() {
let _ = fs::rename(&to, &from);
}
}
}
#[derive(Debug)]
pub enum ComposeError {
NothingToCompose,
TombstonedAppRequiresAllowDestructive {
app_label: String,
database: String,
text: String,
},
DestructiveRequiresAllowDestructive {
bucket: BucketKey,
classification: Classification,
},
UnsupportedDelta { bucket: BucketKey, reason: String },
SqlEmit(super::sql::SqlEmitError),
Io { path: PathBuf, source: io::Error },
SerializeFailed(SnapshotError),
HandEditedMigrationWouldBeOverwritten {
bucket: BucketKey,
path: PathBuf,
text: String,
},
FolderRenameTargetCollision {
from: PathBuf,
to: PathBuf,
offending_entry: String,
},
Diff(super::diff::DiffError),
PhaseZeroAutoEmit(super::bootstrap::AutoEmitError),
}
impl std::fmt::Display for ComposeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NothingToCompose => write!(
f,
"D012: nothing to compose — model state matches snapshot for every bucket"
),
Self::TombstonedAppRequiresAllowDestructive { text, .. } => f.write_str(text),
Self::DestructiveRequiresAllowDestructive {
bucket,
classification,
} => write!(
f,
"{database}/{app}: classification {classification:?} requires --allow-destructive",
database = bucket.database,
app = super::target::app_dirname(&bucket.app),
),
Self::UnsupportedDelta { bucket, reason } => write!(
f,
"{database}/{app}: unsupported delta — {reason}",
database = bucket.database,
app = super::target::app_dirname(&bucket.app),
),
Self::SqlEmit(e) => write!(f, "SQL emit failed: {e}"),
Self::Io { path, source } => write!(f, "I/O at {}: {source}", path.display()),
Self::SerializeFailed(e) => write!(f, "serialize failed: {e}"),
Self::HandEditedMigrationWouldBeOverwritten { text, .. } => f.write_str(text),
Self::FolderRenameTargetCollision {
from,
to,
offending_entry,
} => write!(
f,
"folder rename would collide at {to_path}: entry \"{offending_entry}\" already exists \
(source: {from_path}); resolve manually before re-running compose",
from_path = from.display(),
to_path = to.display(),
),
Self::Diff(e) => write!(f, "differ refused: {e}"),
Self::PhaseZeroAutoEmit(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for ComposeError {}
pub struct ComposeRequest<'a> {
pub workspace_root: &'a Path,
pub models: &'a std::collections::BTreeMap<BucketKey, AppliedSchema>,
pub snapshots: &'a std::collections::BTreeMap<BucketKey, AppliedSchema>,
pub apps: &'a [AppLifecycle],
pub name: &'a str,
pub allow_destructive: bool,
pub force_overwrite: bool,
pub now: OffsetDateTime,
pub _guard: &'a WorkspaceGuard,
pub pk_flip_join_table_option: Option<super::diff::PkFlipJoinTableOption>,
#[doc(hidden)]
pub skip_phase_zero_auto_emit: bool,
}
#[derive(Debug, Clone)]
pub struct ComposeReport {
pub composed_buckets: Vec<ComposedBucket>,
pub emitted_phase_zero: Vec<super::bootstrap::EmittedPhaseZero>,
}
#[derive(Debug, Clone)]
pub struct ComposedBucket {
pub bucket: BucketKey,
pub version: String,
pub up_sql_path: PathBuf,
pub down_sql_path: PathBuf,
pub replay_plan_path: PathBuf,
pub pending_json_path: PathBuf,
pub classification: Classification,
}
#[derive(Debug, Clone)]
pub struct AppLifecycle {
pub label: String,
pub database: String,
pub renamed_from: Option<String>,
pub tombstone: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PendingPlan {
pub format_version: String,
pub bucket_database: String,
pub bucket_app: String,
pub version: String,
pub slug: String,
pub model_snapshot: AppliedSchema,
pub checksum_up: String,
pub checksum_down: Option<String>,
pub composed_at: String,
}
pub const PENDING_FORMAT_VERSION: &str = "1";
#[derive(Debug)]
pub enum PendingLoadError {
Parse {
path: Option<PathBuf>,
source: serde_json::Error,
},
UnsupportedFormatVersion {
found: String,
expected: &'static str,
path: Option<PathBuf>,
},
}
impl std::fmt::Display for PendingLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PendingLoadError::Parse { path, source } => match path {
Some(p) => write!(f, "pending parse at {}: {source}", p.display()),
None => write!(f, "pending parse: {source}"),
},
PendingLoadError::UnsupportedFormatVersion {
found,
expected,
path,
} => match path {
Some(p) => write!(
f,
"pending JSON format version '{found}' at {} is not supported by this Djogi (expected '{expected}'); upgrade or check out a newer djogi",
p.display()
),
None => write!(
f,
"pending JSON format version '{found}' is not supported by this Djogi (expected '{expected}'); upgrade or check out a newer djogi"
),
},
}
}
}
impl std::error::Error for PendingLoadError {}
pub fn parse_pending_bytes(
bytes: &[u8],
path: Option<PathBuf>,
) -> Result<PendingPlan, PendingLoadError> {
if let Ok(serde_json::Value::Object(map)) = serde_json::from_slice::<serde_json::Value>(bytes)
&& let Some(serde_json::Value::String(found)) = map.get("format_version")
&& found != PENDING_FORMAT_VERSION
{
return Err(PendingLoadError::UnsupportedFormatVersion {
found: found.clone(),
expected: PENDING_FORMAT_VERSION,
path,
});
}
let plan: PendingPlan = serde_json::from_slice(bytes).map_err(|e| PendingLoadError::Parse {
path: path.clone(),
source: e,
})?;
if plan.format_version != PENDING_FORMAT_VERSION {
return Err(PendingLoadError::UnsupportedFormatVersion {
found: plan.format_version,
expected: PENDING_FORMAT_VERSION,
path,
});
}
Ok(plan)
}
pub fn load_pending(path: &Path) -> Result<PendingPlan, PendingLoadError> {
let bytes = fs::read(path).map_err(|e| PendingLoadError::Parse {
path: Some(path.to_path_buf()),
source: serde_json::Error::io(e),
})?;
parse_pending_bytes(&bytes, Some(path.to_path_buf()))
}
pub fn compose(req: ComposeRequest<'_>) -> Result<ComposeReport, ComposeError> {
let emitted_phase_zero = if req.skip_phase_zero_auto_emit {
Vec::new()
} else {
super::bootstrap::ensure_phase_zero_emitted(
req.workspace_root,
req.models,
req.apps,
req.now,
req._guard,
)
.map_err(ComposeError::PhaseZeroAutoEmit)?
};
if !req.allow_destructive {
for app in req.apps {
if !app.tombstone {
continue;
}
let bucket_for_app = BucketKey {
database: app.database.clone(),
app: app.label.clone(),
};
let models_has_state = req
.models
.get(&bucket_for_app)
.is_some_and(|s| !s.models.is_empty());
let snapshot_has_state = req
.snapshots
.get(&bucket_for_app)
.is_some_and(|s| !s.models.is_empty());
if models_has_state || snapshot_has_state {
let text = format!(
"D011: app \"{label}\" is tombstoned; pass --allow-destructive to emit the drop migration",
label = if app.label.is_empty() {
"_global_"
} else {
app.label.as_str()
}
);
return Err(ComposeError::TombstonedAppRequiresAllowDestructive {
app_label: app.label.clone(),
database: app.database.clone(),
text,
});
}
}
}
let snapshots_for_diff = remap_snapshots_for_renames(req.snapshots, req.apps);
let mut deltas =
diff_bucket_maps(&snapshots_for_diff, req.models).map_err(ComposeError::Diff)?;
if let Some(option) = req.pk_flip_join_table_option {
super::diff::apply_pk_flip_join_table_option(&mut deltas, option);
}
for app in req.apps {
let Some(prior_label) = app.renamed_from.as_deref() else {
continue;
};
let dest_bucket = BucketKey {
database: app.database.clone(),
app: app.label.clone(),
};
if let Some(delta) = deltas.iter_mut().find(|d| d.bucket == dest_bucket) {
delta.operations.insert(
0,
SchemaOperation::RenameApp {
from: prior_label.to_string(),
to: app.label.clone(),
},
);
}
}
let mut effective: Vec<SchemaDelta> = deltas
.into_iter()
.filter(|d| !d.operations.is_empty() || !matches!(d.classification, Classification::NoOp))
.collect();
if effective.is_empty() {
if !emitted_phase_zero.is_empty() {
return Ok(ComposeReport {
composed_buckets: Vec::new(),
emitted_phase_zero,
});
}
return Err(ComposeError::NothingToCompose);
}
for delta in &mut effective {
match &delta.classification {
Classification::Unsupported { reason } => {
return Err(ComposeError::UnsupportedDelta {
bucket: delta.bucket.clone(),
reason: reason.clone(),
});
}
Classification::Destructive | Classification::Lossy if !req.allow_destructive => {
return Err(ComposeError::DestructiveRequiresAllowDestructive {
bucket: delta.bucket.clone(),
classification: delta.classification.clone(),
});
}
_ => {}
}
}
let slug = sanitize_slug(req.name);
let prefix = version_prefix(req.now);
let version = version_id(&prefix, &slug);
let composed_at = format_rfc3339_seconds(req.now);
let mut rollback = WriteRollback::new();
let mut composed_buckets: Vec<ComposedBucket> = Vec::with_capacity(effective.len());
let mut pending_folder_renames: Vec<(PathBuf, PathBuf)> = Vec::new();
let result: Result<(), ComposeError> = (|| {
for delta in &effective {
let mut lowered = lower_delta(delta).map_err(ComposeError::SqlEmit)?;
let mut replay_plan = plan_delta(delta).map_err(ComposeError::SqlEmit)?;
let mut executable_ops: Vec<OperationSql> = replay_plan
.segments
.iter()
.flat_map(|segment| segment.statements.iter().cloned())
.collect();
let mut folder_renames_for_delta: Vec<(String, String)> = Vec::new();
let mut replay_tail_sql: Vec<OperationSql> = Vec::new();
for op in &delta.operations {
if let SchemaOperation::RenameApp { from, to } = op {
let rename_stmt =
emit_rename_app_ledger_update(&delta.bucket.database, from, to);
lowered.push(rename_stmt.clone());
executable_ops.push(rename_stmt.clone());
replay_tail_sql.push(rename_stmt);
folder_renames_for_delta.push((from.clone(), to.clone()));
}
}
if !replay_tail_sql.is_empty() {
replay_plan.segments.push(Segment {
kind: SegmentKind::Transactional,
statements: replay_tail_sql,
});
}
let model_snapshot = req
.models
.get(&delta.bucket)
.cloned()
.unwrap_or_else(|| empty_schema_for(&delta.bucket));
let (checksum_up, checksum_down) = compute_checksums(&executable_ops);
let pending = PendingPlan {
format_version: PENDING_FORMAT_VERSION.to_string(),
bucket_database: delta.bucket.database.clone(),
bucket_app: delta.bucket.app.clone(),
version: version.clone(),
slug: slug.clone(),
model_snapshot,
checksum_up: checksum_up.clone(),
checksum_down: checksum_down.clone(),
composed_at: composed_at.clone(),
};
let up_path = bucket_dir(req.workspace_root, &delta.bucket).join(up_filename(&version));
let down_path =
bucket_dir(req.workspace_root, &delta.bucket).join(down_filename(&version));
let replay_plan_path =
committed_replay_plan_path(req.workspace_root, &delta.bucket, &version);
let pending_path = pending_json_path(req.workspace_root, &delta.bucket);
let up_sql = compose_up_text(&version, delta, &lowered);
let down_sql = compose_down_text(&version, delta, &lowered);
let replay_plan_bytes = serialize_committed_replay_plan(
&replay_plan,
&checksum_up,
checksum_down.as_deref(),
)
.map_err(|e| {
ComposeError::SerializeFailed(SnapshotError::Parse {
source: e,
path: None,
})
})?;
let pending_bytes = serialize_pending(&pending)?;
if !req.force_overwrite {
check_no_hand_edit(
&up_path,
up_sql.as_bytes(),
&down_path,
down_sql.as_bytes(),
&delta.bucket,
)?;
}
ensure_parent(&up_path)?;
ensure_parent(&pending_path)?;
let up_tmp = atomic_write(&up_path, up_sql.as_bytes())?;
rollback.track_tmp(up_tmp.clone());
let down_tmp = atomic_write(&down_path, down_sql.as_bytes())?;
rollback.track_tmp(down_tmp.clone());
let replay_plan_tmp = atomic_write(&replay_plan_path, &replay_plan_bytes)?;
rollback.track_tmp(replay_plan_tmp.clone());
let pending_tmp = atomic_write(&pending_path, &pending_bytes)?;
rollback.track_tmp(pending_tmp.clone());
let up_backup = promote_tmp_with_backup(&up_tmp, &up_path)?;
rollback.promote(&up_tmp, up_path.clone(), up_backup);
let down_backup = promote_tmp_with_backup(&down_tmp, &down_path)?;
rollback.promote(&down_tmp, down_path.clone(), down_backup);
let replay_plan_backup = promote_tmp_with_backup(&replay_plan_tmp, &replay_plan_path)?;
rollback.promote(
&replay_plan_tmp,
replay_plan_path.clone(),
replay_plan_backup,
);
let pending_backup = promote_tmp_with_backup(&pending_tmp, &pending_path)?;
rollback.promote(&pending_tmp, pending_path.clone(), pending_backup);
for (from_label, _to_label) in folder_renames_for_delta {
let from_bucket = BucketKey {
database: delta.bucket.database.clone(),
app: from_label,
};
let from_dir = bucket_dir(req.workspace_root, &from_bucket);
let to_dir = bucket_dir(req.workspace_root, &delta.bucket);
pending_folder_renames.push((from_dir, to_dir));
}
composed_buckets.push(ComposedBucket {
bucket: delta.bucket.clone(),
version: version.clone(),
up_sql_path: up_path,
down_sql_path: down_path,
replay_plan_path,
pending_json_path: pending_path,
classification: delta.classification.clone(),
});
}
Ok(())
})();
result?;
for (from_dir, to_dir) in &pending_folder_renames {
rename_old_bucket_folder(from_dir, to_dir, &mut rollback)?;
}
rollback.commit();
Ok(ComposeReport {
composed_buckets,
emitted_phase_zero,
})
}
fn check_no_hand_edit(
up_path: &Path,
fresh_up_bytes: &[u8],
down_path: &Path,
fresh_down_bytes: &[u8],
bucket: &BucketKey,
) -> Result<(), ComposeError> {
let up_edited = match fs::read(up_path) {
Ok(existing) => existing != fresh_up_bytes,
Err(_) => false, };
let down_edited = match fs::read(down_path) {
Ok(existing) => existing != fresh_down_bytes,
Err(_) => false,
};
if !up_edited && !down_edited {
return Ok(());
}
let (reported_path, side_label) = match (up_edited, down_edited) {
(true, true) => (up_path.to_path_buf(), "up and down"),
(true, false) => (up_path.to_path_buf(), "up"),
(false, true) => (down_path.to_path_buf(), "down"),
(false, false) => unreachable!("guarded above"),
};
let text = format!(
"D013: hand-edited migration would be overwritten ({side_label} side); \
pass --force-overwrite to discard your edits ({path})",
path = reported_path.display()
);
Err(ComposeError::HandEditedMigrationWouldBeOverwritten {
bucket: bucket.clone(),
path: reported_path,
text,
})
}
fn emit_rename_app_ledger_update(database: &str, from: &str, to: &str) -> OperationSql {
let _ = database; let from_escaped = sql_escape_string(from);
let to_escaped = sql_escape_string(to);
let up = format!(
"UPDATE djogi_schema_migrations \
SET app_label = '{to_escaped}' \
WHERE app_label = '{from_escaped}';"
);
let down = format!(
"UPDATE djogi_schema_migrations \
SET app_label = '{from_escaped}' \
WHERE app_label = '{to_escaped}';"
);
OperationSql {
label: format!("RenameAppLedger {from} -> {to}"),
up,
down,
lossy: None,
}
}
fn sql_escape_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if ch == '\'' {
out.push('\'');
out.push('\'');
} else {
out.push(ch);
}
}
out
}
fn rename_old_bucket_folder(
from_dir: &Path,
to_dir: &Path,
rollback: &mut WriteRollback,
) -> Result<(), ComposeError> {
if from_dir == to_dir {
return Ok(());
}
if !from_dir.exists() {
return Ok(());
}
if !to_dir.exists() {
ensure_parent(to_dir)?;
fs::rename(from_dir, to_dir).map_err(|e| ComposeError::Io {
path: to_dir.to_path_buf(),
source: e,
})?;
rollback.track_entry_rename(from_dir.to_path_buf(), to_dir.to_path_buf());
return Ok(());
}
let entries: Vec<PathBuf> = fs::read_dir(from_dir)
.map_err(|e| ComposeError::Io {
path: from_dir.to_path_buf(),
source: e,
})?
.filter_map(|res| res.ok().map(|e| e.path()))
.collect();
for src in &entries {
let Some(name) = src.file_name() else {
continue;
};
let dst = to_dir.join(name);
if dst.exists() {
return Err(ComposeError::FolderRenameTargetCollision {
from: from_dir.to_path_buf(),
to: to_dir.to_path_buf(),
offending_entry: name.to_string_lossy().to_string(),
});
}
}
for src in entries {
let Some(name) = src.file_name().map(|n| n.to_os_string()) else {
continue;
};
let dst = to_dir.join(&name);
fs::rename(&src, &dst).map_err(|e| ComposeError::Io {
path: dst.clone(),
source: e,
})?;
rollback.track_entry_rename(src, dst);
}
fs::remove_dir_all(from_dir).map_err(|e| ComposeError::Io {
path: from_dir.to_path_buf(),
source: e,
})
}
fn serialize_pending(p: &PendingPlan) -> Result<Vec<u8>, ComposeError> {
let mut bytes = serde_json::to_vec_pretty(p).map_err(|e| {
ComposeError::SerializeFailed(SnapshotError::Parse {
path: None,
source: e,
})
})?;
bytes.push(b'\n');
Ok(bytes)
}
fn empty_schema_for(bucket: &BucketKey) -> AppliedSchema {
AppliedSchema {
djogi_version: env!("CARGO_PKG_VERSION").to_string(),
enums: Default::default(),
format_version: super::schema::SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: format_rfc3339_seconds(OffsetDateTime::UNIX_EPOCH),
indexes: Vec::new(),
models: Default::default(),
registered_apps: vec![bucket.app.clone()],
}
}
fn remap_snapshots_for_renames(
snapshots: &std::collections::BTreeMap<BucketKey, AppliedSchema>,
apps: &[AppLifecycle],
) -> std::collections::BTreeMap<BucketKey, AppliedSchema> {
use std::collections::BTreeMap;
let mut rename_map: BTreeMap<(String, String), String> = BTreeMap::new();
for app in apps {
if let Some(old) = app.renamed_from.as_deref() {
rename_map.insert((app.database.clone(), old.to_string()), app.label.clone());
}
}
if rename_map.is_empty() {
return snapshots.clone();
}
let mut remapped: BTreeMap<BucketKey, AppliedSchema> = BTreeMap::new();
for (key, schema) in snapshots {
let lookup_key = (key.database.clone(), key.app.clone());
if let Some(new_label) = rename_map.get(&lookup_key) {
let new_key = BucketKey {
database: key.database.clone(),
app: new_label.clone(),
};
let mut relabeled = schema.clone();
for entry in &mut relabeled.registered_apps {
if entry == &key.app {
*entry = new_label.clone();
}
}
remapped.insert(new_key, relabeled);
} else {
remapped.insert(key.clone(), schema.clone());
}
}
remapped
}
fn compute_checksums(lowered: &[OperationSql]) -> (String, Option<String>) {
let up = compute_checksum(lowered.iter().map(|o| o.up.as_str()));
let any_real_down = lowered.iter().any(|o| !o.down.starts_with("--"));
let down = if any_real_down {
Some(compute_checksum(lowered.iter().map(|o| o.down.as_str())))
} else {
None
};
(up, down)
}
fn compose_up_text(version: &str, delta: &SchemaDelta, lowered: &[OperationSql]) -> String {
let mut out = String::with_capacity(lowered.iter().map(|o| o.up.len()).sum::<usize>() + 256);
out.push_str("-- Djogi composed migration — up\n");
out.push_str(&format!("-- Version: {version}\n"));
out.push_str(&format!(
"-- Bucket: {database}/{app}\n",
database = delta.bucket.database,
app = super::target::app_dirname(&delta.bucket.app),
));
out.push_str(&format!(
"-- Classification: {classification:?}\n",
classification = delta.classification,
));
out.push_str("--\n");
out.push_str("-- Apply via `djogi migrations apply`, not psql. This file is a review\n");
out.push_str("-- artifact and replay source. Manual execution bypasses ledger recording,\n");
out.push_str("-- checksum verification, advisory locking, and snapshot advancement.\n");
out.push_str("-- Direct execution is only appropriate for debugging, audit transparency,\n");
out.push_str("-- or explicit operator override.\n");
out.push_str("--\n");
out.push_str("-- DO NOT EDIT — regenerate via `djogi migrations compose`.\n\n");
if requires_numeric_array_helper(lowered) {
out.push_str(NUMERIC_ARRAY_HELPER_PRELUDE);
out.push('\n');
}
if requires_date_array_helper(lowered) {
out.push_str(DATE_ARRAY_HELPER_PRELUDE);
out.push('\n');
}
if requires_tstz_array_helper(lowered) {
out.push_str(TSTZ_ARRAY_HELPER_PRELUDE);
out.push('\n');
}
for op in lowered {
out.push_str(&format!("-- {label}\n", label = op.label));
out.push_str(op.up.trim_end_matches('\n'));
out.push_str("\n\n");
}
out
}
fn compose_down_text(version: &str, delta: &SchemaDelta, lowered: &[OperationSql]) -> String {
let mut out = String::with_capacity(lowered.iter().map(|o| o.down.len()).sum::<usize>() + 256);
out.push_str("-- Djogi composed migration — down\n");
out.push_str(&format!("-- Version: {version}\n"));
out.push_str(&format!(
"-- Bucket: {database}/{app}\n",
database = delta.bucket.database,
app = super::target::app_dirname(&delta.bucket.app),
));
out.push_str("--\n");
out.push_str("-- Apply via `djogi migrations apply`, not psql. This file is a review\n");
out.push_str("-- artifact and replay source. Manual execution bypasses ledger recording,\n");
out.push_str("-- checksum verification, advisory locking, and snapshot advancement.\n");
out.push_str("-- Direct execution is only appropriate for debugging, audit transparency,\n");
out.push_str("-- or explicit operator override.\n");
out.push_str("--\n");
out.push_str("-- DO NOT EDIT — regenerate via `djogi migrations compose`.\n\n");
if requires_numeric_array_helper(lowered) {
out.push_str(NUMERIC_ARRAY_HELPER_PRELUDE);
out.push('\n');
}
if requires_date_array_helper(lowered) {
out.push_str(DATE_ARRAY_HELPER_PRELUDE);
out.push('\n');
}
if requires_tstz_array_helper(lowered) {
out.push_str(TSTZ_ARRAY_HELPER_PRELUDE);
out.push('\n');
}
for op in lowered.iter().rev() {
out.push_str(&format!("-- {label}\n", label = op.label));
if let Some(lossy) = &op.lossy {
out.push_str(&format!(
"-- LOSSY: {kind:?} — {detail}\n",
kind = lossy.kind,
detail = lossy.detail
));
}
out.push_str(op.down.trim_end_matches('\n'));
out.push_str("\n\n");
}
out
}
const NUMERIC_ARRAY_HELPER_MARKER: &str = "djogi.__djogi_numeric_array_is_rust_decimal_v1(";
pub(crate) const NUMERIC_ARRAY_HELPER_PRELUDE: &str = r#"CREATE SCHEMA IF NOT EXISTS djogi;
CREATE OR REPLACE FUNCTION djogi.__djogi_numeric_array_is_rust_decimal_v1(input_array pg_catalog.numeric[])
RETURNS pg_catalog.bool
LANGUAGE sql
IMMUTABLE STRICT PARALLEL SAFE
AS $$
SELECT COALESCE(
pg_catalog.bool_and(
value IS NULL
OR (
pg_catalog.scale(value) IS NOT NULL
AND pg_catalog.scale(value) <= 28
AND pg_catalog.abs(value)
* pg_catalog.power(10::pg_catalog.numeric, pg_catalog.scale(value))
<= 79228162514264337593543950335::pg_catalog.numeric
)
),
true
)
FROM pg_catalog.unnest(input_array) AS value(value);
$$;
"#;
pub(crate) fn requires_numeric_array_helper(operations: &[OperationSql]) -> bool {
operations.iter().any(|op| {
op.up.contains(NUMERIC_ARRAY_HELPER_MARKER) || op.down.contains(NUMERIC_ARRAY_HELPER_MARKER)
})
}
pub(crate) fn numeric_array_helper_operation() -> OperationSql {
OperationSql {
label: "Ensure djogi numeric-array helper".to_string(),
up: NUMERIC_ARRAY_HELPER_PRELUDE.to_string(),
down: "-- no-op rollback placeholder: helper is shared by framework CHECK constraints"
.to_string(),
lossy: None,
}
}
const DATE_ARRAY_HELPER_MARKER: &str = "djogi.__djogi_date_array_is_finite_v1(";
const TSTZ_ARRAY_HELPER_MARKER: &str = "djogi.__djogi_tstz_array_is_finite_v1(";
pub(crate) const DATE_ARRAY_HELPER_PRELUDE: &str = r#"CREATE SCHEMA IF NOT EXISTS djogi;
CREATE OR REPLACE FUNCTION djogi.__djogi_date_array_is_finite_v1(input_array pg_catalog.date[])
RETURNS pg_catalog.bool
LANGUAGE sql
IMMUTABLE STRICT PARALLEL SAFE
AS $$
SELECT COALESCE(
pg_catalog.bool_and(
value IS NULL
OR (
pg_catalog.isfinite(value)
AND value <= '9999-12-31'::pg_catalog.date
)
),
true
)
FROM pg_catalog.unnest(input_array) AS value(value);
$$;
"#;
pub(crate) const TSTZ_ARRAY_HELPER_PRELUDE: &str = r#"CREATE SCHEMA IF NOT EXISTS djogi;
CREATE OR REPLACE FUNCTION djogi.__djogi_tstz_array_is_finite_v1(input_array pg_catalog.timestamptz[])
RETURNS pg_catalog.bool
LANGUAGE sql
IMMUTABLE STRICT PARALLEL SAFE
AS $$
SELECT COALESCE(
pg_catalog.bool_and(
value IS NULL
OR (
pg_catalog.isfinite(value)
AND value <= '9999-12-31 23:59:59.999999+00'::pg_catalog.timestamptz
)
),
true
)
FROM pg_catalog.unnest(input_array) AS value(value);
$$;
"#;
pub(crate) fn requires_date_array_helper(operations: &[OperationSql]) -> bool {
operations.iter().any(|op| {
op.up.contains(DATE_ARRAY_HELPER_MARKER) || op.down.contains(DATE_ARRAY_HELPER_MARKER)
})
}
pub(crate) fn requires_tstz_array_helper(operations: &[OperationSql]) -> bool {
operations.iter().any(|op| {
op.up.contains(TSTZ_ARRAY_HELPER_MARKER) || op.down.contains(TSTZ_ARRAY_HELPER_MARKER)
})
}
pub(crate) fn date_array_helper_operation() -> OperationSql {
OperationSql {
label: "Ensure djogi date-array finite-element helper".to_string(),
up: DATE_ARRAY_HELPER_PRELUDE.to_string(),
down: "-- no-op rollback placeholder: helper is shared by framework CHECK constraints"
.to_string(),
lossy: None,
}
}
pub(crate) fn tstz_array_helper_operation() -> OperationSql {
OperationSql {
label: "Ensure djogi timestamptz-array finite-element helper".to_string(),
up: TSTZ_ARRAY_HELPER_PRELUDE.to_string(),
down: "-- no-op rollback placeholder: helper is shared by framework CHECK constraints"
.to_string(),
lossy: None,
}
}
fn ensure_parent(path: &Path) -> Result<(), ComposeError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).map_err(|e| ComposeError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
Ok(())
}
fn atomic_write(final_path: &Path, bytes: &[u8]) -> Result<PathBuf, ComposeError> {
use std::io::Write as _;
let pid = std::process::id();
let mut file_name = final_path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
file_name.push(format!(".tmp.{pid}"));
let tmp = final_path.with_file_name(file_name);
let mut f = fs::File::create(&tmp).map_err(|e| ComposeError::Io {
path: tmp.clone(),
source: e,
})?;
f.write_all(bytes).map_err(|e| ComposeError::Io {
path: tmp.clone(),
source: e,
})?;
f.sync_all().map_err(|e| ComposeError::Io {
path: tmp.clone(),
source: e,
})?;
Ok(tmp)
}
fn promote_tmp_with_backup(tmp: &Path, final_path: &Path) -> Result<Option<PathBuf>, ComposeError> {
use std::sync::atomic::{AtomicU64, Ordering};
static BACKUP_COUNTER: AtomicU64 = AtomicU64::new(0);
let backup_path = if final_path.exists() {
let pid = std::process::id();
let n = BACKUP_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut name = final_path
.file_name()
.map(|f| f.to_os_string())
.unwrap_or_default();
name.push(format!(".bak.{pid}.{n}"));
let backup = final_path.with_file_name(name);
fs::copy(final_path, &backup).map_err(|e| ComposeError::Io {
path: backup.clone(),
source: e,
})?;
Some(backup)
} else {
None
};
if let Err(e) = fs::rename(tmp, final_path) {
if let Some(b) = backup_path {
let _ = fs::remove_file(&b);
}
return Err(ComposeError::Io {
path: final_path.to_path_buf(),
source: e,
});
}
Ok(backup_path)
}
pub fn prepare_pending_dirs(workspace_root: &Path, bucket: &BucketKey) -> Result<(), ComposeError> {
let dir = pending_database_dir(workspace_root, &bucket.database);
fs::create_dir_all(&dir).map_err(|e| ComposeError::Io {
path: dir,
source: e,
})
}
fn format_rfc3339_seconds(instant: OffsetDateTime) -> String {
let utc = instant.to_offset(time::UtcOffset::UTC);
let secs = utc.unix_timestamp();
let trimmed = OffsetDateTime::from_unix_timestamp(secs).unwrap_or(utc);
let format = time::format_description::well_known::Rfc3339;
trimmed
.format(&format)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrate::guard::acquire as acquire_guard;
use crate::migrate::replay_plan::{self, ReplayPlanLoadStatus};
use crate::migrate::schema::{
ColumnSchema, PkKindSchema, PrimaryKeySchema, SNAPSHOT_FORMAT_VERSION, TableSchema,
};
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
fn at(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> OffsetDateTime {
let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day)
.unwrap();
let time = time::Time::from_hms(hour, minute, second).unwrap();
date.with_time(time).assume_utc()
}
fn temp_workspace(tag: &str) -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir().join(format!("djogi-compose-{tag}-{nanos}-{n}"));
fs::create_dir_all(&p).unwrap();
p
}
fn lock_for(workspace: &Path) -> WorkspaceGuard {
let lock_path = workspace.join(super::super::guard::LOCK_FILE_NAME);
acquire_guard(&lock_path, Duration::from_secs(5)).expect("lock")
}
fn empty_snapshot(bucket: &BucketKey) -> AppliedSchema {
AppliedSchema {
djogi_version: env!("CARGO_PKG_VERSION").to_string(),
enums: BTreeMap::new(),
format_version: SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: "2026-04-25T00:00:00Z".to_string(),
indexes: Vec::new(),
models: BTreeMap::new(),
registered_apps: vec![bucket.app.clone()],
}
}
fn snapshot_with_widgets(bucket: &BucketKey) -> AppliedSchema {
let mut s = empty_snapshot(bucket);
s.models.insert(
"widgets".to_string(),
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![ColumnSchema {
check: None,
comment: None,
default_sql: Some("heerid_next_desc()".to_string()),
foreign_key: None,
generated: None,
identity: None,
index_type: None,
indexed: false,
max_length: None,
name: "id".to_string(),
nullable: false,
on_delete: None,
outbox_exclude: false,
rationale: None,
relation_kind: None,
renamed_from: None,
sequence_within: None,
sql_type: "BIGINT".to_string(),
unique: false,
type_change_using: None,
}],
exclusion_constraints: Vec::new(),
fts: None,
is_through: false,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerIdRecencyBiased,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
table: "widgets".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
},
);
s
}
fn id_column_heerid_desc() -> ColumnSchema {
ColumnSchema {
default_sql: Some("heerid_next_desc()".to_string()),
..col("id", "BIGINT", false)
}
}
fn col(name: &str, ty: &str, nullable: bool) -> ColumnSchema {
ColumnSchema {
check: None,
comment: None,
default_sql: None,
foreign_key: None,
generated: None,
identity: None,
index_type: None,
indexed: false,
max_length: None,
name: name.to_string(),
nullable,
on_delete: None,
outbox_exclude: false,
rationale: None,
relation_kind: None,
renamed_from: None,
sequence_within: None,
sql_type: ty.to_string(),
unique: false,
type_change_using: None,
}
}
fn col_numeric_array_metric_check() -> ColumnSchema {
ColumnSchema {
check: Some("djogi.__djogi_numeric_array_is_rust_decimal_v1(\"amounts\")".to_string()),
..col("amounts", "NUMERIC[]", true)
}
}
fn col_date_array_with_finite_check() -> ColumnSchema {
ColumnSchema {
check: Some("djogi.__djogi_date_array_is_finite_v1(\"blackout_dates\")".to_string()),
..col("blackout_dates", "DATE[]", true)
}
}
fn col_tstz_array_with_finite_check() -> ColumnSchema {
ColumnSchema {
check: Some("djogi.__djogi_tstz_array_is_finite_v1(\"scheduled_slots\")".to_string()),
..col("scheduled_slots", "TIMESTAMPTZ[]", true)
}
}
fn table_with_all_three_array_helpers(bucket: &BucketKey) -> TableSchema {
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![
id_column_heerid_desc(),
col_numeric_array_metric_check(),
col_date_array_with_finite_check(),
col_tstz_array_with_finite_check(),
],
exclusion_constraints: Vec::new(),
fts: None,
is_through: false,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerIdRecencyBiased,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
table: "mixed_array_events".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
fn table_with_numeric_array_metric_check(bucket: &BucketKey) -> TableSchema {
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![id_column_heerid_desc(), col_numeric_array_metric_check()],
exclusion_constraints: Vec::new(),
fts: None,
is_through: false,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerIdRecencyBiased,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
table: "metrics_events".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
fn global_bucket() -> BucketKey {
BucketKey {
database: "main".into(),
app: "".into(),
}
}
#[test]
fn compose_pending_checksum_matches_runner_plan_checksum_for_numeric_array_helper_migrations() {
let work = temp_workspace("numeric-array-helper-checksum");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
let mut model_snapshot = snapshot_with_widgets(&bucket);
model_snapshot.models.insert(
"metrics_events".to_string(),
table_with_numeric_array_metric_check(&bucket),
);
models.insert(bucket.clone(), model_snapshot);
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "numeric-array-checksum-parity",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("compose should succeed");
assert_eq!(report.composed_buckets.len(), 1);
let pending_bytes = fs::read(&report.composed_buckets[0].pending_json_path).unwrap();
let pending: PendingPlan = serde_json::from_slice(&pending_bytes).expect("parse pending");
let deltas = diff_bucket_maps(&snapshots, &models).expect("diff for expected checksum");
let delta = deltas
.into_iter()
.find(|delta| delta.bucket == bucket)
.expect("numeric-array bucket delta");
let plan = plan_delta(&delta).expect("canonical plan for runner-style checksum");
let runner_style_checksum = compute_checksum(
plan.segments
.iter()
.flat_map(|segment| segment.statements.iter())
.map(|statement| statement.up.as_str()),
);
assert_eq!(
pending.checksum_up, runner_style_checksum,
"compose pending checksum must match runner-plan checksum when NumericArray helper is injected"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn compose_pending_checksum_matches_runner_plan_checksum_for_mixed_helper_delta() {
let work = temp_workspace("mixed-array-helper-checksum");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
let mut model_snapshot = snapshot_with_widgets(&bucket);
model_snapshot.models.insert(
"mixed_array_events".to_string(),
table_with_all_three_array_helpers(&bucket),
);
models.insert(bucket.clone(), model_snapshot);
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "mixed-array-checksum-parity",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("compose should succeed");
assert_eq!(report.composed_buckets.len(), 1);
let pending_bytes = fs::read(&report.composed_buckets[0].pending_json_path).unwrap();
let pending: PendingPlan = serde_json::from_slice(&pending_bytes).expect("parse pending");
let deltas = diff_bucket_maps(&snapshots, &models).expect("diff for expected checksum");
let delta = deltas
.into_iter()
.find(|d| d.bucket == bucket)
.expect("mixed-array bucket delta");
let plan = plan_delta(&delta).expect("canonical plan for runner-style checksum");
let runner_style_checksum = compute_checksum(
plan.segments
.iter()
.flat_map(|segment| segment.statements.iter())
.map(|statement| statement.up.as_str()),
);
assert_eq!(
pending.checksum_up, runner_style_checksum,
"compose pending checksum must match runner-plan checksum when all three \
array helpers (numeric, date, tstz) are injected"
);
let up_sql =
fs::read_to_string(&report.composed_buckets[0].up_sql_path).expect("read up SQL");
let numeric_sql_pos = up_sql
.find("__djogi_numeric_array_is_rust_decimal_v1")
.expect("numeric helper in SQL file");
let date_sql_pos = up_sql
.find("__djogi_date_array_is_finite_v1")
.expect("date helper in SQL file");
let tstz_sql_pos = up_sql
.find("__djogi_tstz_array_is_finite_v1")
.expect("tstz helper in SQL file");
assert!(
numeric_sql_pos < date_sql_pos,
"numeric helper prelude must precede date helper prelude in SQL file"
);
assert!(
date_sql_pos < tstz_sql_pos,
"date helper prelude must precede tstz helper prelude in SQL file"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn empty_models_and_snapshots_returns_nothing_to_compose() {
let work = temp_workspace("empty");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), empty_snapshot(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "noop",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("noop");
assert!(matches!(err, ComposeError::NothingToCompose));
let _ = fs::remove_dir_all(&work);
}
#[test]
fn add_table_writes_four_files_atomically() {
let work = temp_workspace("add_table");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("compose");
assert_eq!(report.composed_buckets.len(), 1);
let cb = &report.composed_buckets[0];
assert!(cb.up_sql_path.exists());
assert!(cb.down_sql_path.exists());
assert!(cb.replay_plan_path.exists());
assert!(cb.pending_json_path.exists());
let up = fs::read_to_string(&cb.up_sql_path).unwrap();
assert!(up.contains("CREATE TABLE \"widgets\""));
let pending_bytes = fs::read(&cb.pending_json_path).unwrap();
let pending: PendingPlan = serde_json::from_slice(&pending_bytes).expect("parse");
match replay_plan::load_committed_replay_plan(
&work,
&cb.bucket,
&cb.version,
&pending.checksum_up,
pending.checksum_down.as_deref(),
) {
ReplayPlanLoadStatus::Loaded(plan) => {
assert_eq!(plan.classification, Classification::Additive);
assert_eq!(plan.segments.len(), 1);
assert_eq!(plan.segments[0].kind, SegmentKind::Transactional);
assert_eq!(plan.segments[0].statements[0].label, "AddTable widgets");
}
other => panic!("expected committed replay plan, got {other:?}"),
}
assert_eq!(pending.bucket_app, "");
assert_eq!(pending.bucket_database, "main");
assert!(pending.checksum_up.starts_with("V1:"));
assert!(pending.version.starts_with("V20260425010203__"));
let _ = fs::remove_dir_all(&work);
}
#[test]
fn destructive_classification_requires_allow_destructive() {
let work = temp_workspace("destructive");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), empty_snapshot(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "drop widgets",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("destructive");
assert!(matches!(
err,
ComposeError::DestructiveRequiresAllowDestructive { .. }
));
let dir = bucket_dir(&work, &bucket);
let count = fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0);
assert_eq!(count, 0, "no SQL written on destructive refusal");
let _ = fs::remove_dir_all(&work);
}
#[test]
fn destructive_with_allow_destructive_writes_files() {
let work = temp_workspace("destructive_ok");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), empty_snapshot(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "drop widgets",
allow_destructive: true,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("compose");
assert_eq!(report.composed_buckets.len(), 1);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn tombstoned_app_without_flag_emits_d011() {
let work = temp_workspace("tombstone_no_flag");
let guard = lock_for(&work);
let bucket = BucketKey {
database: "main".into(),
app: "billing".into(),
};
let mut models = BTreeMap::new();
models.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let snapshots = BTreeMap::new();
let app = AppLifecycle {
label: "billing".to_string(),
database: "main".to_string(),
renamed_from: None,
tombstone: true,
};
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: std::slice::from_ref(&app),
name: "tomb",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("tombstone");
match err {
ComposeError::TombstonedAppRequiresAllowDestructive { text, .. } => {
assert!(text.contains("D011"), "must surface D011 token: {text}");
assert!(text.contains("billing"));
}
other => panic!("wrong variant: {other:?}"),
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn rename_app_emits_rename_op_when_destination_has_pending_changes() {
let work = temp_workspace("rename_app");
let guard = lock_for(&work);
let old_bucket = BucketKey {
database: "main".into(),
app: "oldname".into(),
};
let new_bucket = BucketKey {
database: "main".into(),
app: "newname".into(),
};
let mut snapshots = BTreeMap::new();
snapshots.insert(old_bucket.clone(), snapshot_with_widgets(&old_bucket));
let mut models = BTreeMap::new();
let mut new_schema = snapshot_with_widgets(&new_bucket);
let new_table = new_schema.models.get_mut("widgets").unwrap();
new_table.columns.push(ColumnSchema {
check: None,
comment: None,
default_sql: None,
foreign_key: None,
generated: None,
identity: None,
index_type: None,
indexed: false,
max_length: None,
name: "color".to_string(),
nullable: true,
on_delete: None,
outbox_exclude: false,
rationale: None,
relation_kind: None,
renamed_from: None,
sequence_within: None,
sql_type: "TEXT".to_string(),
unique: false,
type_change_using: None,
});
models.insert(new_bucket.clone(), new_schema);
let app = AppLifecycle {
label: "newname".to_string(),
database: "main".to_string(),
renamed_from: Some("oldname".to_string()),
tombstone: false,
};
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: std::slice::from_ref(&app),
name: "rename newname",
allow_destructive: true,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("compose");
let dest = report
.composed_buckets
.iter()
.find(|c| c.bucket == new_bucket)
.expect("destination composed");
let up = fs::read_to_string(&dest.up_sql_path).unwrap();
assert!(
up.contains("RenameApp"),
"up SQL must label the RenameApp op: {up}"
);
assert!(up.contains("oldname"));
assert!(up.contains("newname"));
let _ = fs::remove_dir_all(&work);
}
#[test]
fn overwrite_on_same_name_replaces_artifacts_byte_stably() {
let work = temp_workspace("overwrite");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let now = at(2026, 4, 25, 1, 2, 3);
let req1 = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: false,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let r1 = compose(req1).expect("first");
let up1 = fs::read(&r1.composed_buckets[0].up_sql_path).unwrap();
let pending1 = fs::read(&r1.composed_buckets[0].pending_json_path).unwrap();
let req2 = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: false,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let r2 = compose(req2).expect("second");
let up2 = fs::read(&r2.composed_buckets[0].up_sql_path).unwrap();
let pending2 = fs::read(&r2.composed_buckets[0].pending_json_path).unwrap();
assert_eq!(up1, up2, "up SQL must be byte-identical");
assert_eq!(pending1, pending2, "pending JSON must be byte-identical");
let _ = fs::remove_dir_all(&work);
}
#[test]
fn pending_json_round_trips_through_serde() {
let bucket = global_bucket();
let plan = PendingPlan {
format_version: PENDING_FORMAT_VERSION.to_string(),
bucket_database: bucket.database.clone(),
bucket_app: bucket.app.clone(),
version: "V20260425010203__add_widgets".to_string(),
slug: "add_widgets".to_string(),
model_snapshot: empty_snapshot(&bucket),
checksum_up: "V1:".to_string() + &"a".repeat(64),
checksum_down: None,
composed_at: "2026-04-25T01:02:03Z".to_string(),
};
let bytes = serde_json::to_vec(&plan).unwrap();
let parsed: PendingPlan = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed, plan);
}
#[test]
fn b4_d011_fires_when_models_empty_but_snapshot_has_state() {
let work = temp_workspace("b4_zero_model_d011");
let guard = lock_for(&work);
let bucket = BucketKey {
database: "main".into(),
app: "billing".into(),
};
let mut models = BTreeMap::new();
models.insert(bucket.clone(), empty_snapshot(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let app = AppLifecycle {
label: "billing".to_string(),
database: "main".to_string(),
renamed_from: None,
tombstone: true,
};
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: std::slice::from_ref(&app),
name: "tomb",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("must surface D011");
match err {
ComposeError::TombstonedAppRequiresAllowDestructive { text, .. } => {
assert!(text.contains("D011"), "must surface D011 token: {text}");
assert!(text.contains("billing"));
}
other => panic!("wrong variant: {other:?}"),
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn b3_d013_refuses_to_overwrite_hand_edited_migration() {
let work = temp_workspace("b3_hand_edit");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let now = at(2026, 4, 25, 1, 2, 3);
let req1 = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: false,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let r1 = compose(req1).expect("first");
let up_path = r1.composed_buckets[0].up_sql_path.clone();
let original = fs::read_to_string(&up_path).unwrap();
let edited = original.clone() + "\n-- operator hand-edit\n";
fs::write(&up_path, &edited).unwrap();
let req2 = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: false,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req2).expect_err("must refuse");
match err {
ComposeError::HandEditedMigrationWouldBeOverwritten { text, path, .. } => {
assert!(
text.starts_with("D013:"),
"must start with D013 prefix: {text}"
);
assert!(
text.contains("hand-edited migration would be overwritten"),
"must carry the canonical phrase: {text}"
);
assert!(
text.contains("(up side)"),
"side label must read \"(up side)\" verbatim: {text}"
);
assert!(
text.contains("pass --force-overwrite"),
"must instruct the operator to use --force-overwrite: {text}"
);
assert!(
text.contains(&path.display().to_string()),
"must include the offending path: {text}"
);
assert_eq!(path, up_path, "path must be the up file");
}
other => panic!("wrong variant: {other:?}"),
}
let after_refusal = fs::read_to_string(&up_path).unwrap();
assert_eq!(after_refusal, edited, "must not have been clobbered");
let req3 = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: true,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
compose(req3).expect("force-overwrite succeeds");
let after_force = fs::read_to_string(&up_path).unwrap();
assert_eq!(
after_force, original,
"force-overwrite must restore canonical SQL"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn b5_rename_app_emits_ledger_update_and_renames_folder() {
let work = temp_workspace("b5_rename_round_trip");
let guard = lock_for(&work);
let old_bucket = BucketKey {
database: "main".into(),
app: "oldname".into(),
};
let new_bucket = BucketKey {
database: "main".into(),
app: "newname".into(),
};
let old_dir = bucket_dir(&work, &old_bucket);
fs::create_dir_all(&old_dir).unwrap();
fs::write(old_dir.join("V20260101010101__init.sdjql"), "-- init").unwrap();
let mut snapshots = BTreeMap::new();
snapshots.insert(old_bucket.clone(), snapshot_with_widgets(&old_bucket));
let mut models = BTreeMap::new();
models.insert(new_bucket.clone(), snapshot_with_widgets(&new_bucket));
let app = AppLifecycle {
label: "newname".to_string(),
database: "main".to_string(),
renamed_from: Some("oldname".to_string()),
tombstone: false,
};
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: std::slice::from_ref(&app),
name: "rename newname",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("compose");
let dest = report
.composed_buckets
.iter()
.find(|c| c.bucket == new_bucket)
.expect("destination composed");
let up = fs::read_to_string(&dest.up_sql_path).unwrap();
let down = fs::read_to_string(&dest.down_sql_path).unwrap();
assert!(
up.contains("UPDATE djogi_schema_migrations"),
"up must carry the ledger UPDATE: {up}"
);
assert!(up.contains("'newname'") && up.contains("'oldname'"));
assert!(
down.contains("UPDATE djogi_schema_migrations"),
"down must carry the inverse UPDATE: {down}"
);
let new_dir = bucket_dir(&work, &new_bucket);
assert!(new_dir.exists(), "new bucket dir must exist");
assert!(
!old_dir.exists(),
"old bucket dir must have been renamed away"
);
assert!(new_dir.join("V20260101010101__init.sdjql").exists());
assert!(
!up.contains("DROP TABLE \"widgets\""),
"rename must not emit DROP TABLE for widgets: {up}"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn b7_pending_format_version_peek_rejects_future_version() {
let blob = r#"{
"format_version": "2",
"bucket_database": "main",
"bucket_app": "billing",
"version": "V20260425010203__add_invoices",
"slug": "add_invoices",
"model_snapshot": {
"djogi_version": "0.2.0",
"enums": {},
"format_version": "1",
"generated_at": "2027-01-01T00:00:00Z",
"indexes": [],
"models": {},
"registered_apps": []
},
"checksum_up": "V1:0000000000000000000000000000000000000000000000000000000000000000",
"checksum_down": null,
"composed_at": "2026-04-25T01:02:03Z",
"future_field_added_in_v2": "garbage"
}"#;
let err = parse_pending_bytes(blob.as_bytes(), None).expect_err("must fail");
match err {
PendingLoadError::UnsupportedFormatVersion {
found, expected, ..
} => {
assert_eq!(found, "2");
assert_eq!(expected, "1");
}
other => panic!("expected UnsupportedFormatVersion, got {other:?}"),
}
}
#[test]
fn b7_pending_loader_accepts_current_format_version() {
let bucket = global_bucket();
let plan = PendingPlan {
format_version: PENDING_FORMAT_VERSION.to_string(),
bucket_database: bucket.database.clone(),
bucket_app: bucket.app.clone(),
version: "V20260425010203__add_widgets".to_string(),
slug: "add_widgets".to_string(),
model_snapshot: empty_snapshot(&bucket),
checksum_up: "V1:".to_string() + &"a".repeat(64),
checksum_down: None,
composed_at: "2026-04-25T01:02:03Z".to_string(),
};
let bytes = serde_json::to_vec(&plan).unwrap();
let parsed = parse_pending_bytes(&bytes, None).expect("loader accepts canonical shape");
assert_eq!(parsed, plan);
}
#[test]
fn b2_rollback_cleans_all_tmps_on_rename_failure() {
let work = temp_workspace("b2_rollback");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let now = at(2026, 4, 25, 1, 2, 3);
let prefix = version_prefix(now);
let version = version_id(&prefix, &sanitize_slug("add widgets"));
let down_filename_str = down_filename(&version);
let bucket_directory = bucket_dir(&work, &bucket);
fs::create_dir_all(&bucket_directory).unwrap();
let blocked_down = bucket_directory.join(&down_filename_str);
fs::create_dir_all(&blocked_down).unwrap();
fs::write(blocked_down.join("sentinel"), b"keep").unwrap();
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: false,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("rename must fail");
assert!(matches!(err, ComposeError::Io { .. }));
let mut tmp_files = Vec::new();
if let Ok(entries) = fs::read_dir(&bucket_directory) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.contains(".tmp.") {
tmp_files.push(name);
}
}
}
assert!(
tmp_files.is_empty(),
"no .tmp.<pid> file should remain: {tmp_files:?}"
);
let up_path = bucket_directory.join(up_filename(&version));
assert!(!up_path.exists(), "up SQL must have been rolled back");
let pending_path = pending_json_path(&work, &bucket);
assert!(
!pending_path.exists(),
"pending JSON must have been rolled back"
);
assert!(blocked_down.join("sentinel").exists());
let _ = fs::remove_dir_all(&work);
}
#[test]
fn b10_rollback_restores_original_bytes_on_overwrite_failure() {
let work = temp_workspace("b10_overwrite_restore");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let now = at(2026, 4, 25, 1, 2, 3);
let prefix = version_prefix(now);
let version = version_id(&prefix, &sanitize_slug("add widgets"));
let bucket_directory = bucket_dir(&work, &bucket);
fs::create_dir_all(&bucket_directory).unwrap();
let up_path = bucket_directory.join(up_filename(&version));
fs::write(&up_path, b"old up content").unwrap();
let blocked_down = bucket_directory.join(down_filename(&version));
fs::create_dir_all(&blocked_down).unwrap();
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: true,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("down promote must fail");
assert!(matches!(err, ComposeError::Io { .. }));
let mut tmp_files: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(&bucket_directory) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.contains(".tmp.") {
tmp_files.push(name);
}
}
}
assert!(
tmp_files.is_empty(),
".tmp.<pid> files must be cleaned: {tmp_files:?}"
);
let after = fs::read_to_string(&up_path).expect("up still exists");
assert_eq!(
after, "old up content",
"rollback must restore original up bytes from the backup"
);
let mut bak_files: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(&bucket_directory) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.contains(".bak.") {
bak_files.push(name);
}
}
}
assert!(
bak_files.is_empty(),
"backup files must be cleaned after restore: {bak_files:?}"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn b10_rollback_restores_multi_promote_lifo_order() {
let work = temp_workspace("b10_multi_promote_lifo");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let now = at(2026, 4, 25, 1, 2, 3);
let prefix = version_prefix(now);
let version = version_id(&prefix, &sanitize_slug("add widgets"));
let bucket_directory = bucket_dir(&work, &bucket);
fs::create_dir_all(&bucket_directory).unwrap();
let up_path = bucket_directory.join(up_filename(&version));
let down_path = bucket_directory.join(down_filename(&version));
let original_up = b"operator up content";
let original_down = b"operator down content";
fs::write(&up_path, original_up).unwrap();
fs::write(&down_path, original_down).unwrap();
let pending_path = pending_json_path(&work, &bucket);
if let Some(parent) = pending_path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::create_dir_all(&pending_path).unwrap();
fs::write(pending_path.join("sentinel"), b"keep").unwrap();
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: true,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("pending promote must fail");
assert!(
matches!(err, ComposeError::Io { .. }),
"must surface a typed I/O error: {err:?}"
);
let after_up = fs::read(&up_path).expect("up file still present");
assert_eq!(
after_up.as_slice(),
original_up,
"up file must be restored to original operator content"
);
let after_down = fs::read(&down_path).expect("down file still present");
assert_eq!(
after_down.as_slice(),
original_down,
"down file must be restored to original operator content"
);
let mut tmp_files: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(&bucket_directory) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.contains(".tmp.") {
tmp_files.push(name);
}
}
}
assert!(
tmp_files.is_empty(),
".tmp.<pid> files must be cleaned: {tmp_files:?}"
);
let mut bak_files: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(&bucket_directory) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.contains(".bak.") {
bak_files.push(name);
}
}
}
assert!(
bak_files.is_empty(),
"backup files must be cleaned after restore: {bak_files:?}"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn b3_round2_d013_fires_on_down_only_hand_edit() {
let work = temp_workspace("b3r2_down_only");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let now = at(2026, 4, 25, 1, 2, 3);
let req1 = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: false,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let r1 = compose(req1).expect("first compose");
let down_path = r1.composed_buckets[0].down_sql_path.clone();
let original_down = fs::read_to_string(&down_path).unwrap();
let edited_down = original_down.clone() + "\n-- operator hand-edit on down only\n";
fs::write(&down_path, &edited_down).unwrap();
let req2 = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: false,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req2).expect_err("down hand-edit must refuse");
match err {
ComposeError::HandEditedMigrationWouldBeOverwritten { text, path, .. } => {
assert!(
text.starts_with("D013:"),
"must start with D013 prefix: {text}"
);
assert!(
text.contains("hand-edited migration would be overwritten"),
"must carry the canonical phrase: {text}"
);
assert!(
text.contains("(down side)"),
"side label must read \"(down side)\" verbatim: {text}"
);
assert!(
text.contains("pass --force-overwrite"),
"must instruct the operator to use --force-overwrite: {text}"
);
assert!(
text.contains(&path.display().to_string()),
"must include the offending path: {text}"
);
assert_eq!(path, down_path, "path must be the down file");
}
other => panic!("wrong variant: {other:?}"),
}
let after = fs::read_to_string(&down_path).unwrap();
assert_eq!(after, edited_down);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn b3_round2_d013_fires_on_both_sides_hand_edit() {
let work = temp_workspace("b3r2_both");
let guard = lock_for(&work);
let bucket = global_bucket();
let mut models = BTreeMap::new();
models.insert(bucket.clone(), snapshot_with_widgets(&bucket));
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), empty_snapshot(&bucket));
let now = at(2026, 4, 25, 1, 2, 3);
let req1 = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: false,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let r1 = compose(req1).expect("first compose");
let up_path = r1.composed_buckets[0].up_sql_path.clone();
let down_path = r1.composed_buckets[0].down_sql_path.clone();
fs::write(&up_path, b"-- hand edit up\n").unwrap();
fs::write(&down_path, b"-- hand edit down\n").unwrap();
let req2 = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &[],
name: "add widgets",
allow_destructive: false,
force_overwrite: false,
now,
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req2).expect_err("both-side edit must refuse");
match err {
ComposeError::HandEditedMigrationWouldBeOverwritten { text, path, .. } => {
assert!(
text.starts_with("D013:"),
"must start with D013 prefix: {text}"
);
assert!(
text.contains("hand-edited migration would be overwritten"),
"must carry the canonical phrase: {text}"
);
assert!(
text.contains("(up and down side)"),
"side label must read \"(up and down side)\" verbatim: {text}"
);
assert!(
text.contains("pass --force-overwrite"),
"must instruct the operator to use --force-overwrite: {text}"
);
assert!(
text.contains(&path.display().to_string()),
"must include the offending path: {text}"
);
assert_eq!(path, up_path, "both-edited reports the up path");
}
other => panic!("wrong variant: {other:?}"),
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn b9_rename_app_with_three_tables_no_allow_destructive() {
let work = temp_workspace("b9_rename_three_tables");
let guard = lock_for(&work);
let old_bucket = BucketKey {
database: "main".into(),
app: "billing".into(),
};
let new_bucket = BucketKey {
database: "main".into(),
app: "invoicing".into(),
};
fn three_tables(bucket: &BucketKey) -> AppliedSchema {
let mut s = AppliedSchema {
djogi_version: env!("CARGO_PKG_VERSION").to_string(),
enums: BTreeMap::new(),
format_version: SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: "2026-04-25T00:00:00Z".to_string(),
indexes: Vec::new(),
models: BTreeMap::new(),
registered_apps: vec![bucket.app.clone()],
};
for name in ["invoices", "customers", "line_items"] {
s.models.insert(
name.to_string(),
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![ColumnSchema {
check: None,
comment: None,
default_sql: Some("heerid_next_desc()".to_string()),
foreign_key: None,
generated: None,
identity: None,
index_type: None,
indexed: false,
max_length: None,
name: "id".to_string(),
nullable: false,
on_delete: None,
outbox_exclude: false,
rationale: None,
relation_kind: None,
renamed_from: None,
sequence_within: None,
sql_type: "BIGINT".to_string(),
unique: false,
type_change_using: None,
}],
exclusion_constraints: Vec::new(),
fts: None,
is_through: false,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerIdRecencyBiased,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
table: name.to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
},
);
}
s
}
let mut snapshots = BTreeMap::new();
snapshots.insert(old_bucket.clone(), three_tables(&old_bucket));
let mut models = BTreeMap::new();
models.insert(new_bucket.clone(), three_tables(&new_bucket));
let app = AppLifecycle {
label: "invoicing".to_string(),
database: "main".to_string(),
renamed_from: Some("billing".to_string()),
tombstone: false,
};
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: std::slice::from_ref(&app),
name: "rename invoicing",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("rename without --allow-destructive must succeed");
let dest = report
.composed_buckets
.iter()
.find(|c| c.bucket == new_bucket)
.expect("destination bucket composed");
let up = fs::read_to_string(&dest.up_sql_path).unwrap();
for name in ["invoices", "customers", "line_items"] {
let drop_text = format!("DROP TABLE \"{name}\"");
assert!(
!up.contains(&drop_text),
"rename must not emit {drop_text} (B-9): {up}"
);
}
assert!(up.contains("UPDATE djogi_schema_migrations"));
let _ = fs::remove_dir_all(&work);
}
#[test]
fn b11_folder_rename_collision_refuses_fail_fast() {
let work = temp_workspace("b11_collision");
let guard = lock_for(&work);
let old_bucket = BucketKey {
database: "main".into(),
app: "oldname".into(),
};
let new_bucket = BucketKey {
database: "main".into(),
app: "newname".into(),
};
let old_dir = bucket_dir(&work, &old_bucket);
let new_dir = bucket_dir(&work, &new_bucket);
fs::create_dir_all(&old_dir).unwrap();
fs::create_dir_all(&new_dir).unwrap();
fs::write(old_dir.join("V20260101010101__init.sdjql"), "from-old").unwrap();
fs::write(new_dir.join("V20260101010101__init.sdjql"), "from-new").unwrap();
let mut snapshots = BTreeMap::new();
snapshots.insert(old_bucket.clone(), snapshot_with_widgets(&old_bucket));
let mut models = BTreeMap::new();
models.insert(new_bucket.clone(), snapshot_with_widgets(&new_bucket));
let app = AppLifecycle {
label: "newname".to_string(),
database: "main".to_string(),
renamed_from: Some("oldname".to_string()),
tombstone: false,
};
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: std::slice::from_ref(&app),
name: "rename newname",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("collision must surface");
match err {
ComposeError::FolderRenameTargetCollision {
offending_entry, ..
} => {
assert_eq!(offending_entry, "V20260101010101__init.sdjql");
}
other => panic!("wrong variant: {other:?}"),
}
assert_eq!(
fs::read_to_string(old_dir.join("V20260101010101__init.sdjql")).unwrap(),
"from-old"
);
assert_eq!(
fs::read_to_string(new_dir.join("V20260101010101__init.sdjql")).unwrap(),
"from-new"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn b8_classify_bucket_routes_through_with_pending() {
use super::super::build_match::{classify_bucket, classify_bucket_with_pending};
use super::super::schema::SNAPSHOT_FORMAT_VERSION;
let bucket = BucketKey {
database: "main".into(),
app: "billing".into(),
};
let drifted = AppliedSchema {
djogi_version: "9.9.9".to_string(),
enums: BTreeMap::new(),
format_version: SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: "2026-04-25T00:00:00Z".to_string(),
indexes: Vec::new(),
models: BTreeMap::new(),
registered_apps: Vec::new(),
};
let synced = AppliedSchema {
djogi_version: "0.1.0".to_string(),
..drifted.clone()
};
let via_wrapper = classify_bucket(&bucket, Some(&drifted), Some(&drifted), Some(&synced))
.expect("must produce diagnostic");
let via_direct = classify_bucket_with_pending(
&bucket,
Some(&drifted),
Some(&drifted),
Some(&synced),
None,
)
.expect("must produce diagnostic");
assert_eq!(via_wrapper, via_direct);
}
#[test]
fn b11_pre_flight_pre_empts_mid_loop_rollback() {
{
let work = temp_workspace("b11_gap_file_file");
let guard = lock_for(&work);
let old_bucket = BucketKey {
database: "main".into(),
app: "oldname".into(),
};
let new_bucket = BucketKey {
database: "main".into(),
app: "newname".into(),
};
let old_dir = bucket_dir(&work, &old_bucket);
let new_dir = bucket_dir(&work, &new_bucket);
fs::create_dir_all(&old_dir).unwrap();
fs::create_dir_all(&new_dir).unwrap();
fs::write(old_dir.join("V20260101010101__a.sdjql"), "movable").unwrap();
fs::write(old_dir.join("V20260101010102__b.sdjql"), "from-old").unwrap();
fs::write(new_dir.join("V20260101010102__b.sdjql"), "from-new").unwrap();
let mut snapshots = BTreeMap::new();
snapshots.insert(old_bucket.clone(), snapshot_with_widgets(&old_bucket));
let mut models = BTreeMap::new();
models.insert(new_bucket.clone(), snapshot_with_widgets(&new_bucket));
let app = AppLifecycle {
label: "newname".to_string(),
database: "main".to_string(),
renamed_from: Some("oldname".to_string()),
tombstone: false,
};
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: std::slice::from_ref(&app),
name: "rename newname",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("collision must surface");
match err {
ComposeError::FolderRenameTargetCollision {
offending_entry, ..
} => {
assert_eq!(offending_entry, "V20260101010102__b.sdjql");
}
other => panic!("wrong variant (file-vs-file): {other:?}"),
}
assert!(
old_dir.join("V20260101010101__a.sdjql").exists(),
"movable entry must remain under OLD — pre-flight \
must pre-empt the entire merge loop"
);
assert!(
!new_dir.join("V20260101010101__a.sdjql").exists(),
"movable entry must NOT have been promoted into NEW"
);
let _ = fs::remove_dir_all(&work);
}
{
let work = temp_workspace("b11_gap_file_dir");
let guard = lock_for(&work);
let old_bucket = BucketKey {
database: "main".into(),
app: "oldname".into(),
};
let new_bucket = BucketKey {
database: "main".into(),
app: "newname".into(),
};
let old_dir = bucket_dir(&work, &old_bucket);
let new_dir = bucket_dir(&work, &new_bucket);
fs::create_dir_all(&old_dir).unwrap();
fs::create_dir_all(&new_dir).unwrap();
fs::write(old_dir.join("V20260101010101__init.sdjql"), "movable").unwrap();
fs::create_dir_all(new_dir.join("V20260101010101__init.sdjql")).unwrap();
fs::write(
new_dir.join("V20260101010101__init.sdjql").join("sentinel"),
b"keep",
)
.unwrap();
let mut snapshots = BTreeMap::new();
snapshots.insert(old_bucket.clone(), snapshot_with_widgets(&old_bucket));
let mut models = BTreeMap::new();
models.insert(new_bucket.clone(), snapshot_with_widgets(&new_bucket));
let app = AppLifecycle {
label: "newname".to_string(),
database: "main".to_string(),
renamed_from: Some("oldname".to_string()),
tombstone: false,
};
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: std::slice::from_ref(&app),
name: "rename newname",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 4, 25, 1, 2, 3),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("file-vs-dir collision must surface");
match err {
ComposeError::FolderRenameTargetCollision {
offending_entry, ..
} => {
assert_eq!(offending_entry, "V20260101010101__init.sdjql");
}
other => panic!("wrong variant (file-vs-dir): {other:?}"),
}
assert!(
new_dir
.join("V20260101010101__init.sdjql")
.join("sentinel")
.exists(),
"blocking directory's contents must be preserved"
);
let _ = fs::remove_dir_all(&work);
}
}
#[test]
fn b9_remap_relabels_registered_apps_field() {
let old_billing_bucket = BucketKey {
database: "main".into(),
app: "billing".into(),
};
let audit_bucket = BucketKey {
database: "main".into(),
app: "audit".into(),
};
let mut before_billing = empty_snapshot(&old_billing_bucket);
before_billing.registered_apps =
vec!["".to_string(), "billing".to_string(), "users".to_string()];
let mut before_audit = empty_snapshot(&audit_bucket);
before_audit.registered_apps = vec!["audit".to_string()];
let mut before: BTreeMap<BucketKey, AppliedSchema> = BTreeMap::new();
before.insert(old_billing_bucket.clone(), before_billing.clone());
before.insert(audit_bucket.clone(), before_audit.clone());
let apps = [AppLifecycle {
label: "invoicing".to_string(),
database: "main".to_string(),
renamed_from: Some("billing".to_string()),
tombstone: false,
}];
let after = remap_snapshots_for_renames(&before, &apps);
let new_billing_bucket = BucketKey {
database: "main".into(),
app: "invoicing".into(),
};
assert!(
after.contains_key(&new_billing_bucket),
"remap must produce the new bucket key (main, invoicing)"
);
assert!(
!after.contains_key(&old_billing_bucket),
"remap must drop the old bucket key (main, billing)"
);
let relabeled = &after[&new_billing_bucket];
assert!(
relabeled.registered_apps.iter().any(|s| s == "invoicing"),
"registered_apps must contain new label \"invoicing\": {:?}",
relabeled.registered_apps
);
assert!(
!relabeled.registered_apps.iter().any(|s| s == "billing"),
"registered_apps must drop old label \"billing\": {:?}",
relabeled.registered_apps
);
assert!(relabeled.registered_apps.iter().any(|s| s.is_empty()));
assert!(relabeled.registered_apps.iter().any(|s| s == "users"));
let after_audit = after.get(&audit_bucket).expect("audit untouched");
assert_eq!(*after_audit, before_audit);
}
#[test]
fn compose_up_down_prepends_numeric_array_helper_once_when_check_is_referenced() {
let delta = SchemaDelta {
bucket: BucketKey {
database: "main".into(),
app: "billing".into(),
},
operations: Vec::new(),
classification: Classification::Reversible,
};
let lowered = vec![OperationSql {
label: "add metric check".into(),
up: r#"ALTER TABLE "invoices" ADD CONSTRAINT "metrics_check" CHECK (djogi.__djogi_numeric_array_is_rust_decimal_v1("metrics"));"#
.into(),
down: r#"ALTER TABLE "invoices" DROP CONSTRAINT "metrics_check";"#
.into(),
lossy: None,
}];
let version = "V20260518__numeric_array_helper";
let up_sql = compose_up_text(version, &delta, &lowered);
let down_sql = compose_down_text(version, &delta, &lowered);
assert!(
up_sql.contains(NUMERIC_ARRAY_HELPER_PRELUDE),
"Up migration must include numeric helper prelude: {up_sql}"
);
assert!(
down_sql.contains(NUMERIC_ARRAY_HELPER_PRELUDE),
"Down migration must include numeric helper prelude: {down_sql}"
);
assert_eq!(
up_sql.matches("CREATE SCHEMA IF NOT EXISTS djogi;").count(),
1,
"Up migration must emit helper prelude once: {up_sql}"
);
assert_eq!(
down_sql
.matches("CREATE SCHEMA IF NOT EXISTS djogi;")
.count(),
1,
"Down migration must emit helper prelude once: {down_sql}"
);
assert!(
up_sql.find("CREATE SCHEMA IF NOT EXISTS djogi;").unwrap()
< up_sql.find("-- add metric check").unwrap(),
"Up migration prelude must appear before operations: {up_sql}"
);
assert!(
down_sql.find("CREATE SCHEMA IF NOT EXISTS djogi;").unwrap()
< down_sql.find("-- add metric check").unwrap(),
"Down migration prelude must appear before operations: {down_sql}"
);
assert!(
up_sql.contains("djogi.__djogi_numeric_array_is_rust_decimal_v1(\"metrics\")"),
"Up migration should reference helper: {up_sql}"
);
assert!(
!up_sql.contains("NOT EXISTS (SELECT 1 FROM unnest(\"metrics\")"),
"Numeric helper CHECK should not use subquery style: {up_sql}"
);
}
#[test]
fn compose_up_text_omits_numeric_array_helper_when_unreferenced() {
let delta = SchemaDelta {
bucket: BucketKey {
database: "main".into(),
app: "billing".into(),
},
operations: Vec::new(),
classification: Classification::Reversible,
};
let lowered = vec![OperationSql {
label: "add integer col".into(),
up: r#"ALTER TABLE "accounts" ADD COLUMN "score" integer CHECK ("score" >= 0);"#.into(),
down: r#"ALTER TABLE "accounts" DROP COLUMN "score";"#.into(),
lossy: None,
}];
let version = "V20260518__no_numeric_array_helper";
let up_sql = compose_up_text(version, &delta, &lowered);
let down_sql = compose_down_text(version, &delta, &lowered);
assert!(
!up_sql.contains("CREATE SCHEMA IF NOT EXISTS djogi;"),
"Up migration without helper references must not include prelude: {up_sql}"
);
assert!(
!down_sql.contains("CREATE SCHEMA IF NOT EXISTS djogi;"),
"Down migration without helper references must not include prelude: {down_sql}"
);
}
#[test]
fn compose_numeric_array_helper_prelude_uses_only_valid_schema_qualified_identifiers() {
let expected_identifiers = [
"numeric", "bool", "bool_and", "scale", "abs", "power", "numeric", "unnest",
];
assert_helper_prelude_uses_input_array_argument(
NUMERIC_ARRAY_HELPER_PRELUDE,
"__djogi_numeric_array_is_rust_decimal_v1",
"numeric",
);
assert_helper_prelude_uses_pg_catalog_bool_return_type(NUMERIC_ARRAY_HELPER_PRELUDE);
let found_identifiers = pg_catalog_identifiers(NUMERIC_ARRAY_HELPER_PRELUDE);
for id in &found_identifiers {
assert!(
expected_identifiers.contains(&id.as_str()),
"Unexpected schema qualification in helper prelude: pg_catalog.{id}"
);
}
assert!(
!found_identifiers.iter().any(|id| *id == "coalesce"),
"COALESCE is conditional-expression syntax and must not be schema-qualified: {NUMERIC_ARRAY_HELPER_PRELUDE}"
);
assert!(
NUMERIC_ARRAY_HELPER_PRELUDE.contains("SELECT COALESCE("),
"Helper body should use PostgreSQL conditional-expression syntax: COALESCE"
);
}
#[tokio::test]
#[allow(clippy::disallowed_methods)]
async fn compose_numeric_array_helper_prelude_applies_in_postgres_when_database_url_present() {
use std::env;
let database_url = match env::var("DATABASE_URL") {
Ok(database_url) if !database_url.is_empty() => database_url,
_ => return,
};
let (mut client, connection) =
tokio_postgres::connect(&database_url, tokio_postgres::NoTls)
.await
.unwrap();
let connection = tokio::spawn(async move {
if let Err(e) = connection.await {
panic!("Postgres connection task failed: {e}");
}
});
let tx = client.transaction().await.unwrap();
tx.batch_execute(NUMERIC_ARRAY_HELPER_PRELUDE)
.await
.unwrap();
let row = tx
.query_one(
"SELECT djogi.__djogi_numeric_array_is_rust_decimal_v1(ARRAY[1::numeric, 2::numeric]::numeric[])",
&[],
)
.await
.unwrap();
assert!(row.get::<_, bool>(0));
tx.rollback().await.unwrap();
drop(client);
connection.await.unwrap();
}
#[test]
fn compose_up_down_prepends_date_array_helper_when_check_is_referenced() {
let delta = SchemaDelta {
bucket: BucketKey {
database: "main".into(),
app: "scheduling".into(),
},
operations: Vec::new(),
classification: Classification::Reversible,
};
let lowered = vec![OperationSql {
label: "add blackout dates check".into(),
up: r#"ALTER TABLE "calendars" ADD CONSTRAINT "blackout_dates_check" CHECK (djogi.__djogi_date_array_is_finite_v1("blackout_dates"));"#
.into(),
down: r#"ALTER TABLE "calendars" DROP CONSTRAINT "blackout_dates_check";"#
.into(),
lossy: None,
}];
let version = "V20260518__date_array_helper";
let up_sql = compose_up_text(version, &delta, &lowered);
let down_sql = compose_down_text(version, &delta, &lowered);
assert!(
up_sql.contains(DATE_ARRAY_HELPER_PRELUDE),
"Up migration must include date-array helper prelude: {up_sql}"
);
assert!(
down_sql.contains(DATE_ARRAY_HELPER_PRELUDE),
"Down migration must include date-array helper prelude: {down_sql}"
);
assert!(
up_sql.find("CREATE SCHEMA IF NOT EXISTS djogi;").unwrap()
< up_sql.find("-- add blackout dates check").unwrap(),
"Date-array prelude must appear before operations in up migration: {up_sql}"
);
assert!(
!up_sql.contains("djogi.__djogi_numeric_array_is_rust_decimal_v1("),
"Date-array migration must not inject unneeded numeric helper: {up_sql}"
);
assert!(
!up_sql.contains("djogi.__djogi_tstz_array_is_finite_v1("),
"Date-array migration must not inject unneeded tstz helper: {up_sql}"
);
}
#[test]
fn compose_up_down_prepends_tstz_array_helper_when_check_is_referenced() {
let delta = SchemaDelta {
bucket: BucketKey {
database: "main".into(),
app: "events".into(),
},
operations: Vec::new(),
classification: Classification::Reversible,
};
let lowered = vec![OperationSql {
label: "add scheduled slots check".into(),
up: r#"ALTER TABLE "sessions" ADD CONSTRAINT "slots_check" CHECK (djogi.__djogi_tstz_array_is_finite_v1("slots"));"#
.into(),
down: r#"ALTER TABLE "sessions" DROP CONSTRAINT "slots_check";"#.into(),
lossy: None,
}];
let version = "V20260518__tstz_array_helper";
let up_sql = compose_up_text(version, &delta, &lowered);
let down_sql = compose_down_text(version, &delta, &lowered);
assert!(
up_sql.contains(TSTZ_ARRAY_HELPER_PRELUDE),
"Up migration must include tstz-array helper prelude: {up_sql}"
);
assert!(
down_sql.contains(TSTZ_ARRAY_HELPER_PRELUDE),
"Down migration must include tstz-array helper prelude: {down_sql}"
);
assert!(
up_sql.find("CREATE SCHEMA IF NOT EXISTS djogi;").unwrap()
< up_sql.find("-- add scheduled slots check").unwrap(),
"Tstz-array prelude must appear before operations in up migration: {up_sql}"
);
assert!(
!up_sql.contains("djogi.__djogi_numeric_array_is_rust_decimal_v1("),
"Tstz-array migration must not inject unneeded numeric helper: {up_sql}"
);
assert!(
!up_sql.contains("djogi.__djogi_date_array_is_finite_v1("),
"Tstz-array migration must not inject unneeded date-array helper: {up_sql}"
);
}
#[test]
fn compose_date_array_helper_prelude_uses_only_valid_schema_qualified_identifiers() {
let expected_identifiers = ["date", "bool", "isfinite", "bool_and", "unnest"];
assert_helper_prelude_uses_input_array_argument(
DATE_ARRAY_HELPER_PRELUDE,
"__djogi_date_array_is_finite_v1",
"date",
);
assert_helper_prelude_uses_pg_catalog_bool_return_type(DATE_ARRAY_HELPER_PRELUDE);
let found_identifiers = pg_catalog_identifiers(DATE_ARRAY_HELPER_PRELUDE);
for id in &found_identifiers {
assert!(
expected_identifiers.contains(&id.as_str()),
"Unexpected schema qualification in date-array helper: pg_catalog.{id}"
);
}
assert!(
!found_identifiers.iter().any(|id| *id == "coalesce"),
"COALESCE must not be schema-qualified: {DATE_ARRAY_HELPER_PRELUDE}"
);
assert!(
DATE_ARRAY_HELPER_PRELUDE.contains("SELECT COALESCE("),
"Date-array helper body should use unqualified COALESCE: {DATE_ARRAY_HELPER_PRELUDE}"
);
assert!(
DATE_ARRAY_HELPER_PRELUDE.contains("pg_catalog.isfinite(value)"),
"Date-array helper must guard against both ±infinity via isfinite: \
{DATE_ARRAY_HELPER_PRELUDE}"
);
assert!(
DATE_ARRAY_HELPER_PRELUDE.contains("'9999-12-31'::pg_catalog.date"),
"Date-array helper must cap at time::Date MAX (9999-12-31): {DATE_ARRAY_HELPER_PRELUDE}"
);
}
#[test]
fn compose_tstz_array_helper_prelude_uses_only_valid_schema_qualified_identifiers() {
let expected_identifiers = ["timestamptz", "bool", "isfinite", "bool_and", "unnest"];
assert_helper_prelude_uses_input_array_argument(
TSTZ_ARRAY_HELPER_PRELUDE,
"__djogi_tstz_array_is_finite_v1",
"timestamptz",
);
assert_helper_prelude_uses_pg_catalog_bool_return_type(TSTZ_ARRAY_HELPER_PRELUDE);
let found_identifiers = pg_catalog_identifiers(TSTZ_ARRAY_HELPER_PRELUDE);
for id in &found_identifiers {
assert!(
expected_identifiers.contains(&id.as_str()),
"Unexpected schema qualification in tstz-array helper: pg_catalog.{id}"
);
}
assert!(
!found_identifiers.iter().any(|id| *id == "coalesce"),
"COALESCE must not be schema-qualified: {TSTZ_ARRAY_HELPER_PRELUDE}"
);
assert!(
TSTZ_ARRAY_HELPER_PRELUDE.contains("SELECT COALESCE("),
"Tstz-array helper body should use unqualified COALESCE: {TSTZ_ARRAY_HELPER_PRELUDE}"
);
assert!(
TSTZ_ARRAY_HELPER_PRELUDE.contains("pg_catalog.isfinite(value)"),
"Tstz-array helper must guard against both ±infinity via isfinite: \
{TSTZ_ARRAY_HELPER_PRELUDE}"
);
assert!(
TSTZ_ARRAY_HELPER_PRELUDE
.contains("'9999-12-31 23:59:59.999999+00'::pg_catalog.timestamptz"),
"Tstz-array helper must cap at time::OffsetDateTime MAX (UTC): {TSTZ_ARRAY_HELPER_PRELUDE}"
);
}
#[tokio::test]
#[allow(clippy::disallowed_methods)]
async fn compose_date_array_helper_prelude_applies_in_postgres_when_database_url_present() {
use std::env;
let database_url = match env::var("DATABASE_URL") {
Ok(database_url) if !database_url.is_empty() => database_url,
_ => return,
};
let (mut client, connection) =
tokio_postgres::connect(&database_url, tokio_postgres::NoTls)
.await
.unwrap();
let connection = tokio::spawn(async move {
if let Err(e) = connection.await {
panic!("Postgres connection task failed: {e}");
}
});
let tx = client.transaction().await.unwrap();
tx.batch_execute(DATE_ARRAY_HELPER_PRELUDE).await.unwrap();
let row = tx
.query_one(
"SELECT djogi.__djogi_date_array_is_finite_v1(ARRAY['2026-05-18'::date, '2000-01-01'::date]::date[])",
&[],
)
.await
.unwrap();
assert!(
row.get::<_, bool>(0),
"finite date array must pass the helper check"
);
let row = tx
.query_one(
"SELECT djogi.__djogi_date_array_is_finite_v1(ARRAY['infinity'::date]::date[])",
&[],
)
.await
.unwrap();
assert!(
!row.get::<_, bool>(0),
"date array containing +infinity must fail the helper check"
);
let row = tx
.query_one(
"SELECT djogi.__djogi_date_array_is_finite_v1(ARRAY['-infinity'::date]::date[])",
&[],
)
.await
.unwrap();
assert!(
!row.get::<_, bool>(0),
"date array containing -infinity must fail the helper check"
);
let row = tx
.query_one(
"SELECT djogi.__djogi_date_array_is_finite_v1(ARRAY[]::date[])",
&[],
)
.await
.unwrap();
assert!(
row.get::<_, bool>(0),
"empty date array must pass the helper check"
);
tx.rollback().await.unwrap();
drop(client);
connection.await.unwrap();
}
#[tokio::test]
#[allow(clippy::disallowed_methods)]
async fn compose_tstz_array_helper_prelude_applies_in_postgres_when_database_url_present() {
use std::env;
let database_url = match env::var("DATABASE_URL") {
Ok(database_url) if !database_url.is_empty() => database_url,
_ => return,
};
let (mut client, connection) =
tokio_postgres::connect(&database_url, tokio_postgres::NoTls)
.await
.unwrap();
let connection = tokio::spawn(async move {
if let Err(e) = connection.await {
panic!("Postgres connection task failed: {e}");
}
});
let tx = client.transaction().await.unwrap();
tx.batch_execute(TSTZ_ARRAY_HELPER_PRELUDE).await.unwrap();
let row = tx
.query_one(
"SELECT djogi.__djogi_tstz_array_is_finite_v1(ARRAY['2026-05-18 00:00:00+00'::timestamptz, '2000-01-01 12:00:00+00'::timestamptz]::timestamptz[])",
&[],
)
.await
.unwrap();
assert!(
row.get::<_, bool>(0),
"finite timestamptz array must pass the helper check"
);
let row = tx
.query_one(
"SELECT djogi.__djogi_tstz_array_is_finite_v1(ARRAY['infinity'::timestamptz]::timestamptz[])",
&[],
)
.await
.unwrap();
assert!(
!row.get::<_, bool>(0),
"timestamptz array containing +infinity must fail the helper check"
);
let row = tx
.query_one(
"SELECT djogi.__djogi_tstz_array_is_finite_v1(ARRAY['-infinity'::timestamptz]::timestamptz[])",
&[],
)
.await
.unwrap();
assert!(
!row.get::<_, bool>(0),
"timestamptz array containing -infinity must fail the helper check"
);
let row = tx
.query_one(
"SELECT djogi.__djogi_tstz_array_is_finite_v1(ARRAY[]::timestamptz[])",
&[],
)
.await
.unwrap();
assert!(
row.get::<_, bool>(0),
"empty timestamptz array must pass the helper check"
);
tx.rollback().await.unwrap();
drop(client);
connection.await.unwrap();
}
fn assert_helper_prelude_uses_input_array_argument(
prelude: &str,
function_name: &str,
pg_type: &str,
) {
let expected_signature = format!(
"CREATE OR REPLACE FUNCTION djogi.{function_name}(input_array pg_catalog.{pg_type}[])"
);
assert!(
prelude.contains(&expected_signature),
"Helper prelude must use a non-keyword input_array argument in its signature: {prelude}"
);
assert!(
prelude.contains("FROM pg_catalog.unnest(input_array) AS value(value);"),
"Helper body must reference the renamed input_array argument: {prelude}"
);
let rejected_signature = format!("{function_name}(values pg_catalog.{pg_type}[])");
assert!(
!prelude.contains(&rejected_signature),
"Helper prelude must not use PostgreSQL keyword `values` as an argument: {prelude}"
);
assert!(
!prelude.contains("pg_catalog.unnest(values)"),
"Helper body must not reference the rejected `values` argument: {prelude}"
);
}
#[test]
fn compose_up_header_contains_apply_via_cli_warning() {
let version = "V20260425010203__add_users";
let bucket = BucketKey {
database: "main".to_string(),
app: "myapp".to_string(),
};
let delta = SchemaDelta {
bucket,
operations: vec![],
classification: Classification::Additive,
};
let lowered: Vec<OperationSql> = vec![];
let text = compose_up_text(version, &delta, &lowered);
assert!(
text.contains("Apply via `djogi migrations apply`"),
"up header must contain apply-via-CLI warning: {text}"
);
assert!(
text.contains("not psql"),
"up header must name psql as the bypass path: {text}"
);
assert!(
text.contains("ledger recording"),
"up header must mention ledger: {text}"
);
assert!(
text.contains("-- DO NOT EDIT"),
"up header must still contain DO NOT EDIT line: {text}"
);
let apply_pos = text.find("Apply via `djogi migrations apply`").unwrap();
let dont_edit_pos = text.find("-- DO NOT EDIT").unwrap();
assert!(
apply_pos < dont_edit_pos,
"apply warning ({apply_pos}) must precede DO NOT EDIT ({dont_edit_pos})"
);
}
#[test]
fn compose_down_header_contains_apply_via_cli_warning() {
let version = "V20260425010203__add_users";
let bucket = BucketKey {
database: "main".to_string(),
app: "myapp".to_string(),
};
let delta = SchemaDelta {
bucket,
operations: vec![],
classification: Classification::Additive,
};
let lowered: Vec<OperationSql> = vec![];
let text = compose_down_text(version, &delta, &lowered);
assert!(
text.contains("Apply via `djogi migrations apply`"),
"down header must contain apply-via-CLI warning: {text}"
);
assert!(
text.contains("-- DO NOT EDIT"),
"down header must still contain DO NOT EDIT line: {text}"
);
}
fn assert_helper_prelude_uses_pg_catalog_bool_return_type(prelude: &str) {
assert!(
prelude.contains("\nRETURNS pg_catalog.bool\n"),
"Helper prelude must use PostgreSQL's schema-qualified bool type: {prelude}"
);
assert!(
!prelude.contains("RETURNS pg_catalog.boolean"),
"Helper prelude must not use PostgreSQL's unqualified-only boolean alias with a schema: {prelude}"
);
}
fn pg_catalog_identifiers(sql: &str) -> Vec<String> {
const PREFIX: &str = "pg_catalog.";
let mut ids = Vec::new();
let mut cursor = 0usize;
while let Some(offset) = sql[cursor..].find(PREFIX) {
let ident_start = cursor + offset + PREFIX.len();
let rest = &sql[ident_start..];
let mut len = 0usize;
for b in rest.bytes() {
if len == 0 {
if !(b.is_ascii_alphabetic() || b == b'_') {
break;
}
} else if !(b.is_ascii_alphanumeric() || b == b'_') {
break;
}
len += 1;
}
if len > 0 {
ids.push(rest[..len].to_string());
}
cursor = ident_start + len.max(1);
}
ids
}
}