fix(migrations): normalize legacy column order
CI / Rust Checks (push) Successful in 14m21s
CI / UI Checks (push) Successful in 6s
CI / Community Image Smoke (push) Successful in 6m14s
CI / Frontend E2E (push) Successful in 7m10s
CI / Deploy (push) Successful in 1m12s

This commit is contained in:
2026-09-02 01:01:58 +03:00
parent 2759fde81f
commit bbb9394911
2 changed files with 254 additions and 7 deletions
@@ -6,17 +6,15 @@ use super::authority::MigrationError;
// Exact PostgreSQL 16 catalog contract produced by the last published // Exact PostgreSQL 16 catalog contract produced by the last published
// pre-ledger Community schema (commit 8318e4b). // pre-ledger Community schema (commit 8318e4b).
const LEDGERLESS_BASELINE_FINGERPRINT_SHA256: &str = const LEDGERLESS_BASELINE_FINGERPRINT_SHA256: &str =
"0dbd7357a80c0d772de03ea2833013932697187b32fbd7b54be594e224e5f82f"; "36624ca42a28c1388f9c50e6d6c489a8e5ca1f73441a894d9af89e5e115b542c";
const LEDGERLESS_MCP_FINGERPRINTS_SHA256: &[&str] = &[ const LEDGERLESS_MCP_FINGERPRINTS_SHA256: &[&str] = &[
// Initial published session table. // Initial published session table.
"53b58899dc388609cbd83fe8321a48878d610952a39d13b5fc8f05149b703683", "9240c3d85dbc9eeddb1cd99661ec8f7d8d6ece0e62ec486151dcc951a7f3c81c",
// Published session table after supports_elicitation was added. // Published session table after supports_elicitation was added.
"250e3e57f02283e9300bdff549305d49a6a875717ec4e4c332a2b9af3f883f09", "ac9f99a7e667552a07480d5d45379dd0a8b529b00ba98855ce6725535198249f",
// Same published contract after an in-place upgrade from the initial layout.
"23c02db67cf83a3834b1ed7caa417eaafd7bcc69657d598ba831f06aeded4a62",
]; ];
const LEDGERLESS_EXTENSION_FINGERPRINT_SHA256: &str = const LEDGERLESS_EXTENSION_FINGERPRINT_SHA256: &str =
"da06947028e3c18cc6ba93a58775722b425fd9989095f069316a146a2f45e921"; "0809a80c0bb6e80f68d0557006c4b62f2e63556f61f6fdedc5abc324950e2587";
const BASELINE_RELATIONS: &[&str] = &[ const BASELINE_RELATIONS: &[&str] = &[
"workspaces", "workspaces",
@@ -134,7 +132,7 @@ async fn catalog_fingerprint(
where n.nspname = current_schema() where n.nspname = current_schema()
), column_rows as ( ), column_rows as (
select jsonb_build_array( select jsonb_build_array(
'column', c.table_name, c.ordinal_position, c.column_name, 'column', c.table_name, c.column_name,
c.data_type, c.udt_name, c.is_nullable, coalesce(c.column_default, '') c.data_type, c.udt_name, c.is_nullable, coalesce(c.column_default, '')
) item ) item
from information_schema.columns c from information_schema.columns c
@@ -105,6 +105,185 @@ async fn published_ledgerless_baseline_upgrades_without_data_loss() {
); );
} }
#[tokio::test]
async fn published_ledgerless_historical_column_layout_upgrades_without_data_loss() {
let database_url =
crank_test_support::postgres_schema_url("test_ledgerless_historical_layout").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::raw_sql(
"alter table platform_api_keys
drop column key_kind,
drop column expires_at,
drop column allowed_origins_json;
alter table platform_api_keys
add column key_kind text not null default 'mcp_client',
add column expires_at timestamptz null,
add column allowed_origins_json jsonb not null default '[]'::jsonb;
drop index approval_requests_pending_fingerprint_idx;
alter table approval_requests
drop column execution_started_at,
drop column execution_attempts,
drop column request_fingerprint,
add column confirmation_title text not null default '',
add column confirmation_body text not null default '';
alter table approval_requests
alter column confirmation_title drop default,
alter column confirmation_body drop default,
drop column confirmation_title,
drop column confirmation_body,
add column execution_started_at timestamptz null,
add column execution_attempts integer not null default 0,
add column request_fingerprint text null;
create unique index approval_requests_pending_fingerprint_idx
on approval_requests(agent_id, operation_id, operation_version, request_fingerprint)
where status = 'pending' and request_fingerprint is not null;
insert into operations
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
values ('op_historical_layout', 'ws_default', 'historical-layout', 'Historical layout', 'rest', 'draft', now(), now());
insert into agents
(id, workspace_id, slug, display_name, description, status, created_at, updated_at)
values ('agent_historical_layout', 'ws_default', 'historical-layout', 'Historical layout', '', 'draft', now(), now());
insert into platform_api_keys
(id, workspace_id, agent_id, name, prefix, secret_hash, key_kind, scopes_json, status,
created_at, expires_at, allowed_origins_json)
values ('key_historical_layout', 'ws_default', 'agent_historical_layout', 'Historical layout',
'cp_', 'hash', 'admin', '[]'::jsonb, 'active', now(),
'2030-01-02 03:04:05+00'::timestamptz, '[\"https://example.test\"]'::jsonb);
insert into approval_requests
(id, workspace_id, agent_id, operation_id, operation_version, status, risk_level,
request_payload_json, created_at, expires_at, execution_started_at,
execution_attempts, request_fingerprint)
values ('approval_historical_layout', 'ws_default', 'agent_historical_layout', 'op_historical_layout', 1,
'pending', 'high', '{\"layout\":\"historical\"}'::jsonb, now(), now() + interval '1 hour',
'2029-02-03 04:05:06+00'::timestamptz, 3, 'historical-fingerprint');",
)
.execute(&pool)
.await
.unwrap();
let historical_ordinals: Vec<(String, i32)> = sqlx::query_as(
"select table_name || '.' || column_name, ordinal_position
from information_schema.columns
where table_schema = current_schema()
and ((table_name = 'platform_api_keys'
and column_name in ('key_kind', 'expires_at', 'allowed_origins_json'))
or (table_name = 'approval_requests'
and column_name in ('execution_started_at', 'execution_attempts', 'request_fingerprint')))
order by table_name, ordinal_position",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
historical_ordinals,
vec![
("approval_requests.execution_started_at".to_owned(), 22),
("approval_requests.execution_attempts".to_owned(), 23),
("approval_requests.request_fingerprint".to_owned(), 24),
("platform_api_keys.key_kind".to_owned(), 15),
("platform_api_keys.expires_at".to_owned(), 16),
("platform_api_keys.allowed_origins_json".to_owned(), 17),
],
"fixture must reproduce the published in-place column layout",
);
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 0,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
let key_count: i64 = sqlx::query_scalar(
"select count(*) from platform_api_keys
where id = 'key_historical_layout'
and key_kind = 'admin'
and expires_at = '2030-01-02 03:04:05+00'::timestamptz
and allowed_origins_json = '[\"https://example.test\"]'::jsonb",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
key_count, 1,
"platform key row must survive the layout upgrade"
);
let approval_count: i64 = sqlx::query_scalar(
"select count(*) from approval_requests
where id = 'approval_historical_layout'
and request_payload_json = '{\"layout\":\"historical\"}'::jsonb
and execution_started_at = '2029-02-03 04:05:06+00'::timestamptz
and execution_attempts = 3
and request_fingerprint = 'historical-fingerprint'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
approval_count, 1,
"approval row must survive the layout upgrade"
);
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 13 }
);
}
#[tokio::test]
async fn published_initial_mcp_layout_is_accepted() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_initial_mcp").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::raw_sql(
"drop table mcp_transport_sessions;
create table mcp_transport_sessions (
id text primary key,
protocol_version text not null,
initialized boolean not null default false,
workspace_slug text not null,
agent_slug text not null,
created_at timestamptz not null,
updated_at timestamptz not null,
expires_at timestamptz null
);
create index mcp_transport_sessions_workspace_agent_idx
on mcp_transport_sessions(workspace_slug, agent_slug, updated_at desc);",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 0,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
let supports_elicitation: bool = sqlx::query_scalar(
"select exists (
select 1 from information_schema.columns
where table_schema = current_schema()
and table_name = 'mcp_transport_sessions'
and column_name = 'supports_elicitation'
)",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(supports_elicitation);
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 13 }
);
}
#[tokio::test] #[tokio::test]
async fn published_in_place_mcp_upgrade_layout_is_accepted() { async fn published_in_place_mcp_upgrade_layout_is_accepted() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_mcp_upgrade").await; let database_url = crank_test_support::postgres_schema_url("test_ledgerless_mcp_upgrade").await;
@@ -167,8 +346,78 @@ async fn ledgerless_baseline_with_future_drift_is_rejected_without_writes() {
assert!(!core_exists, "rejected adoption must remain read-only"); assert!(!core_exists, "rejected adoption must remain read-only");
} }
#[tokio::test]
async fn ledgerless_baseline_with_default_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_default").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query("alter table workspaces alter column status set default 'active'")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test] #[tokio::test]
async fn ledgerless_baseline_with_constraint_drift_is_rejected_without_writes() { async fn ledgerless_baseline_with_constraint_drift_is_rejected_without_writes() {
let database_url =
crank_test_support::postgres_schema_url("test_ledgerless_constraint_definition").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query(
"alter table workspaces
add constraint workspaces_status_nonempty check (status <> '')",
)
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn ledgerless_baseline_with_rls_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_rls").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query("alter table workspaces enable row level security")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn ledgerless_baseline_with_nullability_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_constraint").await; let database_url = crank_test_support::postgres_schema_url("test_ledgerless_constraint").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await; ledgerless_v1(&pool).await;