diff --git a/src/abi/mod.rs b/src/abi/mod.rs index be2f1cc..6b94921 100644 --- a/src/abi/mod.rs +++ b/src/abi/mod.rs @@ -460,7 +460,7 @@ impl AccessPolicy { /// with validation and path resolution. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SidecarConfig { - /// Storage backend: "sqlite" or "json". + /// Storage backend: "sqlite" (default) or "postgres"/"postgresql". pub storage: String, /// File path for the sidecar database. pub path: String, diff --git a/src/codegen/overlay.rs b/src/codegen/overlay.rs index 893231e..596fe90 100644 --- a/src/codegen/overlay.rs +++ b/src/codegen/overlay.rs @@ -83,21 +83,29 @@ pub enum SqlDialect { impl SqlDialect { /// Map a `[sidecar].storage` value to a dialect. `sqlite` → /// [`SqlDialect::Sqlite`]; `postgres`/`postgresql` → - /// [`SqlDialect::Postgres`]. `json` and unknown values are rejected - /// (the previous behaviour silently emitted SQLite DDL regardless, - /// V-L2-F1). The JSON store is tracked separately by #112. + /// [`SqlDialect::Postgres`]. Every other value is rejected rather than + /// silently emitting SQLite DDL regardless of the configured store + /// (V-L2-F1). + /// + /// This is the single source of truth for "is this a supported sidecar + /// storage value?": [`crate::manifest::validate_manifest`] calls it so + /// `validate`, `doctor`, and `generate` cannot disagree on the closed + /// set. + /// + /// V-L2-F2 (#112): a JSON sidecar store was evaluated here and dropped + /// rather than implemented. There is no storage-abstraction layer to + /// host a second runtime backend yet (provenance, temporal, drift, and + /// gc are all SQLite-specific), so leaving it advertised-but-rejected + /// was worse than removing it. If a flat-file store is wanted later it + /// should be designed against a real storage trait and re-added to this + /// match — until then it is just another unsupported value. pub fn from_storage(storage: &str) -> anyhow::Result { match storage.to_lowercase().as_str() { "sqlite" => Ok(SqlDialect::Sqlite), "postgres" | "postgresql" => Ok(SqlDialect::Postgres), - "json" => anyhow::bail!( - "[sidecar].storage = \"json\" is not implemented (it previously \ - emitted SQLite DDL silently). Use \"sqlite\". The JSON sidecar \ - store is tracked by hyperpolymath/verisimiser#112." - ), other => anyhow::bail!( - "unknown [sidecar].storage {other:?}; supported: \"sqlite\" \ - (\"postgres\" for a PostgreSQL sidecar; \"json\" is #112)." + "unsupported [sidecar].storage {other:?}; supported backends \ + are \"sqlite\" (default) and \"postgres\"/\"postgresql\"." ), } } @@ -879,10 +887,18 @@ mod tests { SqlDialect::from_storage("PostgreSQL").unwrap(), SqlDialect::Postgres ); + // V-L2-F2 (#112): the JSON store was dropped, not implemented, so + // `json` is now rejected exactly like any other unsupported value — + // no special-case "coming soon" text and no #112 pointer. let json_err = SqlDialect::from_storage("json").unwrap_err().to_string(); assert!( - json_err.contains("not implemented") && json_err.contains("#112"), - "json must be rejected with the #112 pointer, got: {json_err}" + json_err.contains("unsupported") && json_err.contains("sqlite"), + "json must be rejected as unsupported and list the supported \ + backends, got: {json_err}" + ); + assert!( + !json_err.contains("#112") && !json_err.contains("not implemented"), + "a dropped backend must not be advertised as tracked/coming, got: {json_err}" ); assert!(SqlDialect::from_storage("mariadb").is_err()); } diff --git a/src/gc.rs b/src/gc.rs index 16fb4de..d623acf 100644 --- a/src/gc.rs +++ b/src/gc.rs @@ -309,7 +309,10 @@ mod tests { #[test] fn gc_rejects_non_sqlite_backend() { - let m = fixture("/dev/null", RetentionConfig::default(), "json"); + // `postgres` is a valid generate-time dialect, but gc is SQLite-only + // and must refuse rather than silently no-op. (The `json` value was + // dropped as a storage option entirely in V-L2-F2 / #112.) + let m = fixture("/dev/null", RetentionConfig::default(), "postgres"); let err = run_gc(&m, true).unwrap_err(); assert!( err.to_string().contains("only supports the SQLite sidecar"), diff --git a/src/main.rs b/src/main.rs index c5df959..eae66ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -209,9 +209,10 @@ fn main() -> Result<()> { // Create output directory. std::fs::create_dir_all(&output)?; - // The sidecar DDL dialect follows [sidecar].storage. This - // rejects `json` (tracked by #112) instead of silently - // emitting SQLite DDL for a non-SQLite store (V-L2-F1). + // The sidecar DDL dialect follows [sidecar].storage. Unknown or + // unsupported values are rejected here (and earlier by + // `validate`) instead of silently emitting SQLite DDL for a + // non-SQLite store (V-L2-F1 / V-L2-F2). let dialect = codegen::overlay::SqlDialect::from_storage(&m.sidecar.storage)?; // Generate sidecar overlay schema. Errors here surface invalid diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index 3659e5b..8e3fc2d 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -279,11 +279,14 @@ mod octad_tests { /// temporal versions, and access policies. It never writes to your target database. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SidecarConfig { - /// Storage backend for the sidecar. `"sqlite"` (default) is the only - /// implemented store; `"postgres"`/`"postgresql"` selects the - /// PostgreSQL DDL dialect. `"json"` is **not implemented** and is - /// rejected at `generate` time — tracked by #112 (V-L2-F2). The - /// dialect mapping lives in `codegen::overlay::SqlDialect`. + /// Storage backend for the sidecar. The supported set is closed: + /// `"sqlite"` (default) is the fully-featured runtime store used by + /// `generate`, `drift`, and `gc`; `"postgres"`/`"postgresql"` selects + /// the PostgreSQL DDL dialect emitted by `generate`. Any other value is + /// rejected — `validate`/`doctor` flag it up front via the + /// `sidecar-storage-supported` check, and `generate` refuses it rather + /// than silently emitting SQLite DDL. The canonical mapping lives in + /// [`crate::codegen::overlay::SqlDialect::from_storage`]. #[serde(default = "default_sidecar_storage")] pub storage: String, @@ -394,6 +397,78 @@ mod validate_manifest_tests { assert_eq!(failed, vec!["schema-source-exists"]); } + /// V-L2-F2 (#112): a dropped/unsupported `[sidecar].storage` value + /// (here the removed `json` store) must fail the + /// `sidecar-storage-supported` check at validate time, not slip + /// through to `generate`. Other checks still run independently. + #[test] + fn unsupported_storage_fails_storage_check() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("verisimiser.toml"); + let sidecar_path = dir.path().join("sidecar.db"); + let body = format!( + "[project]\n\ + name = \"test\"\n\ + [database]\n\ + backend = \"sqlite\"\n\ + [sidecar]\n\ + storage = \"json\"\n\ + path = \"{}\"\n", + sidecar_path.display().to_string().replace('\\', "/") + ); + std::fs::write(&path, body).expect("write"); + + let report = validate_manifest(path.to_str().unwrap()); + assert!(!report.passed, "json storage must not validate"); + let failed: Vec<&str> = report + .checks + .iter() + .filter(|c| !c.passed) + .map(|c| c.name.as_str()) + .collect(); + assert_eq!( + failed, + vec!["sidecar-storage-supported"], + "only the storage check should fail; checks: {:?}", + report.checks + ); + } + + /// The PostgreSQL dialect remains a supported `storage` value (it + /// selects the postgres DDL for `generate`), so a postgres sidecar + /// must still pass validation. + #[test] + fn postgres_storage_passes_storage_check() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("verisimiser.toml"); + let sidecar_path = dir.path().join("sidecar.db"); + let body = format!( + "[project]\n\ + name = \"test\"\n\ + [database]\n\ + backend = \"postgresql\"\n\ + [sidecar]\n\ + storage = \"postgres\"\n\ + path = \"{}\"\n", + sidecar_path.display().to_string().replace('\\', "/") + ); + std::fs::write(&path, body).expect("write"); + + let report = validate_manifest(path.to_str().unwrap()); + assert!( + report.passed, + "postgres storage must validate; checks: {:?}", + report.checks + ); + assert!( + report + .checks + .iter() + .any(|c| c.name == "sidecar-storage-supported" && c.passed), + "the storage-supported check must run and pass" + ); + } + /// A malformed manifest must fail `manifest-loads` and stop further /// checks (because the rest depend on having a parsed manifest). #[test] @@ -621,6 +696,7 @@ enable-constraints = {enable_constraints} enable-simulation = {enable_simulation} [sidecar] +# storage backend: "sqlite" (default) or "postgres" storage = "{sidecar_storage}" path = "{sidecar_path}" @@ -734,6 +810,10 @@ impl ValidationReport { /// set, the file at that path is readable. /// 3. **`sidecar-path-writable`** — the parent directory of /// `[sidecar].path` is writable (or createable). +/// 4. **`sidecar-storage-supported`** — `[sidecar].storage` is one of the +/// supported backends (`sqlite`, `postgres`/`postgresql`). Reuses the +/// same mapping `generate` uses so a manifest that validates cannot +/// then fail at generate time on an unsupported store. V-L2-F2 (#112). /// /// Out of scope here: V-L2-E1 backend/target_db conflict (own issue), /// target-DB reachability (needs live connection). @@ -820,6 +900,29 @@ pub fn validate_manifest(path: &str) -> ValidationReport { )), }); } + + // 4. Sidecar storage backend is one we support. Reuses the same + // mapping `generate` uses (codegen::overlay::SqlDialect) so a + // manifest that validates here cannot then fail at generate + // time on an unsupported store — and so a dropped backend + // (e.g. json, V-L2-F2 #112) is rejected consistently at + // validate, doctor, and generate. + let storage_check = + match crate::codegen::overlay::SqlDialect::from_storage(&m.sidecar.storage) { + Ok(_) => ValidationCheck { + name: "sidecar-storage-supported".to_string(), + description: "[sidecar].storage is a supported backend".to_string(), + passed: true, + detail: None, + }, + Err(e) => ValidationCheck { + name: "sidecar-storage-supported".to_string(), + description: "[sidecar].storage is a supported backend".to_string(), + passed: false, + detail: Some(e.to_string()), + }, + }; + checks.push(storage_check); } let passed = checks.iter().all(|c| c.passed);