qail 0.28.0

Schema-first database toolkit - migrations, diff, lint, and query generation
Documentation
//! qail sync - Generate sync triggers from qail.toml
//!
//! Parses [[sync]] rules and generates PostgreSQL trigger migrations
//! that populate the _qail_queue table on INSERT/UPDATE/DELETE.

use crate::colors::*;
use anyhow::Result;
use serde::Deserialize;
use std::fs;
use std::path::Path;

/// Sync rule from qail.toml
#[derive(Debug, Deserialize)]
pub struct SyncRule {
    pub source_table: String,
    pub trigger_column: Option<String>,
    pub target_collection: String,
    pub embedding_model: Option<String>,
}

/// Project config from qail.toml
#[derive(Debug, Deserialize)]
struct QailConfig {
    project: ProjectConfig,
    #[serde(default)]
    sync: Vec<SyncRule>,
}

#[derive(Debug, Deserialize)]
struct ProjectConfig {
    mode: String,
}

/// Generate sync trigger migrations from qail.toml
pub fn generate_sync_triggers() -> Result<()> {
    println!("{} Generating sync triggers...", "".cyan());

    // 1. Read qail.toml
    let config_path = Path::new("qail.toml");
    if !config_path.exists() {
        anyhow::bail!("qail.toml not found. Run 'qail init' first.");
    }

    let content = fs::read_to_string(config_path)?;
    let config: QailConfig = toml::from_str(&content)
        .map_err(|e| anyhow::anyhow!("Failed to parse qail.toml: {}", e))?;

    if config.project.mode != "hybrid" {
        anyhow::bail!("Sync triggers only apply to 'hybrid' mode projects.");
    }

    if config.sync.is_empty() {
        println!("{} No [[sync]] rules found in qail.toml", "".yellow());
        println!("Add sync rules like:");
        println!("  [[sync]]");
        println!("  source_table = \"products\"");
        println!("  trigger_column = \"description\"");
        println!("  target_collection = \"products_search\"");
        return Ok(());
    }

    // 2. Resolve deltas directory
    let migrations_dir = crate::migrations::resolve_deltas_dir(true)?;

    // Find next migration number
    let next_num = find_next_migration_number(&migrations_dir)?;

    let up_path = migrations_dir.join(format!("{:03}_qail_sync_triggers.up.qail", next_num));
    let down_path = migrations_dir.join(format!("{:03}_qail_sync_triggers.down.qail", next_num));

    // 3. Generate up migration
    let mut up_content =
        String::from("# QAIL Sync Triggers\n# Auto-generated by: qail sync generate\n\n");

    for rule in &config.sync {
        up_content.push_str(&generate_trigger_function(rule));
        up_content.push_str(&generate_trigger(rule));
        up_content.push('\n');
    }

    // 4. Generate down migration
    let mut down_content = String::from("# QAIL Sync Triggers - Rollback\n\n");

    for rule in config.sync.iter().rev() {
        down_content.push_str(&format!(
            "drop trigger if exists qail_sync_{} on {}\n",
            rule.source_table, rule.source_table
        ));
        down_content.push_str(&format!(
            "drop function if exists _qail_{}_notify\n\n",
            rule.source_table
        ));
    }

    // 5. Write files
    fs::write(&up_path, up_content)?;
    fs::write(&down_path, down_content)?;

    println!("{} Created {}", "".green(), up_path.display());
    println!("{} Created {}", "".green(), down_path.display());
    println!();
    println!("Next: Run 'qail migrate up' to apply triggers");

    Ok(())
}

/// Generate the trigger function for a sync rule
fn generate_trigger_function(rule: &SyncRule) -> String {
    let table = &rule.source_table;

    // Build UPDATE condition - only sync if trigger_column changed
    let update_condition = if let Some(col) = &rule.trigger_column {
        format!("TG_OP = 'UPDATE' and NEW.{col} is distinct from OLD.{col}")
    } else {
        "TG_OP = 'UPDATE'".to_string()
    };

    format!(
        r#"# Trigger function for {table} -> Qdrant sync
function _qail_{table}_notify() returns trigger {{
  # On INSERT: Always queue (new row = new embedding)
  if TG_OP = 'INSERT' {{
    insert into _qail_queue (ref_table, ref_id, operation, payload)
    values ('{table}', NEW.id::text, 'UPSERT', to_jsonb(NEW))
  }}
  
  # On UPDATE: Only queue if trigger column changed (saves CPU/API costs!)
  if {update_condition} {{
    insert into _qail_queue (ref_table, ref_id, operation, payload)
    values ('{table}', NEW.id::text, 'UPSERT', to_jsonb(NEW))
  }}
  
  # On DELETE: Always queue (must remove from Qdrant)
  if TG_OP = 'DELETE' {{
    insert into _qail_queue (ref_table, ref_id, operation, payload)
    values ('{table}', OLD.id::text, 'DELETE', to_jsonb(OLD))
  }}
  
  return coalesce(NEW, OLD)
}}

"#
    )
}

/// Generate the trigger definition
fn generate_trigger(rule: &SyncRule) -> String {
    let table = &rule.source_table;

    format!(
        r#"trigger qail_sync_{table}
  after insert or update or delete on {table}
  for each row execute _qail_{table}_notify()

"#
    )
}

/// Find the next migration number based on existing files
fn find_next_migration_number(migrations_dir: &Path) -> Result<u32> {
    let mut max = 1;

    if let Ok(entries) = fs::read_dir(migrations_dir) {
        for entry in entries.flatten() {
            let name = entry.file_name();
            let name_str = name.to_string_lossy();

            // Parse "NNN_*.qail" pattern
            if let Some(num_str) = name_str.split('_').next()
                && let Ok(num) = num_str.parse::<u32>()
            {
                max = max.max(num + 1);
            }
        }
    }

    Ok(max)
}

/// List sync rules from qail.toml
pub fn list_sync_rules() -> Result<()> {
    let config_path = Path::new("qail.toml");
    if !config_path.exists() {
        anyhow::bail!("qail.toml not found. Run 'qail init' first.");
    }

    let content = fs::read_to_string(config_path)?;
    let config: QailConfig = toml::from_str(&content)?;

    if config.sync.is_empty() {
        println!("No sync rules configured.");
        return Ok(());
    }

    println!("{}", "Sync Rules:".white().bold());
    for (i, rule) in config.sync.iter().enumerate() {
        println!(
            "  {}. {}{}",
            i + 1,
            rule.source_table.yellow(),
            rule.target_collection.cyan()
        );
        if let Some(col) = &rule.trigger_column {
            println!("     Trigger: {}", col);
        }
        if let Some(model) = &rule.embedding_model {
            println!("     Model: {}", model);
        }
    }

    Ok(())
}