schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
Some checks failed
CI / Classify Changes (push) Has been cancelled
CI / Check AGENTS.md Links (push) Has been cancelled
Release Edge / Prepare edge release (push) Has been cancelled
CI / Test Workspace (push) Has been cancelled
CI / Test omnigraph-server --features aws (push) Has been cancelled
CI / RustFS S3 Integration (push) Has been cancelled
Release Edge / Build edge omnigraph-linux-x86_64 (push) Has been cancelled
Release Edge / Build edge omnigraph-macos-arm64 (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Andrew Altshuler 2026-05-19 01:56:46 +03:00 committed by GitHub
parent e8fec2fa0f
commit aadfa11ecb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 478 additions and 8 deletions

View file

@ -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::<SchemaApplyOutput>(
&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(),
)

View file

@ -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);
}

View file

@ -262,12 +262,18 @@ pub struct ChangeRequest {
pub branch: Option<String>,
}
#[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)]

View file

@ -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

View file

@ -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::<Vec<_>>();
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",
);
}

View file

@ -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.

View file

@ -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.",