fix(migrations): normalize legacy column order
This commit is contained in:
@@ -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]
|
||||
async fn published_in_place_mcp_upgrade_layout_is_accepted() {
|
||||
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");
|
||||
}
|
||||
|
||||
#[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]
|
||||
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 pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
ledgerless_v1(&pool).await;
|
||||
|
||||
Reference in New Issue
Block a user