#![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::snapshot::serialize_snapshot;
use super::sql::{OperationSql, lower_delta};
use super::target::{bucket_dir, pending_database_dir, pending_json_path, snapshot_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)]
#[non_exhaustive]
pub enum ComposeError {
NothingToCompose,
TombstonedAppRequiresAllowDestructive {
app_label: String,
database: String,
text: String,
},
LinkageDropWithoutModels {
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,
},
PendingJsonWouldBeOverwritten { path: PathBuf, text: String },
FolderRenameTargetCollision {
from: PathBuf,
to: PathBuf,
offending_entry: String,
},
Diff(super::diff::DiffError),
PhaseZeroAutoEmit(super::bootstrap::AutoEmitError),
CrossBucketForeignKeyCycle {
database: String,
chain: Vec<String>,
},
}
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::LinkageDropWithoutModels { 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::PendingJsonWouldBeOverwritten { 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}"),
Self::CrossBucketForeignKeyCycle { database, chain } => {
write!(
f,
"cross-app foreign keys in database `{database}` form a dependency cycle \
between apps: {chain}. No slice apply order can satisfy the cycle. Move \
the mutually-referencing models into one app, or remove one direction of \
the reference",
chain = chain.join(", ")
)
}
}
}
}
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>,
pub converged_snapshot_buckets: Vec<BucketKey>,
}
#[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 depends_on: Vec<String>,
}
pub const PENDING_FORMAT_VERSION: &str = "2";
#[derive(Debug)]
pub enum PendingLoadError {
Parse {
path: Option<PathBuf>,
source: serde_json::Error,
},
UnsupportedFormatVersion {
found: String,
expected: &'static str,
path: Option<PathBuf>,
},
}
fn format_version_recovery_hint(found: &str, expected: &str) -> &'static str {
match (found.parse::<u64>().ok(), expected.parse::<u64>().ok()) {
(Some(f), Some(e)) if f < e => {
"re-run 'djogi migrations compose' to regenerate this pending file"
}
(Some(f), Some(e)) if f > e => {
"upgrade to a newer version of djogi (or check out a newer revision)"
}
_ => "upgrade or check out a newer djogi",
}
}
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,
} => {
let hint = format_version_recovery_hint(found, expected);
match path {
Some(p) => write!(
f,
"pending JSON format version '{found}' at {} is not supported by this Djogi (expected '{expected}'); {hint}",
p.display()
),
None => write!(
f,
"pending JSON format version '{found}' is not supported by this Djogi (expected '{expected}'); {hint}"
),
}
}
}
}
}
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()))
}
fn table_to_bucket_index(
models: &std::collections::BTreeMap<BucketKey, AppliedSchema>,
) -> std::collections::BTreeMap<(String, String), BucketKey> {
let mut idx = std::collections::BTreeMap::new();
for (bucket, schema) in models {
for table_name in schema.models.keys() {
idx.insert(
(bucket.database.clone(), table_name.clone()),
bucket.clone(),
);
}
}
idx
}
fn fk_target_tables(op: &SchemaOperation) -> Vec<String> {
let mut out = Vec::new();
match op {
SchemaOperation::AddTable(t) => {
for col in &t.columns {
if let Some(fk) = &col.foreign_key {
out.push(fk.ref_table.clone());
}
}
}
SchemaOperation::AddColumn { column, .. } => {
if let Some(fk) = &column.foreign_key {
out.push(fk.ref_table.clone());
}
}
SchemaOperation::AddForeignKey { fk, .. } => {
out.push(fk.ref_table.clone());
}
SchemaOperation::DropTable { .. } => {}
SchemaOperation::RenameTable { .. } => {}
SchemaOperation::DropColumn { .. } => {}
SchemaOperation::RenameColumn { .. } => {}
SchemaOperation::AlterColumn { .. } => {}
SchemaOperation::DropForeignKey { .. } => {}
SchemaOperation::AddIndex(_) => {}
SchemaOperation::DropIndex(_) => {}
SchemaOperation::AddExclusionConstraint { .. } => {}
SchemaOperation::DropExclusionConstraint { .. } => {}
SchemaOperation::AddEnum(_) => {}
SchemaOperation::DropEnum(_) => {}
SchemaOperation::AddEnumVariant { .. } => {}
SchemaOperation::PkTypeFlip { .. } => {}
SchemaOperation::PkTypeFlipGroup(_) => {}
SchemaOperation::PkTypeFlipMultiGroup(_) => {}
SchemaOperation::RenameApp { .. } => {}
SchemaOperation::MoveModelBetweenApps { .. } => {}
SchemaOperation::SetTableComment { .. } => {}
SchemaOperation::SetStorageParams { .. } => {}
SchemaOperation::SetTablespace { .. } => {}
SchemaOperation::Unsupported { .. } => {}
}
out
}
fn reconcile_enum_ops_across_buckets(
deltas: &mut [SchemaDelta],
models: &std::collections::BTreeMap<BucketKey, AppliedSchema>,
snapshots: &std::collections::BTreeMap<BucketKey, AppliedSchema>,
fk_cross_deps: &std::collections::BTreeMap<BucketKey, std::collections::BTreeSet<String>>,
) -> Result<std::collections::BTreeMap<BucketKey, std::collections::BTreeSet<String>>, ComposeError>
{
use std::collections::{BTreeMap as BM, BTreeSet as BS};
#[derive(Clone, Debug)]
struct RemoveOp {
idx: usize,
enum_name: String,
op_kind: OpKind,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum OpKind {
AddEnum,
DropEnum,
AddEnumVariant { variant: String },
}
let mut db_context: BM<String, (Vec<usize>, DBContext)> = BM::new();
for delta in deltas.iter() {
db_context
.entry(delta.bucket.database.clone())
.or_insert_with(|| {
(
Vec::new(),
DBContext {
projected_enums: BM::new(),
snapshot_enums: BM::new(),
add_enum_ops: BM::new(),
drop_enum_ops: BM::new(),
add_enum_variant_ops: BM::new(),
},
)
});
}
for (bucket, schema) in models {
if let Some((_, ctx)) = db_context.get_mut(&bucket.database) {
for name in schema.enums.keys() {
ctx.projected_enums
.entry(bucket.clone())
.or_default()
.insert(name.clone());
}
}
}
for (bucket, schema) in snapshots {
if let Some((_, ctx)) = db_context.get_mut(&bucket.database) {
for name in schema.enums.keys() {
ctx.snapshot_enums
.entry(bucket.clone())
.or_default()
.insert(name.clone());
}
}
}
for (i, delta) in deltas.iter().enumerate() {
let db = delta.bucket.database.clone();
let (indices, ctx) = db_context.entry(db).or_insert_with(|| {
(
Vec::new(),
DBContext {
projected_enums: BM::new(),
snapshot_enums: BM::new(),
add_enum_ops: BM::new(),
drop_enum_ops: BM::new(),
add_enum_variant_ops: BM::new(),
},
)
});
indices.push(i);
for op in &delta.operations {
match op {
SchemaOperation::AddEnum(e) => {
ctx.add_enum_ops
.insert((delta.bucket.clone(), e.name.clone()), i);
}
SchemaOperation::DropEnum(name) => {
ctx.drop_enum_ops
.entry((delta.bucket.clone(), name.clone()))
.or_default()
.push(i);
}
SchemaOperation::AddEnumVariant {
enum_name, variant, ..
} => {
ctx.add_enum_variant_ops
.entry((enum_name.clone(), variant.clone()))
.or_default()
.push((delta.bucket.clone(), i));
}
_ => {}
}
}
}
let mut removes: Vec<RemoveOp> = Vec::new();
let mut enum_edges: BM<BucketKey, BS<String>> = BM::new();
for (db_name, (_indices, ctx)) in &db_context {
let mut all_enum_names: BS<String> = BS::new();
for (_, name) in ctx.add_enum_ops.keys() {
all_enum_names.insert(name.clone());
}
for (_, name) in ctx.drop_enum_ops.keys() {
all_enum_names.insert(name.clone());
}
let all_projected_apps: BS<String> = ctx
.add_enum_ops
.keys()
.chain(ctx.drop_enum_ops.keys())
.map(|(bk, _)| bk.app.clone())
.chain(
ctx.add_enum_variant_ops
.values()
.flat_map(|v| v.iter())
.map(|(bk, _)| bk.app.clone()),
)
.collect();
let db_deps: BM<BucketKey, BS<String>> = fk_cross_deps
.iter()
.filter(|(bk, _)| bk.database == *db_name)
.map(|(bk, deps)| (bk.clone(), deps.clone()))
.collect();
let topo_order = if all_projected_apps.len() > 1 {
order_buckets(db_name, &all_projected_apps, &db_deps)?
} else {
all_projected_apps.into_iter().collect()
};
for enum_name in &all_enum_names {
let mut adders: Vec<(BucketKey, usize)> = ctx
.add_enum_ops
.iter()
.filter(|((_, name), _)| name == enum_name)
.map(|((bucket, _), idx)| (bucket.clone(), *idx))
.collect();
if adders.is_empty() {
continue;
}
adders.sort_by_key(|(bk, _)| topo_order.iter().position(|a| a == &bk.app).unwrap_or(0));
let has_snapshot_owner = adders.iter().any(|(adder_bucket, _)| {
ctx.snapshot_enums.iter().any(|(snap_bucket, snap_set)| {
snap_bucket != adder_bucket && snap_set.contains(enum_name.as_str())
})
});
if has_snapshot_owner {
for (_bucket, idx) in &adders {
removes.push(RemoveOp {
idx: *idx,
enum_name: enum_name.clone(),
op_kind: OpKind::AddEnum,
});
}
} else {
let (owner_bucket, _owner_idx) = &adders[0];
for (bucket, idx) in &adders[1..] {
removes.push(RemoveOp {
idx: *idx,
enum_name: enum_name.clone(),
op_kind: OpKind::AddEnum,
});
enum_edges
.entry(bucket.clone())
.or_default()
.insert(owner_bucket.app.clone());
}
}
}
for enum_name in &all_enum_names {
let mut droppers: Vec<(BucketKey, usize)> = ctx
.drop_enum_ops
.iter()
.filter(|((_, name), _)| name == enum_name)
.flat_map(|((bucket, _), idxs)| idxs.iter().map(|&idx| (bucket.clone(), idx)))
.collect();
if droppers.is_empty() {
continue;
}
let any_projected = ctx
.projected_enums
.values()
.any(|en| en.contains(enum_name.as_str()));
if any_projected {
for (_bucket, idx) in &droppers {
removes.push(RemoveOp {
idx: *idx,
enum_name: enum_name.clone(),
op_kind: OpKind::DropEnum,
});
}
} else {
droppers.sort_by_key(|(bk, _)| {
topo_order.iter().position(|a| a == &bk.app).unwrap_or(0)
});
for (_bucket, idx) in &droppers[..droppers.len() - 1] {
removes.push(RemoveOp {
idx: *idx,
enum_name: enum_name.clone(),
op_kind: OpKind::DropEnum,
});
}
}
}
for ((enum_name, variant), contributors) in &ctx.add_enum_variant_ops {
let owner_bucket: Option<BucketKey> = ctx
.add_enum_ops
.iter()
.find(|((_, name), _)| name == enum_name)
.map(|((bucket, _), _)| bucket.clone())
.or_else(|| {
ctx.projected_enums
.iter()
.find(|(_, names)| names.contains(enum_name.as_str()))
.map(|(bucket, _)| bucket.clone())
});
let mut adders = contributors.clone();
adders.sort_by_key(|(bk, _)| topo_order.iter().position(|a| a == &bk.app).unwrap_or(0));
let variant_owner = adders.first().map(|(bk, _)| bk.clone());
for (bucket, idx) in &adders {
if variant_owner.as_ref() != Some(bucket) {
removes.push(RemoveOp {
idx: *idx,
enum_name: enum_name.clone(),
op_kind: OpKind::AddEnumVariant {
variant: variant.clone(),
},
});
}
if let Some(ref ob) = owner_bucket
&& bucket != ob
{
enum_edges
.entry(bucket.clone())
.or_default()
.insert(ob.app.clone());
}
}
}
}
for rm in &removes {
match &rm.op_kind {
OpKind::AddEnum => {
deltas[rm.idx].operations.retain(
|op| !matches!(op, SchemaOperation::AddEnum(e) if e.name == rm.enum_name),
);
}
OpKind::DropEnum => {
deltas[rm.idx]
.operations
.retain(|op| !matches!(op, SchemaOperation::DropEnum(n) if n == &rm.enum_name));
}
OpKind::AddEnumVariant { variant } => {
deltas[rm.idx].operations.retain(|op| {
!matches!(
op,
SchemaOperation::AddEnumVariant { enum_name, variant: v, .. }
if enum_name == &rm.enum_name && v == variant
)
});
}
}
}
Ok(enum_edges)
}
#[derive(Default)]
struct DBContext {
projected_enums: std::collections::BTreeMap<BucketKey, std::collections::BTreeSet<String>>,
snapshot_enums: std::collections::BTreeMap<BucketKey, std::collections::BTreeSet<String>>,
add_enum_ops: std::collections::BTreeMap<(BucketKey, String), usize>,
drop_enum_ops: std::collections::BTreeMap<(BucketKey, String), Vec<usize>>,
add_enum_variant_ops: std::collections::BTreeMap<(String, String), Vec<(BucketKey, usize)>>,
}
fn cross_bucket_dependencies(
deltas: &[SchemaDelta],
index: &std::collections::BTreeMap<(String, String), BucketKey>,
) -> std::collections::BTreeMap<BucketKey, std::collections::BTreeSet<String>> {
let mut deps: std::collections::BTreeMap<BucketKey, std::collections::BTreeSet<String>> =
std::collections::BTreeMap::new();
for delta in deltas {
let entry = deps.entry(delta.bucket.clone()).or_default();
for op in &delta.operations {
for target in fk_target_tables(op) {
let key = (delta.bucket.database.clone(), target);
if let Some(owner) = index.get(&key) {
if *owner != delta.bucket {
entry.insert(owner.app.clone());
}
}
}
}
}
deps
}
fn order_buckets(
database: &str,
buckets: &std::collections::BTreeSet<String>,
deps: &std::collections::BTreeMap<BucketKey, std::collections::BTreeSet<String>>,
) -> Result<Vec<String>, ComposeError> {
let mut in_degree: std::collections::BTreeMap<&str, usize> =
buckets.iter().map(|b| (b.as_str(), 0)).collect();
let mut rev: std::collections::BTreeMap<&str, Vec<&str>> = std::collections::BTreeMap::new();
for app in buckets {
let key = BucketKey {
database: database.to_string(),
app: app.clone(),
};
if let Some(targets) = deps.get(&key) {
for dep in targets {
if buckets.contains(dep) {
*in_degree.get_mut(app.as_str()).expect("seeded") += 1;
rev.entry(dep.as_str()).or_default().push(app.as_str());
}
}
}
}
let mut ready: std::collections::BTreeSet<&str> = in_degree
.iter()
.filter(|(_, d)| **d == 0)
.map(|(b, _)| *b)
.collect();
let mut out = Vec::with_capacity(buckets.len());
while let Some(&next) = ready.iter().next() {
ready.remove(next);
out.push(next.to_string());
for &dependent in rev.get(next).map(Vec::as_slice).unwrap_or(&[]) {
let d = in_degree.get_mut(dependent).expect("seeded");
*d -= 1;
if *d == 0 {
ready.insert(dependent);
}
}
}
if out.len() != buckets.len() {
let chain: Vec<String> = in_degree
.iter()
.filter(|(_, d)| **d > 0)
.map(|(b, _)| (*b).to_string())
.collect();
return Err(ComposeError::CrossBucketForeignKeyCycle {
database: database.to_string(),
chain,
});
}
Ok(out)
}
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);
{
use std::collections::BTreeSet;
let tombstoned: BTreeSet<(&str, &str)> = req
.apps
.iter()
.filter(|a| a.tombstone)
.map(|a| (a.database.as_str(), a.label.as_str()))
.collect();
let app_has_models = |database: &str, app: &str| -> bool {
let bucket = BucketKey {
database: database.to_string(),
app: app.to_string(),
};
req.models
.get(&bucket)
.is_some_and(|s| !s.models.is_empty())
};
for (bucket, snap) in snapshots_for_diff.iter() {
if snap.models.is_empty() {
continue; }
let database = bucket.database.as_str();
let app = bucket.app.as_str();
if tombstoned.contains(&(database, app)) {
continue; }
if app_has_models(database, app) {
continue; }
let display_label = super::target::app_dirname(app);
let text = format!(
"app \"{display_label}\" was previously registered (database \"{database}\") \
but no models for it are linked now — did you forget to link its crate? \
Refusing to emit DROPs. If this removal is intentional, mark the app \
`#[app(tombstone)]`."
);
return Err(ComposeError::LinkageDropWithoutModels {
app_label: app.to_string(),
database: database.to_string(),
text,
});
}
}
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,
converged_snapshot_buckets: Vec::new(),
});
}
return Err(ComposeError::NothingToCompose);
}
let bucket_index = table_to_bucket_index(req.models);
let mut cross_deps = cross_bucket_dependencies(&effective, &bucket_index);
let buckets_with_ops_pre_reconcile: std::collections::BTreeSet<BucketKey> = effective
.iter()
.filter(|d| !d.operations.is_empty())
.map(|d| d.bucket.clone())
.collect();
let enum_edges =
reconcile_enum_ops_across_buckets(&mut effective, req.models, req.snapshots, &cross_deps)?;
for (bucket, deps) in &enum_edges {
cross_deps
.entry(bucket.clone())
.or_default()
.extend(deps.iter().cloned());
}
for delta in &mut effective {
delta.classification = super::diff::classify_operations(&delta.operations);
}
effective
.retain(|d| !d.operations.is_empty() || !matches!(d.classification, Classification::NoOp));
let converged_snapshot_buckets: Vec<BucketKey> = {
let effective_buckets_post_retain: std::collections::BTreeSet<&BucketKey> =
effective.iter().map(|d| &d.bucket).collect();
buckets_with_ops_pre_reconcile
.into_iter()
.filter(|bk| !effective_buckets_post_retain.contains(bk))
.collect()
};
for bucket in &converged_snapshot_buckets {
let snap_path = snapshot_path(req.workspace_root, bucket);
let current_snap = req
.models
.get(bucket)
.cloned()
.unwrap_or_else(|| empty_schema_for(bucket));
let snap_bytes = serialize_snapshot(¤t_snap).map_err(|e| ComposeError::Io {
path: snap_path.clone(),
source: std::io::Error::other(e.to_string()),
})?;
ensure_parent(&snap_path)?;
let snap_tmp = atomic_write(&snap_path, &snap_bytes)?;
let snap_backup = promote_tmp_with_backup(&snap_tmp, &snap_path)?;
if let Some(bak) = snap_backup {
let _ = fs::remove_file(bak);
}
}
if effective.is_empty() {
if !emitted_phase_zero.is_empty() {
return Ok(ComposeReport {
composed_buckets: Vec::new(),
emitted_phase_zero,
converged_snapshot_buckets,
});
}
if !converged_snapshot_buckets.is_empty() {
return Ok(ComposeReport {
composed_buckets: Vec::new(),
emitted_phase_zero,
converged_snapshot_buckets,
});
}
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 mut db_buckets: std::collections::BTreeMap<&str, std::collections::BTreeSet<String>> =
std::collections::BTreeMap::new();
for delta in &effective {
db_buckets
.entry(delta.bucket.database.as_str())
.or_default()
.insert(delta.bucket.app.clone());
}
for (database, buckets) in &db_buckets {
order_buckets(database, buckets, &cross_deps)?;
}
let effective_apps: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> = {
let mut map: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
std::collections::BTreeMap::new();
for delta in &effective {
map.entry(delta.bucket.database.clone())
.or_default()
.insert(delta.bucket.app.clone());
}
map
};
let mut depends_on_map: std::collections::BTreeMap<BucketKey, Vec<String>> = cross_deps
.iter()
.map(|(key, targets)| {
(
key.clone(),
effective_apps
.get(key.database.as_str())
.map(|apps| targets.intersection(apps).cloned().collect())
.unwrap_or_default(),
)
})
.collect();
for (bucket, deps) in &enum_edges {
let mut target_set: std::collections::BTreeSet<String> = depends_on_map
.get(bucket)
.cloned()
.unwrap_or_default()
.into_iter()
.collect();
target_set.extend(deps.iter().cloned());
depends_on_map.insert(bucket.clone(), target_set.into_iter().collect());
}
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(),
depends_on: depends_on_map
.get(&delta.bucket)
.cloned()
.unwrap_or_default(),
};
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,
)?;
}
check_pending_path_compatible(&pending_path, &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,
converged_snapshot_buckets,
})
}
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 check_pending_path_compatible(
pending_path: &Path,
bucket: &BucketKey,
) -> Result<(), ComposeError> {
if !pending_path.exists() {
return Ok(());
}
let pending = load_pending(pending_path).map_err(|e| ComposeError::PendingJsonWouldBeOverwritten {
path: pending_path.to_path_buf(),
text: format!(
"pending JSON would be overwritten at {}: existing file is not a compatible pending artifact ({e})",
pending_path.display()
),
})?;
let same_bucket =
pending.bucket_database == bucket.database && pending.bucket_app == bucket.app;
let is_legacy_phase_zero =
bucket.app.is_empty() && pending.version == super::bootstrap::PHASE_ZERO_VERSION;
if same_bucket && !is_legacy_phase_zero {
return Ok(());
}
Err(ComposeError::PendingJsonWouldBeOverwritten {
path: pending_path.to_path_buf(),
text: format!(
"pending JSON would be overwritten at {}: existing file belongs to a different pending authority",
pending_path.display()
),
})
}
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, EnumSchema, 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,
codec: 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,
codec: 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()),
codec: None,
..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()),
codec: None,
..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()),
codec: None,
..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 fallback_plan_round_trips_rendered_helper_prelude_files() {
let work = temp_workspace("numeric-array-helper-fallback");
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-fallback-round-trip",
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 composed = &report.composed_buckets[0];
let pending: PendingPlan =
serde_json::from_slice(&fs::read(&composed.pending_json_path).unwrap())
.expect("parse pending");
let up_sql = fs::read_to_string(&composed.up_sql_path).unwrap();
let down_sql = fs::read_to_string(&composed.down_sql_path).unwrap();
assert!(
up_sql.contains(NUMERIC_ARRAY_HELPER_PRELUDE),
"fixture must carry the injected helper prelude: {up_sql}"
);
let built = crate::migrate::canonical_fallback_replay_plan(
&bucket,
&pending.version,
&up_sql,
&down_sql,
)
.expect("rendered helper-prelude files must build a fallback plan");
assert_eq!(built.checksum_up, pending.checksum_up);
assert_eq!(built.checksum_down, pending.checksum_down);
assert!(built.checksum_down.is_some());
let rehash = compute_checksum(
built
.plan
.segments
.iter()
.flat_map(|segment| segment.statements.iter())
.map(|statement| statement.up.as_str()),
);
assert_eq!(rehash, pending.checksum_up);
assert_eq!(
built.plan.segments[0].statements[0].up,
NUMERIC_ARRAY_HELPER_PRELUDE
);
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 snap = snapshot_with_widgets(&bucket);
snap.models.insert(
"gadgets".to_string(),
TableSchema {
app: None,
columns: vec![id_column_heerid_desc()],
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: "gadgets".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
},
);
let mut models = BTreeMap::new();
let mut model_snap = snapshot_with_widgets(&bucket);
model_snap.models.remove("widgets");
model_snap.models.insert(
"gadgets".to_string(),
TableSchema {
app: None,
columns: vec![id_column_heerid_desc()],
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: "gadgets".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
},
);
models.insert(bucket.clone(), model_snap);
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), snap);
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 snap = snapshot_with_widgets(&bucket);
snap.models.insert(
"gadgets".to_string(),
TableSchema {
app: None,
columns: vec![id_column_heerid_desc()],
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: "gadgets".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
},
);
let mut models = BTreeMap::new();
let mut model_snap = snapshot_with_widgets(&bucket);
model_snap.models.remove("widgets");
model_snap.models.insert(
"gadgets".to_string(),
TableSchema {
app: None,
columns: vec![id_column_heerid_desc()],
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: "gadgets".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
},
);
models.insert(bucket.clone(), model_snap);
let mut snapshots = BTreeMap::new();
snapshots.insert(bucket.clone(), snap);
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,
codec: 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(),
depends_on: Vec::new(),
};
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": "3",
"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_v3": "garbage"
}"#;
let err = parse_pending_bytes(blob.as_bytes(), None).expect_err("must fail");
match err {
PendingLoadError::UnsupportedFormatVersion {
found, expected, ..
} => {
assert_eq!(found, "3");
assert_eq!(expected, PENDING_FORMAT_VERSION);
}
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(),
depends_on: Vec::new(),
};
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 format_one_pending_rejected_with_version_mismatch() {
let blob = r#"{
"format_version": "1",
"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": ["billing"]
},
"checksum_up": "V1:0000000000000000000000000000000000000000000000000000000000000000",
"checksum_down": null,
"composed_at": "2026-04-25T01:02:03Z"
}"#;
let err =
parse_pending_bytes(blob.as_bytes(), None).expect_err("old format must be rejected");
assert!(
matches!(err, PendingLoadError::UnsupportedFormatVersion { .. }),
"expected the actionable upgrade error, got {err:?}"
);
}
#[test]
fn unsupported_format_version_display_stale_says_recompose() {
let err = PendingLoadError::UnsupportedFormatVersion {
found: "1".to_string(),
expected: PENDING_FORMAT_VERSION,
path: None,
};
let msg = err.to_string();
assert!(
msg.ends_with("; re-run 'djogi migrations compose' to regenerate this pending file"),
"stale must end with '; <recompose phrase>': {msg}"
);
}
#[test]
fn unsupported_format_version_display_future_says_upgrade() {
let err = PendingLoadError::UnsupportedFormatVersion {
found: "3".to_string(),
expected: PENDING_FORMAT_VERSION,
path: Some(std::path::PathBuf::from("migrations/main/_global_/V.json")),
};
let msg = err.to_string();
assert!(
msg.ends_with("; upgrade to a newer version of djogi (or check out a newer revision)"),
"future must end with '; <upgrade phrase>': {msg}"
);
assert!(
msg.contains("migrations/main/_global_/V.json"),
"path arm must still name the file: {msg}"
);
}
#[test]
fn unsupported_format_version_display_non_numeric_fallback() {
let err = PendingLoadError::UnsupportedFormatVersion {
found: "v2-beta".to_string(),
expected: PENDING_FORMAT_VERSION,
path: None,
};
let msg = err.to_string();
assert!(
msg.ends_with("; upgrade or check out a newer djogi"),
"non-numeric must end with '; <fallback phrase>': {msg}"
);
}
#[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 replay_plan_path = committed_replay_plan_path(&work, &bucket, &version);
fs::create_dir_all(&replay_plan_path).unwrap();
fs::write(replay_plan_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("replay-plan 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,
codec: 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}: {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
}
#[test]
fn linkage_drop_guard_fires_with_allow_destructive() {
let work = temp_workspace("linkage_fire");
let guard = lock_for(&work);
let billing_bucket = BucketKey {
database: "main".to_string(),
app: "billing".to_string(),
};
let mut snapshots = BTreeMap::new();
snapshots.insert(
billing_bucket.clone(),
snapshot_with_widgets(&billing_bucket),
);
let models: BTreeMap<BucketKey, AppliedSchema> = BTreeMap::new();
let apps = vec![AppLifecycle {
label: "billing".to_string(),
database: "main".to_string(),
renamed_from: None,
tombstone: false,
}];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "drop_billing",
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 err =
compose(req).expect_err("linkage guard must refuse even with --allow-destructive");
assert!(
matches!(err, ComposeError::LinkageDropWithoutModels { ref app_label, .. } if app_label == "billing"),
"expected LinkageDropWithoutModels for billing, got: {err}"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn linkage_drop_guard_allows_tombstoned_app_removal() {
let work = temp_workspace("linkage_tombstone");
let guard = lock_for(&work);
let billing_bucket = BucketKey {
database: "main".to_string(),
app: "billing".to_string(),
};
let mut snapshots = BTreeMap::new();
snapshots.insert(
billing_bucket.clone(),
snapshot_with_widgets(&billing_bucket),
);
let models: BTreeMap<BucketKey, AppliedSchema> = BTreeMap::new();
let apps = vec![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: &apps,
name: "drop_billing_tombstone",
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,
};
match compose(req) {
Err(ComposeError::LinkageDropWithoutModels { .. }) => {
panic!("tombstoned removal must not trip the linkage guard")
}
_ => {
}
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn linkage_drop_guard_does_not_fire_for_renamed_app() {
let work = temp_workspace("linkage_rename");
let guard = lock_for(&work);
let old_bucket = BucketKey {
database: "main".to_string(),
app: "accounts".to_string(),
};
let new_bucket = BucketKey {
database: "main".to_string(),
app: "ledger".to_string(),
};
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 apps = vec![AppLifecycle {
label: "ledger".to_string(),
database: "main".to_string(),
renamed_from: Some("accounts".to_string()),
tombstone: false,
}];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "rename_ledger",
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,
};
match compose(req) {
Err(ComposeError::LinkageDropWithoutModels { .. }) => {
panic!("renamed app must not trip the linkage guard")
}
_ => {
}
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn linkage_drop_guard_fires_for_emptied_global_bucket() {
let work = temp_workspace("linkage_global");
let guard = lock_for(&work);
let global_bucket = BucketKey {
database: "main".to_string(),
app: String::new(),
};
let mut snapshots = BTreeMap::new();
snapshots.insert(global_bucket.clone(), snapshot_with_widgets(&global_bucket));
let models: BTreeMap<BucketKey, AppliedSchema> = BTreeMap::new();
let apps = vec![AppLifecycle {
label: String::new(),
database: "main".to_string(),
renamed_from: None,
tombstone: false,
}];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "drop_global",
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 err = compose(req).expect_err("global bucket guard must fire");
assert!(
matches!(err, ComposeError::LinkageDropWithoutModels { ref app_label, .. } if app_label.is_empty()),
"expected LinkageDropWithoutModels for global bucket, got: {err}"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn linkage_drop_guard_fires_when_app_absent_from_registry() {
let work = temp_workspace("linkage_absent");
let guard = lock_for(&work);
let orphan_bucket = BucketKey {
database: "main".to_string(),
app: "orphan".to_string(),
};
let mut snapshots = BTreeMap::new();
snapshots.insert(orphan_bucket.clone(), snapshot_with_widgets(&orphan_bucket));
let models: BTreeMap<BucketKey, AppliedSchema> = BTreeMap::new();
let apps: Vec<AppLifecycle> = vec![];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "drop_orphan",
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 err = compose(req).expect_err("guard must fire even when app absent from req.apps");
assert!(
matches!(err, ComposeError::LinkageDropWithoutModels { ref app_label, .. } if app_label == "orphan"),
"expected LinkageDropWithoutModels for orphan, got: {err}"
);
let _ = fs::remove_dir_all(&work);
}
fn fk_to_users() -> crate::migrate::schema::ForeignKeySchema {
use crate::migrate::schema::{ForeignKeySchema, OnDeleteSchema};
ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
}
}
fn col_with_fk_to_users() -> ColumnSchema {
ColumnSchema {
name: "user_id".to_string(),
sql_type: "BIGINT".to_string(),
nullable: false,
foreign_key: Some(fk_to_users()),
..col("user_id", "BIGINT", false)
}
}
fn table_event_log_with_fk(bucket: &BucketKey) -> TableSchema {
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![id_column_heerid_desc(), col_with_fk_to_users()],
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: "event_log".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
fn table_users(bucket: &BucketKey) -> TableSchema {
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![id_column_heerid_desc()],
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: "users".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
fn snapshot_with_enum(bucket: &BucketKey, name: &str, variants: &[&str]) -> AppliedSchema {
let mut s = empty_snapshot(bucket);
s.enums.insert(
name.to_string(),
EnumSchema {
name: name.to_string(),
variants: variants.iter().map(|s| s.to_string()).collect(),
},
);
s
}
fn table_with_enum_col(
bucket: &BucketKey,
table: &str,
col_name: &str,
enum_type: &str,
) -> TableSchema {
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![
id_column_heerid_desc(),
ColumnSchema {
name: col_name.to_string(),
sql_type: enum_type.to_string(),
nullable: true,
foreign_key: None,
..col(col_name, enum_type, true)
},
],
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: table.to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
fn read_written_pending(report: &ComposeReport, database: &str, app: &str) -> PendingPlan {
let bucket = BucketKey {
database: database.to_string(),
app: app.to_string(),
};
let cb = report
.composed_buckets
.iter()
.find(|c| c.bucket == bucket)
.unwrap_or_else(|| panic!("composed bucket for {database}/{app}"));
let bytes = fs::read(&cb.pending_json_path)
.unwrap_or_else(|_| panic!("read pending for {database}/{app}"));
parse_pending_bytes(&bytes, Some(cb.pending_json_path.clone()))
.unwrap_or_else(|e| panic!("parse pending for {database}/{app}: {e}"))
}
#[test]
fn cross_bucket_fk_records_depends_on() {
let work = temp_workspace("two-bucket-fk");
let guard = lock_for(&work);
let users_bucket = BucketKey {
database: "main".into(),
app: "users".into(),
};
let system_bucket = BucketKey {
database: "main".into(),
app: "system".into(),
};
let mut models = BTreeMap::new();
{
let mut users_schema = empty_snapshot(&users_bucket);
users_schema
.models
.insert("users".to_string(), table_users(&users_bucket));
models.insert(users_bucket.clone(), users_schema);
}
{
let mut system_schema = empty_snapshot(&system_bucket);
system_schema.models.insert(
"event_log".to_string(),
table_event_log_with_fk(&system_bucket),
);
models.insert(system_bucket.clone(), system_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(users_bucket.clone(), empty_snapshot(&users_bucket));
snapshots.insert(system_bucket.clone(), empty_snapshot(&system_bucket));
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "users".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "system".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "cross-bucket-fk",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("composes");
let system = read_written_pending(&report, "main", "system");
let users = read_written_pending(&report, "main", "users");
assert_eq!(
system.depends_on,
vec!["users".to_string()],
"system should depend on users (cross-bucket FK)"
);
assert!(
users.depends_on.is_empty(),
"users should have no dependencies"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn fk_target_tables_add_table_with_inline_fk() {
use crate::migrate::diff::SchemaOperation;
let bucket = BucketKey {
database: "main".into(),
app: "system".into(),
};
let op = SchemaOperation::AddTable(table_event_log_with_fk(&bucket));
let targets = fk_target_tables(&op);
assert_eq!(targets, vec!["users".to_string()]);
}
#[test]
fn fk_target_tables_add_foreign_key() {
use crate::migrate::diff::SchemaOperation;
let op = SchemaOperation::AddForeignKey {
table: "orders".to_string(),
column: "user_id".to_string(),
fk: fk_to_users(),
};
let targets = fk_target_tables(&op);
assert_eq!(targets, vec!["users".to_string()]);
}
#[test]
fn fk_target_tables_add_column_with_fk() {
use crate::migrate::diff::SchemaOperation;
let col = col_with_fk_to_users();
let op = SchemaOperation::AddColumn {
table: "orders".to_string(),
column: col,
};
let targets = fk_target_tables(&op);
assert_eq!(targets, vec!["users".to_string()]);
}
#[test]
fn fk_target_tables_drop_table_returns_empty() {
use crate::migrate::diff::SchemaOperation;
let op = SchemaOperation::DropTable("widgets".to_string());
assert!(fk_target_tables(&op).is_empty());
}
#[test]
fn order_buckets_acyclic_two_bucket_order() {
use std::collections::{BTreeMap, BTreeSet};
let _users_bucket = BucketKey {
database: "main".into(),
app: "users".into(),
};
let system_bucket = BucketKey {
database: "main".into(),
app: "system".into(),
};
let mut deps = BTreeMap::new();
let mut system_deps = BTreeSet::new();
system_deps.insert("users".to_string());
deps.insert(system_bucket, system_deps);
let buckets: BTreeSet<String> = BTreeSet::from_iter(vec!["users".into(), "system".into()]);
let order = order_buckets("main", &buckets, &deps).expect("no cycle");
assert_eq!(order, vec!["users", "system"]);
}
#[test]
fn order_buckets_dependency_on_missing_bucket_is_ignored() {
use std::collections::{BTreeMap, BTreeSet};
let system_bucket = BucketKey {
database: "main".into(),
app: "system".into(),
};
let mut deps = BTreeMap::new();
let mut system_deps = BTreeSet::new();
system_deps.insert("billing".to_string());
deps.insert(system_bucket, system_deps);
let buckets: BTreeSet<String> = BTreeSet::from_iter(vec!["system".into()]);
let order = order_buckets("main", &buckets, &deps).expect("no cycle");
assert_eq!(order, vec!["system"]);
}
#[test]
fn order_buckets_cycle_returns_error() {
use std::collections::{BTreeMap, BTreeSet};
let a_bucket = BucketKey {
database: "main".into(),
app: "a".into(),
};
let b_bucket = BucketKey {
database: "main".into(),
app: "b".into(),
};
let mut deps = BTreeMap::new();
let mut a_deps = BTreeSet::new();
a_deps.insert("b".to_string());
deps.insert(a_bucket, a_deps);
let mut b_deps = BTreeSet::new();
b_deps.insert("a".to_string());
deps.insert(b_bucket, b_deps);
let buckets: BTreeSet<String> = BTreeSet::from_iter(vec!["a".into(), "b".into()]);
let err = order_buckets("main", &buckets, &deps).expect_err("cycle detected");
match err {
ComposeError::CrossBucketForeignKeyCycle { database, chain } => {
assert_eq!(database, "main");
assert!(chain.contains(&"a".to_string()));
assert!(chain.contains(&"b".to_string()));
}
_ => panic!("expected CrossBucketForeignKeyCycle, got: {err:?}"),
}
}
#[test]
fn cross_bucket_cycle_compose_refuses() {
let work = temp_workspace("cross-bucket-cycle");
let guard = lock_for(&work);
let a_bucket = BucketKey {
database: "main".into(),
app: "a".into(),
};
let b_bucket = BucketKey {
database: "main".into(),
app: "b".into(),
};
fn fk_to_table(table_name: &str) -> crate::migrate::schema::ForeignKeySchema {
use crate::migrate::schema::{ForeignKeySchema, OnDeleteSchema};
ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: table_name.to_string(),
}
}
fn col_fk(name: &str, target: &str) -> ColumnSchema {
ColumnSchema {
name: format!("{name}_id"),
sql_type: "BIGINT".to_string(),
nullable: false,
foreign_key: Some(fk_to_table(target)),
..col(&format!("{name}_id"), "BIGINT", false)
}
}
fn simple_table(
name: &str,
fk_col_name: &str,
fk_target: &str,
bucket: &BucketKey,
) -> TableSchema {
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![id_column_heerid_desc(), col_fk(fk_col_name, fk_target)],
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,
}
}
let mut models = BTreeMap::new();
{
let mut a_schema = empty_snapshot(&a_bucket);
a_schema.models.insert(
"table_a".to_string(),
simple_table("table_a", "b", "table_b", &a_bucket),
);
models.insert(a_bucket.clone(), a_schema);
}
{
let mut b_schema = empty_snapshot(&b_bucket);
b_schema.models.insert(
"table_b".to_string(),
simple_table("table_b", "a", "table_a", &b_bucket),
);
models.insert(b_bucket.clone(), b_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(a_bucket.clone(), empty_snapshot(&a_bucket));
snapshots.insert(b_bucket.clone(), empty_snapshot(&b_bucket));
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "a".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "b".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "cycle-test",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req).expect_err("cycle must be refused");
match err {
ComposeError::CrossBucketForeignKeyCycle { database, chain } => {
assert_eq!(database, "main");
assert!(chain.contains(&"a".to_string()));
assert!(chain.contains(&"b".to_string()));
}
_ => panic!("expected CrossBucketForeignKeyCycle, got: {err:?}"),
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn cross_bucket_fk_different_databases_is_filtered() {
let work = temp_workspace("cross-db-fk-filter");
let guard = lock_for(&work);
let users_bucket = BucketKey {
database: "main".into(),
app: "users".into(),
};
let events_bucket = BucketKey {
database: "analytics".into(),
app: "events".into(),
};
fn fk_to_users_table() -> crate::migrate::schema::ForeignKeySchema {
use crate::migrate::schema::{ForeignKeySchema, OnDeleteSchema};
ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
}
}
fn col_fk_user(_bucket: &BucketKey) -> ColumnSchema {
ColumnSchema {
name: "user_id".to_string(),
sql_type: "BIGINT".to_string(),
nullable: false,
foreign_key: Some(fk_to_users_table()),
..col("user_id", "BIGINT", false)
}
}
fn table_events(bucket: &BucketKey) -> TableSchema {
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![id_column_heerid_desc(), col_fk_user(bucket)],
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: "page_views".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
let mut models = BTreeMap::new();
{
let mut users_schema = empty_snapshot(&users_bucket);
users_schema
.models
.insert("users".to_string(), table_users(&users_bucket));
models.insert(users_bucket.clone(), users_schema);
}
{
let mut events_schema = empty_snapshot(&events_bucket);
events_schema
.models
.insert("page_views".to_string(), table_events(&events_bucket));
models.insert(events_bucket.clone(), events_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(users_bucket.clone(), empty_snapshot(&users_bucket));
snapshots.insert(events_bucket.clone(), empty_snapshot(&events_bucket));
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "users".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "events".into(),
database: "analytics".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "cross-db-fk",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("composes");
let events = read_written_pending(&report, "analytics", "events");
assert!(
events.depends_on.is_empty(),
"events should have no depends_on (cross-database FK to main/users is filtered out)"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn cross_bucket_fk_target_not_in_projection_is_ignored() {
let work = temp_workspace("fk-preexisting-target");
let guard = lock_for(&work);
let system_bucket = BucketKey {
database: "main".into(),
app: "system".into(),
};
fn table_orders(bucket: &BucketKey) -> TableSchema {
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![id_column_heerid_desc(), col_with_fk_to_users()],
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: "orders".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
let mut models = BTreeMap::new();
{
let mut system_schema = empty_snapshot(&system_bucket);
system_schema
.models
.insert("orders".to_string(), table_orders(&system_bucket));
models.insert(system_bucket.clone(), system_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(system_bucket.clone(), empty_snapshot(&system_bucket));
let apps: Vec<AppLifecycle> = vec![AppLifecycle {
label: "system".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
}];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "fk-preexisting",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("composes");
let system = read_written_pending(&report, "main", "system");
assert!(
system.depends_on.is_empty(),
"system should have no depends_on (FK target 'users' not in projection)"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn cross_bucket_fk_within_same_bucket_is_excluded() {
let work = temp_workspace("within-bucket-fk");
let guard = lock_for(&work);
let orders_bucket = BucketKey {
database: "main".into(),
app: "orders".into(),
};
fn fk_to_items() -> crate::migrate::schema::ForeignKeySchema {
use crate::migrate::schema::{ForeignKeySchema, OnDeleteSchema};
ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "items".to_string(),
}
}
fn col_item_id() -> ColumnSchema {
ColumnSchema {
name: "item_id".to_string(),
sql_type: "BIGINT".to_string(),
nullable: false,
foreign_key: Some(fk_to_items()),
..col("item_id", "BIGINT", false)
}
}
fn table_line_items(bucket: &BucketKey) -> TableSchema {
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![id_column_heerid_desc(), col_item_id()],
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: "line_items".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
fn table_items(bucket: &BucketKey) -> TableSchema {
TableSchema {
app: if bucket.app.is_empty() {
None
} else {
Some(bucket.app.clone())
},
columns: vec![id_column_heerid_desc()],
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: "items".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
let mut models = BTreeMap::new();
{
let mut orders_schema = empty_snapshot(&orders_bucket);
orders_schema
.models
.insert("line_items".to_string(), table_line_items(&orders_bucket));
orders_schema
.models
.insert("items".to_string(), table_items(&orders_bucket));
models.insert(orders_bucket.clone(), orders_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(orders_bucket.clone(), empty_snapshot(&orders_bucket));
let apps: Vec<AppLifecycle> = vec![AppLifecycle {
label: "orders".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
}];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "within-bucket-fk",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("composes");
let orders = read_written_pending(&report, "main", "orders");
assert!(
orders.depends_on.is_empty(),
"orders should have no depends_on (within-bucket FK is excluded)"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn shared_enum_creates_once_with_ownership_edge() {
let work = temp_workspace("shared-enum-create");
let guard = lock_for(&work);
let alpha_bucket = BucketKey {
database: "main".into(),
app: "alpha".into(),
};
let beta_bucket = BucketKey {
database: "main".into(),
app: "beta".into(),
};
let mood = EnumSchema {
name: "mood".to_string(),
variants: vec!["happy".to_string(), "sad".to_string()],
};
let mut models = BTreeMap::new();
{
let mut alpha_schema = empty_snapshot(&alpha_bucket);
alpha_schema.enums.insert("mood".to_string(), mood.clone());
alpha_schema.models.insert(
"posts".to_string(),
table_with_enum_col(&alpha_bucket, "posts", "mood", "mood"),
);
models.insert(alpha_bucket.clone(), alpha_schema);
}
{
let mut beta_schema = empty_snapshot(&beta_bucket);
beta_schema.enums.insert("mood".to_string(), mood.clone());
beta_schema.models.insert(
"comments".to_string(),
table_with_enum_col(&beta_bucket, "comments", "author_mood", "mood"),
);
models.insert(beta_bucket.clone(), beta_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(alpha_bucket.clone(), empty_snapshot(&alpha_bucket));
snapshots.insert(beta_bucket.clone(), empty_snapshot(&beta_bucket));
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "alpha".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "beta".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "shared-enum-create",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("composes");
let alpha_up = fs::read_to_string(
&report
.composed_buckets
.iter()
.find(|c| c.bucket == alpha_bucket)
.unwrap()
.up_sql_path,
)
.unwrap();
let beta_up = fs::read_to_string(
&report
.composed_buckets
.iter()
.find(|c| c.bucket == beta_bucket)
.unwrap()
.up_sql_path,
)
.unwrap();
let alpha_creates = alpha_up.contains("CREATE TYPE");
let beta_creates = beta_up.contains("CREATE TYPE");
assert!(
alpha_creates && !beta_creates,
"alpha should own CREATE TYPE; beta should not. alpha_up:\n{}\nbeta_up:\n{}",
alpha_up,
beta_up
);
let beta_pending = read_written_pending(&report, "main", "beta");
assert_eq!(
beta_pending.depends_on,
vec!["alpha".to_string()],
"beta should depend on alpha for enum ownership"
);
let beta_down = fs::read_to_string(
&report
.composed_buckets
.iter()
.find(|c| c.bucket == beta_bucket)
.unwrap()
.down_sql_path,
)
.unwrap();
assert!(
!beta_down.contains("DROP TYPE"),
"non-owner beta down-SQL must not DROP the shared enum type; beta_down:\n{beta_down}"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn shared_enum_variant_creates_once() {
use crate::migrate::diff::{self, SchemaOperation};
let alpha_bucket = BucketKey {
database: "main".into(),
app: "alpha".into(),
};
let beta_bucket = BucketKey {
database: "main".into(),
app: "beta".into(),
};
let mut models = BTreeMap::new();
models.insert(
alpha_bucket.clone(),
snapshot_with_enum(&alpha_bucket, "mood", &["happy", "sad", "excited"]),
);
models.insert(
beta_bucket.clone(),
snapshot_with_enum(&beta_bucket, "mood", &["happy", "sad", "excited"]),
);
let mut snapshots = BTreeMap::new();
snapshots.insert(
alpha_bucket.clone(),
snapshot_with_enum(&alpha_bucket, "mood", &["happy", "sad"]),
);
snapshots.insert(
beta_bucket.clone(),
snapshot_with_enum(&beta_bucket, "mood", &["happy", "sad"]),
);
let add_variant_op = SchemaOperation::AddEnumVariant {
enum_name: "mood".to_string(),
variant: "excited".to_string(),
anchor: None,
};
let mut deltas = vec![
SchemaDelta {
bucket: alpha_bucket.clone(),
operations: vec![add_variant_op.clone()],
classification: diff::classify_operations(std::slice::from_ref(&add_variant_op)),
},
SchemaDelta {
bucket: beta_bucket.clone(),
operations: vec![add_variant_op.clone()],
classification: diff::classify_operations(std::slice::from_ref(&add_variant_op)),
},
];
let fk_deps = BTreeMap::new();
let edges = reconcile_enum_ops_across_buckets(&mut deltas, &models, &snapshots, &fk_deps)
.expect("reconcile succeeds");
let surviving_count: usize = deltas
.iter()
.flat_map(|d| d.operations.iter())
.filter(|op| {
matches!(
op,
SchemaOperation::AddEnumVariant { enum_name, variant, .. }
if enum_name == "mood" && variant == "excited"
)
})
.count();
assert_eq!(
surviving_count, 1,
"exactly one AddEnumVariant(mood, excited) must survive; got {surviving_count}"
);
let alpha_has_variant = deltas[0].operations.iter().any(|op| {
matches!(
op,
SchemaOperation::AddEnumVariant { enum_name, variant, .. }
if enum_name == "mood" && variant == "excited"
)
});
let beta_has_variant = deltas[1].operations.iter().any(|op| {
matches!(
op,
SchemaOperation::AddEnumVariant { enum_name, variant, .. }
if enum_name == "mood" && variant == "excited"
)
});
assert!(
alpha_has_variant,
"alpha (topo-first) should retain AddEnumVariant(mood, excited)"
);
assert!(
!beta_has_variant,
"beta (non-owner) should have AddEnumVariant(mood, excited) removed"
);
let beta_deps = edges.get(&beta_bucket).cloned().unwrap_or_default();
assert!(
beta_deps.contains("alpha"),
"beta should depend on alpha for enum variant ownership; edges: {edges:?}"
);
}
#[test]
fn recompose_after_enum_dedup_is_noop() {
let work = temp_workspace("recompose-enum-noop");
let guard = lock_for(&work);
let alpha_bucket = BucketKey {
database: "main".into(),
app: "alpha".into(),
};
let beta_bucket = BucketKey {
database: "main".into(),
app: "beta".into(),
};
let mut models = BTreeMap::new();
{
let mut alpha_schema = empty_snapshot(&alpha_bucket);
alpha_schema.enums.insert(
"mood".to_string(),
EnumSchema {
name: "mood".to_string(),
variants: vec!["happy".to_string(), "sad".to_string()],
},
);
alpha_schema.models.insert(
"posts".to_string(),
table_with_enum_col(&alpha_bucket, "posts", "mood", "mood"),
);
models.insert(alpha_bucket.clone(), alpha_schema);
}
{
let mut beta_schema = empty_snapshot(&beta_bucket);
beta_schema.enums.insert(
"mood".to_string(),
EnumSchema {
name: "mood".to_string(),
variants: vec!["happy".to_string(), "sad".to_string()],
},
);
beta_schema.models.insert(
"comments".to_string(),
table_with_enum_col(&beta_bucket, "comments", "author_mood", "mood"),
);
models.insert(beta_bucket.clone(), beta_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(alpha_bucket.clone(), empty_snapshot(&alpha_bucket));
snapshots.insert(beta_bucket.clone(), empty_snapshot(&beta_bucket));
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "alpha".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "beta".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "recompose-enum-noop",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("first compose should succeed");
assert!(
!report.composed_buckets.is_empty(),
"first compose should have produced buckets"
);
let mut model_snapshots: BTreeMap<BucketKey, AppliedSchema> = BTreeMap::new();
for cb in &report.composed_buckets {
let pending = read_written_pending(&report, &cb.bucket.database, &cb.bucket.app);
model_snapshots.insert(cb.bucket.clone(), pending.model_snapshot);
}
let req2 = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &model_snapshots,
apps: &apps,
name: "recompose-enum-noop-2",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 1),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let err = compose(req2).expect_err("second compose should produce NothingToCompose");
assert!(
matches!(err, ComposeError::NothingToCompose),
"recompose after enum dedup should be a no-op, got: {err}"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn add_enum_suppressed_when_snapshot_already_has_it() {
let work = temp_workspace("enum-already-in-snapshot");
let guard = lock_for(&work);
let alpha_bucket = BucketKey {
database: "main".into(),
app: "alpha".into(),
};
let beta_bucket = BucketKey {
database: "main".into(),
app: "beta".into(),
};
let mood = EnumSchema {
name: "mood".to_string(),
variants: vec!["happy".to_string(), "sad".to_string()],
};
let mut models = BTreeMap::new();
{
let mut alpha_schema = empty_snapshot(&alpha_bucket);
alpha_schema.enums.insert("mood".to_string(), mood.clone());
models.insert(alpha_bucket.clone(), alpha_schema);
}
{
let mut beta_schema = empty_snapshot(&beta_bucket);
beta_schema.enums.insert("mood".to_string(), mood.clone());
models.insert(beta_bucket.clone(), beta_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(
alpha_bucket.clone(),
snapshot_with_enum(&alpha_bucket, "mood", &["happy", "sad"]),
);
snapshots.insert(beta_bucket.clone(), empty_snapshot(&beta_bucket));
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "alpha".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "beta".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "enum-already-in-snapshot",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let result = compose(req);
match result {
Ok(report) => {
for cb in &report.composed_buckets {
let up = fs::read_to_string(&cb.up_sql_path).unwrap();
assert!(
!up.contains("CREATE TYPE"),
"No CREATE TYPE expected when snapshot already has enum. Bucket {:?}:\n{}",
cb.bucket,
up
);
}
}
Err(ComposeError::NothingToCompose) => {
}
Err(e) => panic!("Unexpected error: {:?}", e),
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn drop_enum_deferred_until_last_referencing_bucket() {
let work = temp_workspace("defer-drop-enum");
let guard = lock_for(&work);
let alpha_bucket = BucketKey {
database: "main".into(),
app: "alpha".into(),
};
let beta_bucket = BucketKey {
database: "main".into(),
app: "beta".into(),
};
let mood = EnumSchema {
name: "mood".to_string(),
variants: vec!["happy".to_string(), "sad".to_string()],
};
let mut models = BTreeMap::new();
{
let mut alpha_schema = empty_snapshot(&alpha_bucket);
alpha_schema.enums.insert("mood".to_string(), mood.clone());
models.insert(alpha_bucket.clone(), alpha_schema);
}
{
let beta_schema = empty_snapshot(&beta_bucket);
models.insert(beta_bucket.clone(), beta_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(
alpha_bucket.clone(),
snapshot_with_enum(&alpha_bucket, "mood", &["happy", "sad"]),
);
snapshots.insert(
beta_bucket.clone(),
snapshot_with_enum(&beta_bucket, "mood", &["happy", "sad"]),
);
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "alpha".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "beta".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "defer-drop-enum",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let result = compose(req);
match result {
Ok(report) => {
for cb in &report.composed_buckets {
let up = fs::read_to_string(&cb.up_sql_path).unwrap();
assert!(
!up.contains("DROP TYPE"),
"No DROP TYPE expected when another bucket still references enum. Bucket {:?}:\n{}",
cb.bucket,
up
);
}
}
Err(ComposeError::NothingToCompose) => {
}
Err(e) => panic!("Unexpected error: {:?}", e),
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn last_bucket_drop_emits_drop_enum() {
let work = temp_workspace("last-bucket-drop-enum");
let guard = lock_for(&work);
let alpha_bucket = BucketKey {
database: "main".into(),
app: "alpha".into(),
};
let beta_bucket = BucketKey {
database: "main".into(),
app: "beta".into(),
};
let mut models = BTreeMap::new();
{
models.insert(alpha_bucket.clone(), empty_snapshot(&alpha_bucket));
}
{
models.insert(beta_bucket.clone(), empty_snapshot(&beta_bucket));
}
let mut snapshots = BTreeMap::new();
snapshots.insert(
alpha_bucket.clone(),
snapshot_with_enum(&alpha_bucket, "mood", &["happy", "sad"]),
);
snapshots.insert(
beta_bucket.clone(),
snapshot_with_enum(&beta_bucket, "mood", &["happy", "sad"]),
);
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "alpha".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "beta".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "last-bucket-drop-enum",
allow_destructive: true, force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("composes");
let alpha_present = report
.composed_buckets
.iter()
.any(|c| c.bucket == alpha_bucket);
assert!(
!alpha_present,
"alpha should not appear in composed_buckets after its DropEnum is deduplicated away"
);
let beta_up = fs::read_to_string(
&report
.composed_buckets
.iter()
.find(|c| c.bucket == beta_bucket)
.expect("beta should be in composed_buckets")
.up_sql_path,
)
.unwrap();
assert!(
beta_up.contains("DROP TYPE"),
"beta should emit DROP TYPE. beta_up:\n{}",
beta_up
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn add_enum_variant_orders_after_owner_via_depends_on() {
let work = temp_workspace("enum-variant-depends");
let guard = lock_for(&work);
let alpha_bucket = BucketKey {
database: "main".into(),
app: "alpha".into(),
};
let beta_bucket = BucketKey {
database: "main".into(),
app: "beta".into(),
};
let mood_base = EnumSchema {
name: "mood".to_string(),
variants: vec!["happy".to_string(), "sad".to_string()],
};
let mood_extended = EnumSchema {
name: "mood".to_string(),
variants: vec![
"happy".to_string(),
"sad".to_string(),
"excited".to_string(),
],
};
let mut models = BTreeMap::new();
{
let mut alpha_schema = empty_snapshot(&alpha_bucket);
alpha_schema
.enums
.insert("mood".to_string(), mood_base.clone());
models.insert(alpha_bucket.clone(), alpha_schema);
}
{
let mut beta_schema = empty_snapshot(&beta_bucket);
beta_schema
.enums
.insert("mood".to_string(), mood_extended.clone());
models.insert(beta_bucket.clone(), beta_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(alpha_bucket.clone(), empty_snapshot(&alpha_bucket));
snapshots.insert(
beta_bucket.clone(),
snapshot_with_enum(&beta_bucket, "mood", &["happy", "sad"]),
);
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "alpha".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "beta".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "enum-variant-depends",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("composes");
let beta_pending = read_written_pending(&report, "main", "beta");
assert!(
beta_pending.depends_on.contains(&"alpha".to_string()),
"beta should depend on alpha for enum ownership"
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn classification_rederived_after_drop_enum_suppression() {
let work = temp_workspace("reclassify-after-drop-enum");
let guard = lock_for(&work);
let alpha_bucket = BucketKey {
database: "main".into(),
app: "alpha".into(),
};
let beta_bucket = BucketKey {
database: "main".into(),
app: "beta".into(),
};
let mood = EnumSchema {
name: "mood".to_string(),
variants: vec!["happy".to_string(), "sad".to_string()],
};
let mut models = BTreeMap::new();
{
let mut alpha_schema = empty_snapshot(&alpha_bucket);
alpha_schema.enums.insert("mood".to_string(), mood.clone());
alpha_schema
.models
.insert("posts".to_string(), table_users(&alpha_bucket));
models.insert(alpha_bucket.clone(), alpha_schema);
}
{
let mut beta_schema = empty_snapshot(&beta_bucket);
beta_schema
.models
.insert("comments".to_string(), table_users(&beta_bucket));
models.insert(beta_bucket.clone(), beta_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(
alpha_bucket.clone(),
snapshot_with_enum(&alpha_bucket, "mood", &["happy", "sad"]),
);
{
let mut beta_snap = snapshot_with_enum(&beta_bucket, "mood", &["happy", "sad"]);
beta_snap
.models
.insert("comments".to_string(), table_users(&beta_bucket));
snapshots.insert(beta_bucket.clone(), beta_snap);
}
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "alpha".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "beta".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "reclassify-after-drop-enum",
allow_destructive: false, force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let result = compose(req);
match result {
Ok(_) => {
}
Err(ComposeError::NothingToCompose) => {
}
Err(ComposeError::DestructiveRequiresAllowDestructive { .. }) => {
panic!(
"DropEnum should have been suppressed and reclassified to non-destructive; \
compose failed with DestructiveRequiresAllowDestructive"
)
}
Err(e) => panic!("Unexpected error: {:?}", e),
}
let _ = fs::remove_dir_all(&work);
}
#[test]
fn enum_owner_follows_fk_order_not_alphabetical() {
let work = temp_workspace("enum-owner-fk-order");
let guard = lock_for(&work);
let alpha_bucket = BucketKey {
database: "main".into(),
app: "alpha".into(),
};
let beta_bucket = BucketKey {
database: "main".into(),
app: "beta".into(),
};
fn fk_to_beta_items() -> crate::migrate::schema::ForeignKeySchema {
use crate::migrate::schema::{ForeignKeySchema, OnDeleteSchema};
ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "items".to_string(),
}
}
fn col_item_id_with_fk() -> ColumnSchema {
ColumnSchema {
name: "item_id".to_string(),
sql_type: "BIGINT".to_string(),
nullable: false,
foreign_key: Some(fk_to_beta_items()),
..col("item_id", "BIGINT", false)
}
}
fn col_mood() -> ColumnSchema {
ColumnSchema {
name: "mood".to_string(),
sql_type: "mood".to_string(),
nullable: true,
foreign_key: None,
..col("mood", "mood", true)
}
}
fn alpha_posts(bucket: &BucketKey) -> TableSchema {
TableSchema {
app: Some(bucket.app.clone()),
columns: vec![id_column_heerid_desc(), col_item_id_with_fk(), col_mood()],
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: "posts".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
fn beta_items(bucket: &BucketKey) -> TableSchema {
TableSchema {
app: Some(bucket.app.clone()),
columns: vec![id_column_heerid_desc(), col_mood()],
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: "items".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
let mood = EnumSchema {
name: "mood".to_string(),
variants: vec!["happy".to_string(), "sad".to_string()],
};
let mut models = BTreeMap::new();
{
let mut alpha_schema = empty_snapshot(&alpha_bucket);
alpha_schema.enums.insert("mood".to_string(), mood.clone());
alpha_schema
.models
.insert("posts".to_string(), alpha_posts(&alpha_bucket));
models.insert(alpha_bucket.clone(), alpha_schema);
}
{
let mut beta_schema = empty_snapshot(&beta_bucket);
beta_schema.enums.insert("mood".to_string(), mood.clone());
beta_schema
.models
.insert("items".to_string(), beta_items(&beta_bucket));
models.insert(beta_bucket.clone(), beta_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(alpha_bucket.clone(), empty_snapshot(&alpha_bucket));
snapshots.insert(beta_bucket.clone(), empty_snapshot(&beta_bucket));
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "alpha".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "beta".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "enum-fk-order",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 10, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("compose should succeed without cycle error");
let beta_up = fs::read_to_string(
&report
.composed_buckets
.iter()
.find(|c| c.bucket == beta_bucket)
.expect("beta should be in composed_buckets")
.up_sql_path,
)
.unwrap();
assert!(
beta_up.contains("CREATE TYPE"),
"beta should emit CREATE TYPE (enum owner). beta_up:\n{}",
beta_up
);
let alpha_cb = report
.composed_buckets
.iter()
.find(|c| c.bucket == alpha_bucket)
.expect("alpha should be in composed_buckets");
let alpha_up = fs::read_to_string(&alpha_cb.up_sql_path).unwrap();
assert!(
!alpha_up.contains("CREATE TYPE"),
"alpha should NOT emit CREATE TYPE (deduplicated). alpha_up:\n{}",
alpha_up
);
let alpha_pending = read_written_pending(&report, "main", "alpha");
assert!(
alpha_pending.depends_on.contains(&"beta".to_string()),
"alpha should depend on beta (FK + enum ownership). depends_on: {:?}",
alpha_pending.depends_on
);
let _ = fs::remove_dir_all(&work);
}
#[test]
fn suppressed_drop_enum_writes_convergence_snapshot() {
let work = temp_workspace("converge-snapshot-drop-enum");
let guard = lock_for(&work);
let alpha_bucket = BucketKey {
database: "main".into(),
app: "alpha".into(),
};
let beta_bucket = BucketKey {
database: "main".into(),
app: "beta".into(),
};
let mood = EnumSchema {
name: "mood".to_string(),
variants: vec!["happy".to_string(), "sad".to_string()],
};
let mut models = BTreeMap::new();
{
let mut alpha_schema = empty_snapshot(&alpha_bucket);
alpha_schema.enums.insert("mood".to_string(), mood.clone());
alpha_schema.models.insert(
"posts".to_string(),
table_with_enum_col(&alpha_bucket, "posts", "mood", "mood"),
);
models.insert(alpha_bucket.clone(), alpha_schema);
}
{
let beta_schema = empty_snapshot(&beta_bucket);
models.insert(beta_bucket.clone(), beta_schema);
}
let mut snapshots = BTreeMap::new();
snapshots.insert(
alpha_bucket.clone(),
snapshot_with_enum(&alpha_bucket, "mood", &["happy", "sad"]),
);
snapshots.insert(
beta_bucket.clone(),
snapshot_with_enum(&beta_bucket, "mood", &["happy", "sad"]),
);
let apps: Vec<AppLifecycle> = vec![
AppLifecycle {
label: "alpha".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
AppLifecycle {
label: "beta".into(),
database: "main".into(),
renamed_from: None,
tombstone: false,
},
];
let req = ComposeRequest {
workspace_root: &work,
models: &models,
snapshots: &snapshots,
apps: &apps,
name: "converge-snapshot",
allow_destructive: false,
force_overwrite: false,
now: at(2026, 6, 11, 0, 0, 0),
_guard: &guard,
pk_flip_join_table_option: None,
skip_phase_zero_auto_emit: true,
};
let report = compose(req).expect("compose succeeds");
assert!(
report.converged_snapshot_buckets.contains(&beta_bucket),
"beta should be in converged_snapshot_buckets; got: {:?}",
report.converged_snapshot_buckets
);
let snap_path = crate::migrate::target::snapshot_path(&work, &beta_bucket);
assert!(
snap_path.exists(),
"beta schema_snapshot.json should exist after convergence: {snap_path:?}"
);
let snap_bytes = fs::read(&snap_path).unwrap();
let snap: crate::migrate::schema::AppliedSchema =
serde_json::from_slice(&snap_bytes).expect("valid snapshot JSON");
assert!(
!snap.enums.contains_key("mood"),
"beta convergence snapshot must not contain the mood enum; got: {:?}",
snap.enums.keys().collect::<Vec<_>>()
);
assert!(
!report
.composed_buckets
.iter()
.any(|cb| cb.bucket == beta_bucket),
"beta should NOT appear in composed_buckets (only snapshot was advanced)"
);
let _ = fs::remove_dir_all(&work);
}
}