From aadfa11ecbf8809e04ec6156a87cd5a69e9b9263 Mon Sep 17 00:00:00 2001 From: Andrew Altshuler Date: Tue, 19 May 2026 01:56:46 +0300 Subject: [PATCH] schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode drops were CLI-only. This commit closes that feature gap and adds e2e test coverage for drop modes across HTTP + CLI, plus data preservation on additive apply, plus a CLI↔SDK plan-parity assertion. Feature gap closed: - `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool` (default false via `#[serde(default)]`) to `SchemaApplyRequest`. Added `Default` derive so test usages can use `..Default::default()`. - `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }` and threads through to `apply_schema_as`. - `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path used to bail with "--allow-data-loss not yet supported on remote"; now forwards the flag into the JSON payload so the CLI behaves identically against local and remote URIs. - `openapi.json` — regenerated; only diff is the new field on `SchemaApplyRequest`. Tests added (8 new): * `crates/omnigraph-server/tests/server.rs` (+5): - `schema_apply_route_soft_drops_property_via_http` — POST schema removing nullable property, verify catalog reflects the drop AND `snapshot_at_version(pre)` still has `age` in the field list (time-travel reachability is the Soft contract). - `schema_apply_route_soft_drops_node_type_via_http` — POST schema removing `Company` node + cascading `WorksAt` edge. - `schema_apply_route_hard_drops_property_with_allow_data_loss` — POST with `allow_data_loss: true`, verify plan step reports `mode: hard`. - `schema_apply_route_keeps_drops_soft_without_flag` — same schema without flag, verify `mode: soft`. Pins default semantics against accidental Hard promotion. - `schema_apply_route_additive_property_preserves_existing_rows` — load fixture, POST adding nullable property, verify row count preserved (SDK suite covers data preservation on drops + renames; additive AddProperty wasn't pinned). Plus helpers `schema_without_age` and `schema_without_company`. * `crates/omnigraph-cli/tests/cli.rs` (+3): - `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI `omnigraph schema apply --allow-data-loss --schema X.pg --json`, verify plan step has `mode: hard`. - `schema_apply_without_allow_data_loss_keeps_soft_drops` — without flag, verify Soft. - `schema_plan_parity_cli_and_sdk` — same `.pg` source through `Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json` (CLI), assert the steps array is byte-identical post-JSON. HTTP has no `/schema/plan` endpoint; apply-side parity is implicitly covered by the HTTP drop tests + CLI drop tests using identical fixtures. Docs: - `docs/user/schema-language.md` — new "Destructive drops" section documenting Soft vs Hard semantics and that `allow_data_loss` is now honored uniformly across CLI / HTTP / SDK. Verification: every new test passes; full `cargo test --workspace --locked` green; `scripts/check-agents-md.sh` passes. Co-authored-by: Claude Opus 4.7 --- crates/omnigraph-cli/src/main.rs | 12 +- crates/omnigraph-cli/tests/cli.rs | 131 ++++++++++ crates/omnigraph-server/src/api.rs | 8 +- crates/omnigraph-server/src/lib.rs | 4 +- crates/omnigraph-server/tests/server.rs | 319 ++++++++++++++++++++++++ docs/user/schema-language.md | 8 + openapi.json | 4 + 7 files changed, 478 insertions(+), 8 deletions(-) diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 95114e8..ac21e7b 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -2091,18 +2091,18 @@ async fn main() -> Result<()> { let uri = resolve_uri(&config, uri, target.as_deref())?; let schema_source = fs::read_to_string(&schema)?; let output = if is_remote_uri(&uri) { - if allow_data_loss { - bail!( - "--allow-data-loss is not yet supported on remote (HTTP) schema apply; \ - use `omnigraph schema apply` against a local path or s3:// URI for now" - ); - } + // MR-694 PR B: SchemaApplyRequest gained an + // allow_data_loss field so Hard-mode drops are no + // longer CLI-only. The previous bail is gone; the + // field is forwarded into the JSON payload, and + // the server's `server_schema_apply` honors it. remote_json::( &http_client, Method::POST, remote_url(&uri, "/schema/apply"), Some(serde_json::to_value(SchemaApplyRequest { schema_source: schema_source.clone(), + allow_data_loss, })?), bearer_token.as_deref(), ) diff --git a/crates/omnigraph-cli/tests/cli.rs b/crates/omnigraph-cli/tests/cli.rs index 9dc7338..578d1bd 100644 --- a/crates/omnigraph-cli/tests/cli.rs +++ b/crates/omnigraph-cli/tests/cli.rs @@ -1909,3 +1909,134 @@ fn cli_fails_for_invalid_merge_requests() { // alongside the run state machine. Direct-to-target writes leave nothing // for these CLIs to manage. Audit history is now visible via // `omnigraph commit list` reading the commit graph. + +// ─── MR-694 PR B: --allow-data-loss flag end-to-end ────────────────────── +// +// The schema-lint chassis v1.2 (PR #100) shipped the `--allow-data-loss` +// flag at the CLI layer; the SDK suite verifies promotion to Hard mode +// via `apply_schema_with_options(.., SchemaApplyOptions { allow_data_loss })`. +// These CLI tests close the integration gap so a future change that +// drops the flag wiring in `main.rs` turns red. + +#[test] +fn schema_apply_allow_data_loss_flag_promotes_drops_to_hard() { + let temp = tempdir().unwrap(); + let repo = repo_path(temp.path()); + let schema_path = temp.path().join("drop-age.pg"); + init_repo(&repo); + + // Drop the nullable `age` column. + let next_schema = fs::read_to_string(fixture("test.pg")) + .unwrap() + .replace(" age: I32?\n", ""); + fs::write(&schema_path, next_schema).unwrap(); + + let output = output_success( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg("--allow-data-loss") + .arg("--json") + .arg(&repo), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["applied"], true); + + let drop_step = payload["steps"] + .as_array() + .unwrap() + .iter() + .find(|s| s["kind"] == "drop_property") + .expect("plan should include a drop_property step"); + assert_eq!( + drop_step["mode"], "hard", + "--allow-data-loss should promote Soft → Hard; full step: {drop_step}", + ); +} + +#[test] +fn schema_apply_without_allow_data_loss_keeps_soft_drops() { + // Symmetric to the above: same schema change without the flag → + // drops stay Soft. Pins default semantics against accidental Hard + // promotion if a future refactor changes the option threading. + let temp = tempdir().unwrap(); + let repo = repo_path(temp.path()); + let schema_path = temp.path().join("drop-age-soft.pg"); + init_repo(&repo); + + let next_schema = fs::read_to_string(fixture("test.pg")) + .unwrap() + .replace(" age: I32?\n", ""); + fs::write(&schema_path, next_schema).unwrap(); + + let output = output_success( + cli() + .arg("schema") + .arg("apply") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .arg(&repo), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["applied"], true); + + let drop_step = payload["steps"] + .as_array() + .unwrap() + .iter() + .find(|s| s["kind"] == "drop_property") + .expect("plan should include a drop_property step"); + assert_eq!( + drop_step["mode"], "soft", + "no flag should leave drops Soft; full step: {drop_step}", + ); +} + +#[test] +fn schema_plan_parity_cli_and_sdk() { + // Same .pg through `Omnigraph::plan_schema_with_options` (SDK) and + // `omnigraph schema plan --json` (CLI). Asserts the steps array is + // byte-identical after JSON round-trip. HTTP doesn't expose a + // separate /schema/plan route — that side of parity is covered by + // the HTTP soft/hard drop tests, which exercise apply with + // identical fixtures. + let temp = tempdir().unwrap(); + let repo = repo_path(temp.path()); + init_repo(&repo); + let schema_path = temp.path().join("plan-parity.pg"); + let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace( + " age: I32?\n}", + " age: I32?\n nickname: String?\n}", + ); + fs::write(&schema_path, &next_schema).unwrap(); + + // CLI side. + let cli_output = output_success( + cli() + .arg("schema") + .arg("plan") + .arg("--schema") + .arg(&schema_path) + .arg("--json") + .arg(&repo), + ); + let cli_payload: Value = serde_json::from_slice(&cli_output.stdout).unwrap(); + + // SDK side: open repo, call plan_schema. + let plan = tokio::runtime::Runtime::new().unwrap().block_on(async { + let db = Omnigraph::open(repo.to_string_lossy().as_ref()) + .await + .unwrap(); + db.plan_schema(&next_schema).await.unwrap() + }); + let sdk_steps = serde_json::to_value(&plan.steps).unwrap(); + + assert_eq!( + cli_payload["steps"], sdk_steps, + "CLI plan steps must match SDK plan steps for identical input", + ); + assert_eq!(cli_payload["supported"], plan.supported); +} diff --git a/crates/omnigraph-server/src/api.rs b/crates/omnigraph-server/src/api.rs index 89534f5..1195f12 100644 --- a/crates/omnigraph-server/src/api.rs +++ b/crates/omnigraph-server/src/api.rs @@ -262,12 +262,18 @@ pub struct ChangeRequest { pub branch: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] pub struct SchemaApplyRequest { /// Project schema in `.pg` source form. The diff against the current /// schema produces the migration steps that will be applied. #[schema(example = "node Person {\n name: String @key\n age: I32?\n}\n\nedge Knows: Person -> Person")] pub schema_source: String, + /// When true, promote every `DropMode::Soft` step in the plan to + /// `DropMode::Hard`, making the prior column data unreachable + /// after the apply. Matches the CLI's `--allow-data-loss` flag. + /// Defaults to `false` (drops remain reversible via time travel). + #[serde(default)] + pub allow_data_loss: bool, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index bef91e2..0ab2249 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -1226,7 +1226,9 @@ async fn server_schema_apply( // the redundancy. db.apply_schema_as( &request.schema_source, - omnigraph::db::SchemaApplyOptions::default(), + omnigraph::db::SchemaApplyOptions { + allow_data_loss: request.allow_data_loss, + }, actor_id, ) .await diff --git a/crates/omnigraph-server/tests/server.rs b/crates/omnigraph-server/tests/server.rs index 69fa8a0..e7b4458 100644 --- a/crates/omnigraph-server/tests/server.rs +++ b/crates/omnigraph-server/tests/server.rs @@ -325,6 +325,31 @@ fn additive_schema_with_nickname() -> String { ) } +fn schema_without_age() -> String { + // Drop the nullable `age` column from the test schema. Used by the + // HTTP soft/hard drop tests below. + fs::read_to_string(fixture("test.pg")) + .unwrap() + .replace(" age: I32?\n", "") +} + +fn schema_without_company() -> String { + // Drop the `Company` node type and the edge referencing it. Used + // by the HTTP DropType test below. Hand-crafted (no template + // string replace) because the fixture interleaves the type and + // its edge. + r#"node Person { + name: String @key + age: I32? +} + +edge Knows: Person -> Person { + since: Date? +} +"# + .to_string() +} + fn renamed_person_schema() -> String { fs::read_to_string(fixture("test.pg")) .unwrap() @@ -380,6 +405,7 @@ async fn schema_apply_route_updates_repo_for_authorized_admin() { .body(Body::from( serde_json::to_vec(&SchemaApplyRequest { schema_source: schema, + ..Default::default() }) .unwrap(), )) @@ -414,6 +440,7 @@ async fn schema_apply_route_requires_schema_apply_policy_permission() { .body(Body::from( serde_json::to_vec(&SchemaApplyRequest { schema_source: additive_schema_with_nickname(), + ..Default::default() }) .unwrap(), )) @@ -443,6 +470,7 @@ async fn schema_apply_route_requires_bearer_token_when_policy_enabled() { .body(Body::from( serde_json::to_vec(&SchemaApplyRequest { schema_source: additive_schema_with_nickname(), + ..Default::default() }) .unwrap(), )) @@ -473,6 +501,7 @@ async fn schema_apply_route_can_rename_type() { .body(Body::from( serde_json::to_vec(&SchemaApplyRequest { schema_source: renamed_person_schema(), + ..Default::default() }) .unwrap(), )) @@ -508,6 +537,7 @@ async fn schema_apply_route_can_rename_property() { .body(Body::from( serde_json::to_vec(&SchemaApplyRequest { schema_source: renamed_age_schema(), + ..Default::default() }) .unwrap(), )) @@ -547,6 +577,7 @@ async fn schema_apply_route_can_add_index() { .body(Body::from( serde_json::to_vec(&SchemaApplyRequest { schema_source: indexed_name_schema(), + ..Default::default() }) .unwrap(), )) @@ -582,6 +613,7 @@ async fn schema_apply_route_rejects_unsupported_plan() { .body(Body::from( serde_json::to_vec(&SchemaApplyRequest { schema_source: unsupported_schema_change(), + ..Default::default() }) .unwrap(), )) @@ -622,6 +654,7 @@ async fn schema_apply_route_rejects_when_non_main_branch_exists() { .body(Body::from( serde_json::to_vec(&SchemaApplyRequest { schema_source: additive_schema_with_nickname(), + ..Default::default() }) .unwrap(), )) @@ -3769,6 +3802,7 @@ async fn default_deny_mode_rejects_schema_apply_with_forbidden() { let req = SchemaApplyRequest { schema_source: additive_schema_with_nickname(), + ..Default::default() }; let (status, body) = json_response( &app, @@ -4020,3 +4054,288 @@ async fn policy_decision_parity_branch_merge_team_denied() { "SDK={sdk:?} HTTP={http:?} — should both Deny", ); } + +// ─── MR-694 PR B: HTTP soft + hard drop semantics + data preservation ──── +// +// SDK-level drop semantics are pinned in `crates/omnigraph/tests/schema_apply.rs`. +// These HTTP-side tests mirror the assertions through POST /schema/apply +// and exercise the new `allow_data_loss` field (closes the gap where +// the schema-lint chassis v1.2 shipped Hard mode on the CLI but the +// HTTP request struct had no equivalent field). + +#[tokio::test(flavor = "multi_thread")] +async fn schema_apply_route_soft_drops_property_via_http() { + let (temp, app) = app_for_repo_with_auth_tokens_and_policy( + &fs::read_to_string(fixture("test.pg")).unwrap(), + &[("act-ragnor", "admin-token")], + SCHEMA_APPLY_POLICY_YAML, + ) + .await; + // Load a row that has the column we're about to drop. + let repo = repo_path(temp.path()); + { + let db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap(); + db.load( + "main", + r#"{"type":"Person","data":{"name":"PreDrop","age":42}}"#, + LoadMode::Append, + ) + .await + .unwrap(); + } + let pre_version = manifest_dataset_version(&repo).await; + + let (status, payload) = json_response( + &app, + Request::builder() + .method(Method::POST) + .uri("/schema/apply") + .header("content-type", "application/json") + .header("authorization", "Bearer admin-token") + .body(Body::from( + serde_json::to_vec(&SchemaApplyRequest { + schema_source: schema_without_age(), + ..Default::default() + }) + .unwrap(), + )) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["applied"], true); + + // Catalog reflects the drop: `age` is gone from the live schema. + let reopened = Omnigraph::open(repo.to_str().unwrap()).await.unwrap(); + assert!( + !reopened.catalog().node_types["Person"] + .properties + .contains_key("age"), + "catalog should not contain `age` after drop" + ); + + // Soft drop preserves the prior version — `age` is still readable + // via time travel to the pre-drop manifest version. Mirrors the + // SDK-side assertion in `apply_schema_drops_a_nullable_property_softly_preserves_prior_version`. + let pre_drop_snapshot = reopened.snapshot_at_version(pre_version).await.unwrap(); + let pre_drop_ds = pre_drop_snapshot.open("node:Person").await.unwrap(); + let pre_drop_fields = pre_drop_ds + .schema() + .fields + .iter() + .map(|f| f.name.clone()) + .collect::>(); + assert!( + pre_drop_fields.iter().any(|f| f == "age"), + "soft drop should leave the pre-drop dataset's `age` column \ + time-travel-reachable; got fields {pre_drop_fields:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn schema_apply_route_soft_drops_node_type_via_http() { + let (temp, app) = app_for_repo_with_auth_tokens_and_policy( + &fs::read_to_string(fixture("test.pg")).unwrap(), + &[("act-ragnor", "admin-token")], + SCHEMA_APPLY_POLICY_YAML, + ) + .await; + let repo = repo_path(temp.path()); + + let (status, payload) = json_response( + &app, + Request::builder() + .method(Method::POST) + .uri("/schema/apply") + .header("content-type", "application/json") + .header("authorization", "Bearer admin-token") + .body(Body::from( + serde_json::to_vec(&SchemaApplyRequest { + schema_source: schema_without_company(), + ..Default::default() + }) + .unwrap(), + )) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["applied"], true); + + let reopened = Omnigraph::open(repo.to_str().unwrap()).await.unwrap(); + assert!( + !reopened.catalog().node_types.contains_key("Company"), + "catalog should not contain `Company` after drop" + ); + assert!( + !reopened.catalog().edge_types.contains_key("WorksAt"), + "catalog should not contain `WorksAt` after cascade" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn schema_apply_route_hard_drops_property_with_allow_data_loss() { + let (temp, app) = app_for_repo_with_auth_tokens_and_policy( + &fs::read_to_string(fixture("test.pg")).unwrap(), + &[("act-ragnor", "admin-token")], + SCHEMA_APPLY_POLICY_YAML, + ) + .await; + let repo = repo_path(temp.path()); + { + let db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap(); + db.load( + "main", + r#"{"type":"Person","data":{"name":"PreDropHard","age":50}}"#, + LoadMode::Append, + ) + .await + .unwrap(); + } + + // Apply with allow_data_loss=true → Hard mode promotion. + let (status, payload) = json_response( + &app, + Request::builder() + .method(Method::POST) + .uri("/schema/apply") + .header("content-type", "application/json") + .header("authorization", "Bearer admin-token") + .body(Body::from( + serde_json::to_vec(&SchemaApplyRequest { + schema_source: schema_without_age(), + allow_data_loss: true, + }) + .unwrap(), + )) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["applied"], true); + + // Catalog reflects the drop. + let reopened = Omnigraph::open(repo.to_str().unwrap()).await.unwrap(); + assert!( + !reopened.catalog().node_types["Person"] + .properties + .contains_key("age"), + "catalog should not contain `age` after Hard drop" + ); + // Plan steps should show DropMode::Hard for property drops. + let steps = payload["steps"].as_array().expect("steps array"); + let drop_step = steps + .iter() + .find(|s| s["kind"] == "drop_property") + .expect("plan should include drop_property step"); + let mode = &drop_step["mode"]; + assert_eq!(mode, "hard", "expected hard mode under allow_data_loss=true"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn schema_apply_route_keeps_drops_soft_without_flag() { + // Symmetric to the Hard test: same schema change, but no + // allow_data_loss flag → drops stay Soft (prior column data + // remains time-travel-reachable). Pins the default semantics + // against accidental Hard promotion. + let (temp, app) = app_for_repo_with_auth_tokens_and_policy( + &fs::read_to_string(fixture("test.pg")).unwrap(), + &[("act-ragnor", "admin-token")], + SCHEMA_APPLY_POLICY_YAML, + ) + .await; + let repo = repo_path(temp.path()); + + let (status, payload) = json_response( + &app, + Request::builder() + .method(Method::POST) + .uri("/schema/apply") + .header("content-type", "application/json") + .header("authorization", "Bearer admin-token") + .body(Body::from( + serde_json::to_vec(&SchemaApplyRequest { + schema_source: schema_without_age(), + allow_data_loss: false, + }) + .unwrap(), + )) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["applied"], true); + + let steps = payload["steps"].as_array().expect("steps array"); + let drop_step = steps + .iter() + .find(|s| s["kind"] == "drop_property") + .expect("plan should include drop_property step"); + let mode = &drop_step["mode"]; + assert_eq!(mode, "soft", "expected soft mode without allow_data_loss"); + let _ = repo; +} + +#[tokio::test(flavor = "multi_thread")] +async fn schema_apply_route_additive_property_preserves_existing_rows() { + // SDK suite covers rename and drop data preservation. Additive + // AddProperty wasn't pinned with a row-count check anywhere. + // Load N rows, apply schema adding nullable property, verify + // every row is still readable and the new column is null. + let (temp, app) = app_for_repo_with_auth_tokens_and_policy( + &fs::read_to_string(fixture("test.pg")).unwrap(), + &[("act-ragnor", "admin-token")], + SCHEMA_APPLY_POLICY_YAML, + ) + .await; + let repo = repo_path(temp.path()); + + // Standard fixture data: 4 Persons + 1 Company. Load it. + let pre_count = { + let db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap(); + db.load( + "main", + &fs::read_to_string(fixture("test.jsonl")).unwrap(), + LoadMode::Append, + ) + .await + .unwrap(); + let snap = db + .snapshot_of(omnigraph::db::ReadTarget::branch("main")) + .await + .unwrap(); + snap.entry("node:Person").expect("Person").row_count + }; + assert!(pre_count > 0, "fixture should have loaded Person rows"); + + let (status, payload) = json_response( + &app, + Request::builder() + .method(Method::POST) + .uri("/schema/apply") + .header("content-type", "application/json") + .header("authorization", "Bearer admin-token") + .body(Body::from( + serde_json::to_vec(&SchemaApplyRequest { + schema_source: additive_schema_with_nickname(), + ..Default::default() + }) + .unwrap(), + )) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["applied"], true); + + // Row count preserved. + let db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap(); + let snap = db.snapshot_of(omnigraph::db::ReadTarget::branch("main")) + .await + .unwrap(); + let post_count = snap.entry("node:Person").expect("Person").row_count; + assert_eq!( + post_count, pre_count, + "AddProperty should preserve row count", + ); +} diff --git a/docs/user/schema-language.md b/docs/user/schema-language.md index 2394aaf..4250676 100644 --- a/docs/user/schema-language.md +++ b/docs/user/schema-language.md @@ -78,3 +78,11 @@ Edge bodies only allow `@unique` and `@index`. - `UnsupportedChange { entity, reason }` (forces `supported=false`) `apply_schema()` returns `SchemaApplyResult { supported, applied, manifest_version, steps }` and is gated by an internal `__schema_apply_lock__` system branch so concurrent schema applies serialize. + +## Destructive drops — `--allow-data-loss` + +`DropProperty` and `DropType` steps default to `Soft` mode: the catalog tombstones the entry but the prior column / dataset remains time-travel-reachable via `snapshot_at_version(prev)` until `omnigraph cleanup` runs. Soft drops are reversible. + +Pass `--allow-data-loss` (CLI) or `allow_data_loss: true` (HTTP `POST /schema/apply` body, SDK `SchemaApplyOptions`) to promote every drop in the plan to `Hard` mode. Hard drops run `cleanup_old_versions` on the affected dataset immediately after the manifest publish, making the prior column / dataset unreachable. **Irreversible.** + +The flag is honored uniformly across transports — `omnigraph schema apply --allow-data-loss`, `POST /schema/apply { schema_source, allow_data_loss: true }`, and `apply_schema_with_options(.., SchemaApplyOptions { allow_data_loss: true })` produce identical plans and identical effects. diff --git a/openapi.json b/openapi.json index ea62e31..b0ed1f2 100644 --- a/openapi.json +++ b/openapi.json @@ -1576,6 +1576,10 @@ "schema_source" ], "properties": { + "allow_data_loss": { + "type": "boolean", + "description": "When true, promote every `DropMode::Soft` step in the plan to\n`DropMode::Hard`, making the prior column data unreachable\nafter the apply. Matches the CLI's `--allow-data-loss` flag.\nDefaults to `false` (drops remain reversible via time travel)." + }, "schema_source": { "type": "string", "description": "Project schema in `.pg` source form. The diff against the current\nschema produces the migration steps that will be applied.",