use crate::colors::*;
use anyhow::Result;
use serde::Deserialize;
use std::fs;
use std::path::Path;
#[derive(Debug, Deserialize)]
pub struct SyncRule {
pub source_table: String,
pub trigger_column: Option<String>,
pub target_collection: String,
pub embedding_model: Option<String>,
}
#[derive(Debug, Deserialize)]
struct QailConfig {
project: ProjectConfig,
#[serde(default)]
sync: Vec<SyncRule>,
}
#[derive(Debug, Deserialize)]
struct ProjectConfig {
mode: String,
}
pub fn generate_sync_triggers() -> Result<()> {
println!("{} Generating sync triggers...", "→".cyan());
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(());
}
let migrations_dir = crate::migrations::resolve_deltas_dir(true)?;
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));
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');
}
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
));
}
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(())
}
fn generate_trigger_function(rule: &SyncRule) -> String {
let table = &rule.source_table;
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)
}}
"#
)
}
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()
"#
)
}
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();
if let Some(num_str) = name_str.split('_').next()
&& let Ok(num) = num_str.parse::<u32>()
{
max = max.max(num + 1);
}
}
}
Ok(max)
}
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(())
}