mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-18 04:47:08 +00:00
## Summary This moves workflow-visible variables from JSON file storage into SQLite-backed storage, establishing the first durable SQL table while preserving the existing variable API behavior. ## What Changed - Added a `fabro-db` crate with bundled SQLite, an embedded migration for the `variables` table, and a `Database` owner for `connect()`, `migrate()`, `health_check()`, and pool access. - Replaced the `fabro-variable` JSON file store with an async SQLx-backed `VariableStore` that preserves sorted listing, case-sensitive names, empty string values, name validation, and description-preserving upserts. - Wired server startup to create `<storage>/db/fabro.sqlite3`, run SQLite migrations, import legacy variables when needed, and pass the shared pool into server state. - Grouped live server stores under `AppStores` so runs, variables, vault, environments, and automations share one state boundary while artifacts remain separate. - Updated variable handlers, run creation, validation, and test support for async SQLite-backed variable access. - Added schema, store-level, legacy import, and API-level persistence coverage for variables. ## Legacy JSON Migration On startup, Fabro looks for `<storage>/variables.json`. If it is missing, startup is a no-op for legacy variables. If the file exists, Fabro parses and validates the full file before mutating SQLite. Valid entries are inserted with `ON CONFLICT(name) DO NOTHING`, so existing SQLite values remain authoritative and only missing names are imported from the legacy file. After a successful import transaction, the source file is renamed to a timestamped backup such as `variables.json.imported-<timestamp>.bak`. A later startup naturally skips the import because the original source path no longer exists. Invalid JSON or invalid variable names leave the source file in place for operator repair. Variable values are not logged during import. Logs include only safe metadata such as source/backup paths, row counts, and variable names. ## Verification - `cargo nextest run -p fabro-db -p fabro-variable` - `cargo nextest run -p fabro-server --features test-support variables` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` --- [](https://github.com/EveryInc/compound-engineering-plugin) Generated with GPT-5 via [Codex](https://openai.com/codex)
390 lines
11 KiB
Rust
390 lines
11 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use fabro_variable::{Error, VariableStore, import_legacy_json_once};
|
|
use tokio::fs;
|
|
|
|
struct TestStore {
|
|
dir: tempfile::TempDir,
|
|
pool: fabro_db::DbPool,
|
|
store: VariableStore,
|
|
}
|
|
|
|
async fn test_store() -> anyhow::Result<TestStore> {
|
|
let dir = tempfile::tempdir()?;
|
|
let database = fabro_db::Database::connect(dir.path().join("fabro.sqlite3")).await?;
|
|
database.migrate().await?;
|
|
let pool = database.clone_pool();
|
|
let store = VariableStore::new(pool.clone());
|
|
Ok(TestStore { dir, pool, store })
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_store_lists_empty_database() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
|
|
assert!(test.store.list().await?.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_get_list_and_reopen_variables() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
|
|
let first = test
|
|
.store
|
|
.set("ZETA", "last", Some("Last variable"))
|
|
.await?;
|
|
let second = test.store.set("ALPHA", "", None).await?;
|
|
|
|
assert_eq!(first.name, "ZETA");
|
|
assert_eq!(first.value, "last");
|
|
assert_eq!(first.description.as_deref(), Some("Last variable"));
|
|
assert_eq!(second.value, "");
|
|
assert_eq!(test.store.get("ZETA").await?.unwrap().value, "last");
|
|
assert_eq!(
|
|
test.store
|
|
.list()
|
|
.await?
|
|
.into_iter()
|
|
.map(|variable| variable.name)
|
|
.collect::<Vec<_>>(),
|
|
vec!["ALPHA", "ZETA"]
|
|
);
|
|
|
|
let reopened = VariableStore::new(test.pool.clone());
|
|
assert_eq!(reopened.get("ALPHA").await?.unwrap().value, "");
|
|
assert_eq!(
|
|
reopened.get("ZETA").await?.unwrap().description.as_deref(),
|
|
Some("Last variable")
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upsert_preserves_description_when_omitted_and_updates_when_present() -> anyhow::Result<()>
|
|
{
|
|
let test = test_store().await?;
|
|
|
|
let created = test
|
|
.store
|
|
.set("DEPLOY_ENV", "staging", Some("Deployment target"))
|
|
.await?;
|
|
let preserved = test.store.set("DEPLOY_ENV", "production", None).await?;
|
|
let updated = test
|
|
.store
|
|
.set("DEPLOY_ENV", "preview", Some("Preview target"))
|
|
.await?;
|
|
|
|
assert_eq!(preserved.created_at, created.created_at);
|
|
assert_eq!(preserved.description.as_deref(), Some("Deployment target"));
|
|
assert_eq!(updated.description.as_deref(), Some("Preview target"));
|
|
assert!(updated.updated_at >= preserved.updated_at);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_existing_preserves_description_and_reports_missing() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
|
|
let created = test
|
|
.store
|
|
.set("DEPLOY_ENV", "staging", Some("Deployment target"))
|
|
.await?;
|
|
let updated = test
|
|
.store
|
|
.update_existing("DEPLOY_ENV", "production", None)
|
|
.await?;
|
|
|
|
assert_eq!(updated.created_at, created.created_at);
|
|
assert_eq!(updated.value, "production");
|
|
assert_eq!(updated.description.as_deref(), Some("Deployment target"));
|
|
assert!(matches!(
|
|
test.store.update_existing("MISSING", "value", None).await,
|
|
Err(Error::NotFound(name)) if name == "MISSING"
|
|
));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_deletes_variable_and_reports_missing() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
test.store.set("DEPLOY_ENV", "staging", None).await?;
|
|
|
|
test.store.remove("DEPLOY_ENV").await?;
|
|
|
|
assert!(test.store.get("DEPLOY_ENV").await?.is_none());
|
|
assert!(matches!(
|
|
test.store.remove("DEPLOY_ENV").await,
|
|
Err(Error::NotFound(name)) if name == "DEPLOY_ENV"
|
|
));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn env_style_names_are_required() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
|
|
for invalid in ["", "1BAD", "bad-name", "BAD.NAME"] {
|
|
assert!(matches!(
|
|
test.store.set(invalid, "value", None).await,
|
|
Err(Error::InvalidName(name)) if name == invalid
|
|
));
|
|
}
|
|
|
|
test.store.set("_OK", "value", None).await?;
|
|
test.store.set("OK_123", "value", None).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn value_map_snapshots_values_only() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
test.store
|
|
.set("DEPLOY_ENV", "staging", Some("Deployment target"))
|
|
.await?;
|
|
|
|
let values = test.store.value_map().await?;
|
|
|
|
assert_eq!(
|
|
values.get("DEPLOY_ENV").map(String::as_str),
|
|
Some("staging")
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_missing_legacy_file_is_noop() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
let source = test.dir.path().join("variables.json");
|
|
|
|
let report = import_legacy_json_once(&test.pool, &source).await?;
|
|
|
|
assert!(report.is_none());
|
|
assert!(test.store.list().await?.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_valid_legacy_json_imports_rows_and_renames_source() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
let source = test.dir.path().join("variables.json");
|
|
let source_contents = r#"{
|
|
"ALPHA": {
|
|
"value": "first",
|
|
"description": "Alpha variable",
|
|
"created_at": "2026-06-30T10:00:00Z",
|
|
"updated_at": "2026-06-30T10:01:00Z"
|
|
},
|
|
"EMPTY": {
|
|
"value": "",
|
|
"created_at": "2026-06-30T10:02:00Z",
|
|
"updated_at": "2026-06-30T10:03:00Z"
|
|
}
|
|
}"#;
|
|
fs::write(&source, source_contents).await?;
|
|
|
|
let report = import_legacy_json_once(&test.pool, &source)
|
|
.await?
|
|
.expect("existing legacy file should report import");
|
|
|
|
assert_eq!(report.imported_rows, 2);
|
|
assert_eq!(report.skipped_rows, 0);
|
|
assert_eq!(report.variable_names, vec!["ALPHA", "EMPTY"]);
|
|
assert_eq!(test.store.get("ALPHA").await?.unwrap().value, "first");
|
|
assert_eq!(
|
|
test.store
|
|
.get("ALPHA")
|
|
.await?
|
|
.unwrap()
|
|
.description
|
|
.as_deref(),
|
|
Some("Alpha variable")
|
|
);
|
|
assert_eq!(test.store.get("EMPTY").await?.unwrap().value, "");
|
|
assert!(!source.exists());
|
|
assert_eq!(
|
|
fs::read_to_string(&report.backup_path).await?,
|
|
source_contents
|
|
);
|
|
assert_eq!(legacy_backups(test.dir.path()).await?, vec![
|
|
report.backup_path
|
|
]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_empty_legacy_json_still_renames_source() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
let source = test.dir.path().join("variables.json");
|
|
fs::write(&source, "{}").await?;
|
|
|
|
let report = import_legacy_json_once(&test.pool, &source)
|
|
.await?
|
|
.expect("existing empty legacy file should report import");
|
|
|
|
assert_eq!(report.imported_rows, 0);
|
|
assert_eq!(report.skipped_rows, 0);
|
|
assert!(report.variable_names.is_empty());
|
|
assert!(!source.exists());
|
|
assert_eq!(fs::read_to_string(&report.backup_path).await?, "{}");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_second_run_is_noop_after_source_was_renamed() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
let source = test.dir.path().join("variables.json");
|
|
fs::write(
|
|
&source,
|
|
r#"{
|
|
"ALPHA": {
|
|
"value": "first",
|
|
"created_at": "2026-06-30T10:00:00Z",
|
|
"updated_at": "2026-06-30T10:01:00Z"
|
|
}
|
|
}"#,
|
|
)
|
|
.await?;
|
|
|
|
let first = import_legacy_json_once(&test.pool, &source).await?;
|
|
let second = import_legacy_json_once(&test.pool, &source).await?;
|
|
|
|
assert!(first.is_some());
|
|
assert!(second.is_none());
|
|
assert_eq!(test.store.list().await?.len(), 1);
|
|
assert!(!source.exists());
|
|
assert_eq!(legacy_backups(test.dir.path()).await?.len(), 1);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_preserves_existing_sqlite_values_and_imports_new_names() -> anyhow::Result<()> {
|
|
let test = test_store().await?;
|
|
test.store.set("CURRENT", "sqlite wins", None).await?;
|
|
let source = test.dir.path().join("variables.json");
|
|
fs::write(
|
|
&source,
|
|
r#"{
|
|
"CURRENT": {
|
|
"value": "file loses",
|
|
"created_at": "2026-06-30T10:00:00Z",
|
|
"updated_at": "2026-06-30T10:01:00Z"
|
|
},
|
|
"LEGACY": {
|
|
"value": "file imports",
|
|
"created_at": "2026-06-30T10:00:00Z",
|
|
"updated_at": "2026-06-30T10:01:00Z"
|
|
}
|
|
}"#,
|
|
)
|
|
.await?;
|
|
|
|
let report = import_legacy_json_once(&test.pool, &source)
|
|
.await?
|
|
.expect("existing legacy file should report import");
|
|
|
|
assert_eq!(report.imported_rows, 1);
|
|
assert_eq!(report.skipped_rows, 1);
|
|
assert_eq!(report.variable_names, vec!["LEGACY"]);
|
|
assert_eq!(
|
|
test.store.get("CURRENT").await?.unwrap().value,
|
|
"sqlite wins"
|
|
);
|
|
assert_eq!(
|
|
test.store.get("LEGACY").await?.unwrap().value,
|
|
"file imports"
|
|
);
|
|
assert!(!source.exists());
|
|
assert_eq!(
|
|
fs::read_to_string(&report.backup_path).await?,
|
|
r#"{
|
|
"CURRENT": {
|
|
"value": "file loses",
|
|
"created_at": "2026-06-30T10:00:00Z",
|
|
"updated_at": "2026-06-30T10:01:00Z"
|
|
},
|
|
"LEGACY": {
|
|
"value": "file imports",
|
|
"created_at": "2026-06-30T10:00:00Z",
|
|
"updated_at": "2026-06-30T10:01:00Z"
|
|
}
|
|
}"#
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_invalid_json_or_name_fails_when_sqlite_is_empty() -> anyhow::Result<()> {
|
|
let invalid_json = test_store().await?;
|
|
let invalid_json_source = invalid_json.dir.path().join("variables.json");
|
|
fs::write(&invalid_json_source, "not json").await?;
|
|
|
|
let error = import_legacy_json_once(&invalid_json.pool, &invalid_json_source)
|
|
.await
|
|
.expect_err("invalid JSON should fail import");
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains(&invalid_json_source.display().to_string())
|
|
);
|
|
assert!(invalid_json_source.exists());
|
|
assert!(legacy_backups(invalid_json.dir.path()).await?.is_empty());
|
|
|
|
let invalid_name = test_store().await?;
|
|
let invalid_name_source = invalid_name.dir.path().join("variables.json");
|
|
fs::write(
|
|
&invalid_name_source,
|
|
r#"{
|
|
"BAD-NAME": {
|
|
"value": "secret-ish value must not be in errors",
|
|
"created_at": "2026-06-30T10:00:00Z",
|
|
"updated_at": "2026-06-30T10:01:00Z"
|
|
}
|
|
}"#,
|
|
)
|
|
.await?;
|
|
|
|
let error = import_legacy_json_once(&invalid_name.pool, &invalid_name_source)
|
|
.await
|
|
.expect_err("invalid name should fail import");
|
|
let message = error.to_string();
|
|
assert!(message.contains(&invalid_name_source.display().to_string()));
|
|
assert!(message.contains("BAD-NAME"));
|
|
assert!(!message.contains("secret-ish value"));
|
|
assert!(invalid_name_source.exists());
|
|
assert!(legacy_backups(invalid_name.dir.path()).await?.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn legacy_backups(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
|
|
let mut entries = fs::read_dir(dir).await?;
|
|
let mut backups = Vec::new();
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let path = entry.path();
|
|
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
|
|
continue;
|
|
};
|
|
if file_name.starts_with("variables.json.imported-")
|
|
&& path
|
|
.extension()
|
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("bak"))
|
|
{
|
|
backups.push(path);
|
|
}
|
|
}
|
|
backups.sort();
|
|
Ok(backups)
|
|
}
|