use super::crossing::{
CrossingCheck, PendingCrossing, ReAnchor, acquisition_pairs, discover_re_anchors,
evaluate_crossing, run_eligible,
};
use anyhow::{Context, Result};
use std::collections::{BTreeMap, BTreeSet};
type AcquisitionPairs = BTreeSet<(Option<String>, Option<String>)>;
#[derive(Debug, Clone)]
pub enum SubscriptionSource {
Provision,
Adopt,
Crossing(u64),
}
impl SubscriptionSource {
pub fn as_db_string(&self) -> String {
match self {
Self::Provision => "provision".to_string(),
Self::Adopt => "adopt".to_string(),
Self::Crossing(version) => format!("crossing:{version}"),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Subscription {
pub modules: BTreeSet<String>,
}
fn render_subscription_set(modules: &BTreeSet<String>) -> String {
if modules.is_empty() {
"(base only)".to_string()
} else {
modules.iter().cloned().collect::<Vec<_>>().join(",")
}
}
pub fn parse_section_files(
migrations: &[crate::migration::ParsedMigration],
baselines_dir: &std::path::Path,
) -> Result<BTreeMap<(u64, bool), Vec<crate::migration::section_parser::MigrationSection>>> {
let mut files = BTreeMap::new();
for migration in migrations {
let sql = std::fs::read_to_string(&migration.path)?;
let sections = crate::migration::parse_migration_sections(&migration.path, &sql)?;
files.insert((migration.version, false), sections);
}
for baseline in crate::migration::discover_baselines(baselines_dir)? {
let sql = std::fs::read_to_string(&baseline.path)?;
let sections = crate::migration::parse_migration_sections(&baseline.path, &sql)?;
files.insert((baseline.version, true), sections);
}
Ok(files)
}
pub struct ModuleRuntime {
established: BTreeSet<String>,
re_anchors: Vec<ReAnchor>,
latest_baseline: Option<(u64, Vec<crate::migration::section_parser::MigrationSection>)>,
store: crate::migration_tracking::TrackingStore,
}
impl ModuleRuntime {
pub fn established(&self) -> &BTreeSet<String> {
&self.established
}
pub async fn load(
pool: &sqlx::PgPool,
tracking_table: &crate::config::types::TrackingTable,
baselines_dir: &std::path::Path,
) -> Result<Self> {
let store = crate::migration_tracking::TrackingStore::new(pool, tracking_table)?;
let stored = store.load_subscription().await?;
let re_anchors = discover_re_anchors(baselines_dir)?;
let latest_baseline = match crate::migration::discover_baselines(baselines_dir)?
.into_iter()
.next_back()
{
Some(baseline) => {
let sql = std::fs::read_to_string(&baseline.path)?;
let sections = crate::migration::parse_migration_sections(&baseline.path, &sql)?;
Some((baseline.version, sections))
}
None => None,
};
Ok(Self {
established: stored.modules,
re_anchors,
latest_baseline,
store,
})
}
pub fn adoption_baseline(
&self,
) -> Option<(u64, &[crate::migration::section_parser::MigrationSection])> {
self.latest_baseline
.as_ref()
.map(|(version, sections)| (*version, sections.as_slice()))
}
pub fn needing_baseline_content<'a, I: IntoIterator<Item = &'a String>>(
&self,
modules: I,
) -> Vec<String> {
let baseline_sections = self.adoption_baseline().map(|(_, s)| s).unwrap_or(&[]);
modules
.into_iter()
.filter(|m| !self.established.contains(*m))
.filter(|m| {
baseline_sections
.iter()
.any(|s| s.module.as_deref() == Some(m.as_str()))
})
.cloned()
.collect()
}
pub async fn forecast_crossings(
&self,
migrations: &[crate::migration::ParsedMigration],
) -> Result<BTreeSet<String>> {
let cursor = self.store.consumed_through_cursor().await?;
let re_anchor_versions: BTreeSet<u64> =
self.re_anchors.iter().map(|ra| ra.version).collect();
let mut acquirable_by_version: BTreeMap<u64, AcquisitionPairs> = BTreeMap::new();
for migration in migrations {
if re_anchor_versions.contains(&migration.version) {
let sql = std::fs::read_to_string(&migration.path)?;
let sections = crate::migration::parse_migration_sections(&migration.path, &sql)?;
acquirable_by_version.insert(migration.version, acquisition_pairs(§ions));
}
}
let empty = BTreeSet::new();
let mut simulated = self.established.clone();
for re_anchor in &self.re_anchors {
if cursor.is_some_and(|w| re_anchor.version <= w) {
continue;
}
let applied_sections = self
.store
.covered_baseline_section_names(re_anchor.version)
.await?;
let acquirable = acquirable_by_version
.get(&re_anchor.version)
.unwrap_or(&empty);
let will_run: BTreeSet<(Option<String>, Option<String>)> = acquirable
.iter()
.filter(|(module, _)| run_eligible(module, &simulated, &re_anchor.plain_modules))
.cloned()
.collect();
match evaluate_crossing(re_anchor, &simulated, &applied_sections, &will_run) {
CrossingCheck::Whole { rewritten } => simulated = rewritten,
CrossingCheck::Blocked { .. } => break,
}
}
Ok(simulated.difference(&self.established).cloned().collect())
}
pub async fn cross_re_anchors_through(&mut self, ceiling: Option<u64>) -> Result<()> {
if !self
.re_anchors
.iter()
.any(|ra| ceiling.is_none_or(|c| ra.version <= c))
{
return Ok(());
}
let cursor = self.store.consumed_through_cursor().await?;
for i in 0..self.re_anchors.len() {
let re_anchor = self.re_anchors[i].clone();
if cursor.is_some_and(|w| re_anchor.version <= w) {
continue; }
if ceiling.is_some_and(|c| re_anchor.version > c) {
break; }
let pending = self.gate_re_anchor(&re_anchor, &BTreeSet::new()).await?;
self.commit_crossing(pending).await?;
}
Ok(())
}
pub async fn gate_re_anchor_at(
&self,
version: u64,
acquirable: &BTreeSet<(Option<String>, Option<String>)>,
) -> Result<Option<PendingCrossing>> {
let Some(re_anchor) = self.re_anchors.iter().find(|ra| ra.version == version) else {
return Ok(None);
};
let cursor = self.store.consumed_through_cursor().await?;
if cursor.is_some_and(|w| re_anchor.version <= w) {
return Ok(None); }
Ok(Some(self.gate_re_anchor(re_anchor, acquirable).await?))
}
async fn gate_re_anchor(
&self,
re_anchor: &ReAnchor,
acquirable: &BTreeSet<(Option<String>, Option<String>)>,
) -> Result<PendingCrossing> {
let applied_sections = self
.store
.covered_baseline_section_names(re_anchor.version)
.await?;
let will_run: BTreeSet<(Option<String>, Option<String>)> = acquirable
.iter()
.filter(|(module, _)| run_eligible(module, &self.established, &re_anchor.plain_modules))
.cloned()
.collect();
match evaluate_crossing(re_anchor, &self.established, &applied_sections, &will_run) {
CrossingCheck::Blocked {
needs_adoption,
unsatisfiable,
} => {
let version = re_anchor.version;
if !needs_adoption.is_empty() {
let list = needs_adoption.iter().cloned().collect::<Vec<_>>();
anyhow::bail!(
"re-anchor {version} would relabel objects this target holds into \
module(s) {list_disp}, which it does not subscribe — that would \
orphan them.\n\
Nothing at or after version {version} was applied (the strong \
membrane).\n\
Adopt {list_disp} before applying past {version}:\n \
pgmt migrate provision --modules {list_args}\n\
then re-run.",
list_disp = list.join(", "),
list_args = list.join(","),
);
}
anyhow::bail!(
"re-anchor {version} has remap section(s) this target cannot satisfy: \
{sections} — the source is absent, the section was never applied, and \
the migration at {version} carries no acquisition section that would \
run here.\n\
Nothing at or after version {version} was applied (the strong \
membrane).\n\
The re-anchor at {version} predates migration-borne acquisition, or its \
paired migration is missing. Regenerate the re-anchor with a current pgmt \
(pgmt migrate new <description> --create-baseline).",
sections = unsatisfiable.iter().cloned().collect::<Vec<_>>().join(", "),
);
}
CrossingCheck::Whole { rewritten } => Ok(PendingCrossing {
version: re_anchor.version,
rewritten,
}),
}
}
pub async fn commit_crossing(&mut self, pending: PendingCrossing) -> Result<()> {
let store = self.store.clone();
let pool = store.pool();
let PendingCrossing { version, rewritten } = pending;
let re_anchor = self
.re_anchors
.iter()
.find(|ra| ra.version == version)
.expect("a pending crossing always names a discovered re-anchor")
.clone();
let sql = std::fs::read_to_string(&re_anchor.path).with_context(|| {
format!(
"Failed to read re-anchor baseline: {}",
re_anchor.path.display()
)
})?;
let checksum = crate::migration_tracking::calculate_checksum(&sql);
let relabeled: Vec<(i32, crate::migration::section_parser::MigrationSection)> =
crate::migration::parse_migration_sections(&re_anchor.path, &sql)?
.into_iter()
.enumerate()
.filter(|(_, s)| crate::modules::remap_source_held(s, &self.established))
.map(|(i, s)| (i as i32, s))
.collect();
let mut tx = pool.begin().await?;
for module in rewritten.difference(&self.established) {
store
.add_module(&mut *tx, module, &SubscriptionSource::Crossing(version))
.await?;
}
for module in self.established.difference(&rewritten) {
store.remove_module(&mut *tx, module).await?;
}
store
.record_crossing_consumption(&mut *tx, version, &checksum)
.await?;
store
.record_sections_satisfied(&mut tx, version, true, &relabeled)
.await?;
tx.commit()
.await
.with_context(|| format!("Failed to record crossing of re-anchor {}", version))?;
if rewritten != self.established {
println!(
"Crossed re-anchor {}: subscription {} -> {}",
version,
render_subscription_set(&self.established),
render_subscription_set(&rewritten),
);
}
self.established = rewritten;
Ok(())
}
pub async fn record_provisioned(&mut self, modules: &BTreeSet<String>) -> Result<()> {
let store = self.store.clone();
let mut tx = store.pool().begin().await?;
for module in modules {
store
.add_module(&mut *tx, module, &SubscriptionSource::Provision)
.await?;
}
tx.commit()
.await
.context("Failed to record the provisioned module subscription")?;
self.established.extend(modules.iter().cloned());
Ok(())
}
pub async fn record_adopted(&mut self, modules: &BTreeSet<String>) -> Result<()> {
let new: Vec<&String> = modules
.iter()
.filter(|m| !self.established.contains(*m))
.collect();
if new.is_empty() {
return Ok(());
}
let store = self.store.clone();
let mut tx = store.pool().begin().await?;
for module in &new {
store
.add_module(&mut *tx, module, &SubscriptionSource::Adopt)
.await?;
}
tx.commit()
.await
.context("Failed to record module adoption")?;
self.established.extend(new.into_iter().cloned());
Ok(())
}
}
pub async fn established_pending_through(
pool: &sqlx::PgPool,
tracking_table: &crate::config::types::TrackingTable,
files: &BTreeMap<(u64, bool), Vec<crate::migration::section_parser::MigrationSection>>,
established: &BTreeSet<String>,
through_version: u64,
) -> Result<Vec<u64>> {
let store = crate::migration_tracking::TrackingStore::new(pool, tracking_table)?;
let watermark = store.applied_baseline_watermark().await?.unwrap_or(0);
let done = store.completed_migration_sections().await?;
let mut pending = Vec::new();
for ((version, is_baseline), file_sections) in files {
if *is_baseline || *version > through_version || *version <= watermark {
continue;
}
let has_pending = file_sections.iter().any(|s| {
let relevant = match &s.module {
None => true, Some(m) => established.contains(m),
};
relevant && !done.contains(&(*version, s.name.clone()))
});
if has_pending {
pending.push(*version);
}
}
pending.sort_unstable();
Ok(pending)
}