fix(migrations): normalize legacy column order
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user