feat: harden community production foundation through story 1.5
This commit is contained in:
@@ -2,6 +2,8 @@ use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RegistryError {
|
||||
#[error(transparent)]
|
||||
Migration(#[from] crate::migrations::MigrationError),
|
||||
#[error(transparent)]
|
||||
Storage(#[from] sqlx::Error),
|
||||
#[error(transparent)]
|
||||
@@ -80,4 +82,6 @@ pub enum RegistryError {
|
||||
InvalidEnumRepresentation { field: &'static str },
|
||||
#[error("invalid numeric value for field {field}: {value}")]
|
||||
InvalidNumericValue { field: &'static str, value: i64 },
|
||||
#[error("invalid correlation identity for field {field}")]
|
||||
InvalidCorrelationIdentity { field: &'static str },
|
||||
}
|
||||
|
||||
@@ -1,77 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::{PgPool, query};
|
||||
|
||||
use crate::RegistryError;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ExtensionMigration {
|
||||
pub version: u32,
|
||||
pub sql: &'static str,
|
||||
pub checksum: &'static str,
|
||||
pub source_digest: &'static str,
|
||||
pub phase: &'static str,
|
||||
pub compatibility: &'static str,
|
||||
pub owner: &'static str,
|
||||
}
|
||||
|
||||
pub trait RegistryExtension: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn migrations(&self) -> &[ExtensionMigration];
|
||||
}
|
||||
|
||||
pub async fn apply_extension_migrations(
|
||||
pool: &PgPool,
|
||||
extensions: &[Arc<dyn RegistryExtension>],
|
||||
) -> Result<(), RegistryError> {
|
||||
query(
|
||||
"create table if not exists __crank_ext_migrations (
|
||||
extension_name text not null,
|
||||
version integer not null,
|
||||
applied_at timestamptz not null default now(),
|
||||
primary key (extension_name, version)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
for extension in extensions {
|
||||
for migration in extension.migrations() {
|
||||
let already_applied = query(
|
||||
"select 1
|
||||
from __crank_ext_migrations
|
||||
where extension_name = $1 and version = $2",
|
||||
)
|
||||
.bind(extension.name())
|
||||
.bind(i32::try_from(migration.version).map_err(|_| {
|
||||
RegistryError::InvalidNumericValue {
|
||||
field: "extension_migration.version",
|
||||
value: migration.version as i64,
|
||||
}
|
||||
})?)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.is_some();
|
||||
|
||||
if already_applied {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
query(migration.sql).execute(&mut *tx).await?;
|
||||
query(
|
||||
"insert into __crank_ext_migrations (extension_name, version)
|
||||
values ($1, $2)",
|
||||
)
|
||||
.bind(extension.name())
|
||||
.bind(i32::try_from(migration.version).map_err(|_| {
|
||||
RegistryError::InvalidNumericValue {
|
||||
field: "extension_migration.version",
|
||||
value: migration.version as i64,
|
||||
}
|
||||
})?)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ mod model;
|
||||
mod postgres;
|
||||
|
||||
pub use error::RegistryError;
|
||||
pub use ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations};
|
||||
pub use ext::{ExtensionMigration, RegistryExtension};
|
||||
pub use migrations::{
|
||||
BackfillBatch, BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationDescriptor,
|
||||
MigrationError, MigrationPreflight,
|
||||
};
|
||||
|
||||
pub mod records {
|
||||
pub use crate::model::{
|
||||
@@ -39,8 +43,12 @@ pub mod requests {
|
||||
}
|
||||
|
||||
pub mod infrastructure {
|
||||
pub use crate::ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations};
|
||||
pub use crate::ext::{ExtensionMigration, RegistryExtension};
|
||||
pub use crate::postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
|
||||
pub use crate::{
|
||||
BackfillBatch, BackfillPolicy, MigrationApplyResult, MigrationAuthority,
|
||||
MigrationDescriptor, MigrationError, MigrationPreflight,
|
||||
};
|
||||
}
|
||||
|
||||
pub use model::{
|
||||
|
||||
@@ -1,721 +1,10 @@
|
||||
use sqlx::{PgPool, Postgres, Row, Transaction, query};
|
||||
mod authority;
|
||||
mod baseline_v1;
|
||||
mod schema_guard;
|
||||
|
||||
const CORE_MIGRATION_LOCK_ID: i64 = 0x43_52_41_4E_4B;
|
||||
const BASELINE_VERSION: i32 = 1;
|
||||
const BASELINE_CHECKSUM: &str = "crank-community-baseline-v1";
|
||||
pub use authority::{
|
||||
BackfillBatch, BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationDescriptor,
|
||||
MigrationError, MigrationPreflight,
|
||||
};
|
||||
|
||||
pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
let mut transaction = pool.begin().await?;
|
||||
query("select pg_advisory_xact_lock($1)")
|
||||
.bind(CORE_MIGRATION_LOCK_ID)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create table if not exists __crank_core_migrations (
|
||||
version integer primary key,
|
||||
description text not null,
|
||||
checksum text not null,
|
||||
applied_at timestamptz not null default now()
|
||||
)",
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let applied = query(
|
||||
"select version, checksum
|
||||
from __crank_core_migrations
|
||||
order by version",
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
for row in &applied {
|
||||
let version = row.try_get::<i32, _>("version")?;
|
||||
let checksum = row.try_get::<String, _>("checksum")?;
|
||||
if version != BASELINE_VERSION || checksum != BASELINE_CHECKSUM {
|
||||
return Err(sqlx::Error::Protocol(format!(
|
||||
"unsupported or modified core migration: version={version}, checksum={checksum}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if applied.is_empty() {
|
||||
apply_baseline(&mut transaction).await?;
|
||||
query(
|
||||
"insert into __crank_core_migrations (version, description, checksum)
|
||||
values ($1, $2, $3)",
|
||||
)
|
||||
.bind(BASELINE_VERSION)
|
||||
.bind("community baseline")
|
||||
.bind(BASELINE_CHECKSUM)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_baseline(transaction: &mut Transaction<'_, Postgres>) -> Result<(), sqlx::Error> {
|
||||
query(
|
||||
"create table if not exists workspaces (
|
||||
id text primary key,
|
||||
slug text not null unique,
|
||||
display_name text not null,
|
||||
status text not null,
|
||||
settings_json jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists users (
|
||||
id text primary key,
|
||||
email text not null unique,
|
||||
display_name text not null,
|
||||
password_hash text null,
|
||||
status text not null,
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table users add column if not exists password_hash text null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into users (
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
status,
|
||||
created_at
|
||||
) values (
|
||||
'user_default_owner',
|
||||
'owner@crank.local',
|
||||
'Workspace Owner',
|
||||
'active',
|
||||
now()
|
||||
)
|
||||
on conflict (id) do nothing",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists memberships (
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
user_id text not null references users(id) on delete cascade,
|
||||
role text not null,
|
||||
created_at timestamptz not null,
|
||||
primary key (workspace_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists user_sessions (
|
||||
id text primary key,
|
||||
user_id text not null references users(id) on delete cascade,
|
||||
current_workspace_id text null references workspaces(id) on delete set null,
|
||||
secret_hash text not null,
|
||||
status text not null,
|
||||
expires_at timestamptz not null,
|
||||
last_seen_at timestamptz null,
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"alter table user_sessions
|
||||
add column if not exists current_workspace_id text null references workspaces(id) on delete set null",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into workspaces (
|
||||
id,
|
||||
slug,
|
||||
display_name,
|
||||
status,
|
||||
settings_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
'ws_default',
|
||||
'default',
|
||||
'Default Workspace',
|
||||
'active',
|
||||
'{}'::jsonb,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
on conflict (id) do nothing",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into memberships (
|
||||
workspace_id,
|
||||
user_id,
|
||||
role,
|
||||
created_at
|
||||
) values (
|
||||
'ws_default',
|
||||
'user_default_owner',
|
||||
'owner',
|
||||
now()
|
||||
)
|
||||
on conflict (workspace_id, user_id) do nothing",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists invitation_tokens (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
email text not null,
|
||||
role text not null,
|
||||
status text not null,
|
||||
token_hash text not null,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists platform_api_keys (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text null,
|
||||
name text not null,
|
||||
prefix text not null,
|
||||
secret_hash text not null,
|
||||
key_kind text not null default 'mcp_client',
|
||||
scopes_json jsonb not null,
|
||||
status text not null,
|
||||
created_at timestamptz not null,
|
||||
last_used_at timestamptz null,
|
||||
revoked_at timestamptz null,
|
||||
expires_at timestamptz null,
|
||||
allowed_origins_json jsonb not null default '[]'::jsonb
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists platform_api_keys_workspace_name_idx on platform_api_keys(workspace_id, name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table platform_api_keys add column if not exists agent_id text null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table platform_api_keys add column if not exists key_kind text not null default 'mcp_client'",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table platform_api_keys add column if not exists expires_at timestamptz null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table platform_api_keys add column if not exists allowed_origins_json jsonb not null default '[]'::jsonb",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into workspaces (
|
||||
id,
|
||||
slug,
|
||||
display_name,
|
||||
status,
|
||||
settings_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
'ws_default',
|
||||
'default',
|
||||
'Default Workspace',
|
||||
'active',
|
||||
'{}'::jsonb,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
on conflict (id) do nothing",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists operations (
|
||||
id text primary key,
|
||||
workspace_id text null references workspaces(id) on delete cascade,
|
||||
name text not null,
|
||||
display_name text not null,
|
||||
category text not null default 'general',
|
||||
protocol text not null,
|
||||
security_level text not null default 'standard',
|
||||
status text not null,
|
||||
current_draft_version integer not null default 1,
|
||||
latest_published_version integer null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null,
|
||||
published_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table operations add column if not exists workspace_id text null references workspaces(id) on delete cascade")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table operations add column if not exists category text not null default 'general'",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table operations add column if not exists security_level text not null default 'standard'",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("update operations set workspace_id = 'ws_default' where workspace_id is null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table operations alter column workspace_id set not null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table operations drop constraint if exists operations_name_key")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists operations_workspace_name_idx on operations(workspace_id, name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists operation_versions (
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
version integer not null,
|
||||
status text not null,
|
||||
target_json jsonb not null,
|
||||
input_schema_json jsonb not null,
|
||||
output_schema_json jsonb not null,
|
||||
input_mapping_json jsonb not null,
|
||||
output_mapping_json jsonb not null,
|
||||
execution_config_json jsonb not null,
|
||||
tool_description_json jsonb not null,
|
||||
samples_json jsonb null,
|
||||
generated_draft_json jsonb null,
|
||||
config_export_json jsonb null,
|
||||
wizard_state_json jsonb null,
|
||||
change_note text null,
|
||||
created_at timestamptz not null,
|
||||
created_by text null,
|
||||
primary key (operation_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table operation_versions add column if not exists wizard_state_json jsonb null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists published_operations (
|
||||
operation_id text primary key references operations(id) on delete cascade,
|
||||
version integer not null,
|
||||
published_at timestamptz not null,
|
||||
published_by text null,
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists operation_samples (
|
||||
id text primary key,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
version integer not null,
|
||||
sample_kind text not null,
|
||||
storage_ref text not null,
|
||||
content_type text not null,
|
||||
file_name text null,
|
||||
created_at timestamptz not null,
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists descriptors (
|
||||
id text primary key,
|
||||
operation_id text null references operations(id) on delete cascade,
|
||||
version integer null,
|
||||
descriptor_kind text not null,
|
||||
storage_ref text not null,
|
||||
source_name text null,
|
||||
package_index_json jsonb null,
|
||||
created_at timestamptz not null,
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists auth_profiles (
|
||||
id text primary key,
|
||||
workspace_id text null references workspaces(id) on delete cascade,
|
||||
name text not null,
|
||||
kind text not null,
|
||||
config_json jsonb not null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table auth_profiles add column if not exists workspace_id text null references workspaces(id) on delete cascade")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("update auth_profiles set workspace_id = 'ws_default' where workspace_id is null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table auth_profiles alter column workspace_id set not null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table auth_profiles drop constraint if exists auth_profiles_name_key")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists auth_profiles_workspace_name_idx on auth_profiles(workspace_id, name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists workspace_upstreams (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
name text not null,
|
||||
base_url text not null,
|
||||
static_headers_json jsonb not null default '{}'::jsonb,
|
||||
auth_profile_id text null references auth_profiles(id) on delete set null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists workspace_upstreams_workspace_name_idx on workspace_upstreams(workspace_id, name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists workspace_upstreams_workspace_base_auth_idx on workspace_upstreams(workspace_id, base_url, coalesce(auth_profile_id, ''))",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"insert into workspace_upstreams (
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
base_url,
|
||||
static_headers_json,
|
||||
auth_profile_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
select
|
||||
'upstream_frankfurter_' || w.id,
|
||||
w.id,
|
||||
'Frankfurter',
|
||||
'https://api.frankfurter.dev',
|
||||
'{}'::jsonb,
|
||||
null,
|
||||
now(),
|
||||
now()
|
||||
from workspaces w
|
||||
where not exists (
|
||||
select 1
|
||||
from workspace_upstreams wu
|
||||
where wu.workspace_id = w.id
|
||||
and wu.name = 'Frankfurter'
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists secrets (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
name text not null,
|
||||
kind text not null,
|
||||
status text not null,
|
||||
current_version integer not null,
|
||||
last_used_at timestamptz null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists secrets_workspace_name_idx on secrets(workspace_id, name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists secret_versions (
|
||||
secret_id text not null references secrets(id) on delete cascade,
|
||||
version integer not null,
|
||||
ciphertext text not null,
|
||||
key_version text not null,
|
||||
created_at timestamptz not null,
|
||||
created_by text null references users(id) on delete set null,
|
||||
primary key (secret_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists yaml_import_jobs (
|
||||
id text primary key,
|
||||
source_sample_id text null references operation_samples(id) on delete set null,
|
||||
status text not null,
|
||||
format_version text not null,
|
||||
mode text not null,
|
||||
result_operation_id text null references operations(id) on delete set null,
|
||||
result_version integer null,
|
||||
error_text text null,
|
||||
created_at timestamptz not null,
|
||||
finished_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists import_jobs (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
kind text not null,
|
||||
source_format text not null,
|
||||
source_version text null,
|
||||
status text not null,
|
||||
preview_payload jsonb not null,
|
||||
created_operation_ids jsonb not null default '[]'::jsonb,
|
||||
error_text text null,
|
||||
created_at timestamptz not null,
|
||||
expires_at timestamptz not null,
|
||||
finished_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists agents (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
slug text not null,
|
||||
display_name text not null,
|
||||
description text not null,
|
||||
status text not null,
|
||||
current_draft_version integer not null default 1,
|
||||
latest_published_version integer null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null,
|
||||
published_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists agents_workspace_slug_idx on agents(workspace_id, slug)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists agent_versions (
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
version integer not null,
|
||||
status text not null,
|
||||
instructions_json jsonb not null,
|
||||
tool_selection_policy_json jsonb not null,
|
||||
created_at timestamptz not null,
|
||||
primary key (agent_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists agent_operation_bindings (
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
agent_version integer not null,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
operation_version integer not null,
|
||||
tool_name text not null,
|
||||
tool_title text not null,
|
||||
tool_description_override text null,
|
||||
enabled boolean not null default true,
|
||||
foreign key (agent_id, agent_version) references agent_versions(agent_id, version) on delete cascade,
|
||||
foreign key (operation_id, operation_version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists agent_bindings_tool_name_idx on agent_operation_bindings(agent_id, agent_version, tool_name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists published_agents (
|
||||
agent_id text primary key references agents(id) on delete cascade,
|
||||
version integer not null,
|
||||
published_at timestamptz not null,
|
||||
published_by text null,
|
||||
foreign key (agent_id, version) references agent_versions(agent_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists approval_requests (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
operation_version integer not null,
|
||||
status text not null,
|
||||
risk_level text not null,
|
||||
request_payload_json jsonb not null,
|
||||
response_payload_json jsonb null,
|
||||
created_at timestamptz not null,
|
||||
expires_at timestamptz not null,
|
||||
decided_at timestamptz null,
|
||||
decided_by_key_id text null references platform_api_keys(id) on delete set null,
|
||||
decision_note text null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table approval_requests add column if not exists execution_started_at timestamptz null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table approval_requests add column if not exists execution_attempts integer not null default 0")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table approval_requests add column if not exists request_fingerprint text null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists 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",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create index if not exists approval_requests_agent_status_idx
|
||||
on approval_requests(workspace_id, agent_id, status, expires_at)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists invocation_logs (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text null references agents(id) on delete set null,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
source text not null,
|
||||
level text not null,
|
||||
status text not null,
|
||||
tool_name text not null,
|
||||
message text not null,
|
||||
request_id text null,
|
||||
status_code integer null,
|
||||
duration_ms bigint not null,
|
||||
error_kind text null,
|
||||
request_preview_json jsonb not null,
|
||||
response_preview_json jsonb not null,
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists invocation_logs_workspace_created_idx on invocation_logs(workspace_id, created_at desc)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists invocation_logs_workspace_operation_created_idx on invocation_logs(workspace_id, operation_id, created_at desc)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists invocation_logs_workspace_agent_created_idx on invocation_logs(workspace_id, agent_id, created_at desc)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists usage_rollups (
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text null references agents(id) on delete cascade,
|
||||
operation_id text null references operations(id) on delete cascade,
|
||||
period text not null,
|
||||
calls_total bigint not null,
|
||||
calls_ok bigint not null,
|
||||
calls_error bigint not null,
|
||||
p50_ms bigint not null,
|
||||
p95_ms bigint not null,
|
||||
p99_ms bigint not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
use baseline_v1::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline};
|
||||
|
||||
@@ -0,0 +1,926 @@
|
||||
use std::fmt;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
|
||||
|
||||
use super::schema_guard::{
|
||||
OWNED_RELATIONS, relation_exists, validate_required_relations, validate_schema_fingerprint,
|
||||
};
|
||||
use super::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline};
|
||||
use crate::ext::ExtensionMigration;
|
||||
|
||||
const MIGRATION_LOCK_ID: i64 = 0x4352_414E_4B4D_4947;
|
||||
const CURRENT_VERSION: i64 = 3;
|
||||
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3];
|
||||
const BASELINE_SOURCE_SHA256: &str =
|
||||
"eb1656fc5b4b5be9ee390d237d1d58e4b2274ae5ba9b7ba06a2f3f860dfda675";
|
||||
const CONSOLIDATION_SOURCE: &str = include_str!("consolidation_v2.sql");
|
||||
const CONSOLIDATION_SOURCE_SHA256: &str =
|
||||
"1908b97ca7fe8a85d146b0eebf007d5018a9ff406cb099614270f3428ec1db48";
|
||||
const REQUEST_TRACE_IDENTITY_SOURCE: &str = include_str!("request_trace_identity_v3.sql");
|
||||
const REQUEST_TRACE_IDENTITY_SOURCE_SHA256: &str =
|
||||
"36487625503a8d4d8f18d5771c3c9b6705f845e267acbfcb7244c330f640cd94";
|
||||
const BASELINE_RELATIONS: &[&str] = &[
|
||||
"workspaces",
|
||||
"users",
|
||||
"memberships",
|
||||
"user_sessions",
|
||||
"invitation_tokens",
|
||||
"platform_api_keys",
|
||||
"operations",
|
||||
"operation_versions",
|
||||
"published_operations",
|
||||
"operation_samples",
|
||||
"descriptors",
|
||||
"agents",
|
||||
"agent_versions",
|
||||
"published_agents",
|
||||
"agent_operation_bindings",
|
||||
"secrets",
|
||||
"secret_versions",
|
||||
"auth_profiles",
|
||||
"workspace_upstreams",
|
||||
"yaml_import_jobs",
|
||||
"import_jobs",
|
||||
"approval_requests",
|
||||
"invocation_logs",
|
||||
"usage_rollups",
|
||||
];
|
||||
const CONSOLIDATION_RELATIONS: &[&str] = &[
|
||||
"__crank_migrations",
|
||||
"__crank_migration_legacy_audit",
|
||||
"__crank_mcp_migrations",
|
||||
"mcp_transport_sessions",
|
||||
"__crank_ext_migrations",
|
||||
];
|
||||
const REGISTERED_EXTENSION_MIGRATIONS: &[(&str, ExtensionMigration)] = &[];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MigrationDescriptor {
|
||||
pub version: i64,
|
||||
pub name: &'static str,
|
||||
pub checksum: String,
|
||||
pub source_digest: String,
|
||||
pub phase: &'static str,
|
||||
pub compatibility: &'static str,
|
||||
pub owner: &'static str,
|
||||
pub transactional: bool,
|
||||
pub backfill: BackfillPolicy,
|
||||
pub readable_schema_min: i64,
|
||||
pub readable_schema_max: i64,
|
||||
pub contract_evidence: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BackfillPolicy {
|
||||
None,
|
||||
Bounded {
|
||||
max_batch_rows: u32,
|
||||
max_batch_ms: u32,
|
||||
resumable: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BackfillBatch {
|
||||
pub cursor: Option<String>,
|
||||
pub max_rows: u32,
|
||||
pub max_ms: u32,
|
||||
}
|
||||
|
||||
impl BackfillBatch {
|
||||
pub fn validate(&self, policy: BackfillPolicy) -> Result<(), MigrationError> {
|
||||
match policy {
|
||||
BackfillPolicy::Bounded {
|
||||
max_batch_rows,
|
||||
max_batch_ms,
|
||||
resumable: true,
|
||||
} if (1..=max_batch_rows).contains(&self.max_rows)
|
||||
&& (1..=max_batch_ms).contains(&self.max_ms)
|
||||
&& self
|
||||
.cursor
|
||||
.as_ref()
|
||||
.is_none_or(|cursor| cursor.len() <= 256) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
"contract.backfill",
|
||||
None,
|
||||
"contact_operator",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MigrationPreflight {
|
||||
Current { version: i64 },
|
||||
MigrationRequired { current: i64, target: i64 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MigrationApplyResult {
|
||||
Applied { from: i64, to: i64 },
|
||||
AlreadyCurrent { version: i64 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MigrationError {
|
||||
code: &'static str,
|
||||
stage: &'static str,
|
||||
version: Option<i64>,
|
||||
recovery: &'static str,
|
||||
}
|
||||
|
||||
impl MigrationError {
|
||||
pub(super) fn new(
|
||||
code: &'static str,
|
||||
stage: &'static str,
|
||||
version: Option<i64>,
|
||||
recovery: &'static str,
|
||||
) -> Self {
|
||||
Self {
|
||||
code,
|
||||
stage,
|
||||
version,
|
||||
recovery,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn storage(stage: &'static str) -> Self {
|
||||
Self::new("storage_unavailable", stage, None, "contact_operator")
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &'static str {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn stage(&self) -> &'static str {
|
||||
self.stage
|
||||
}
|
||||
|
||||
pub fn version(&self) -> Option<i64> {
|
||||
self.version
|
||||
}
|
||||
|
||||
pub fn recovery(&self) -> &'static str {
|
||||
self.recovery
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MigrationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"migration_error code={} stage={} version={} recovery={}",
|
||||
self.code,
|
||||
self.stage,
|
||||
self.version
|
||||
.map_or_else(|| "none".to_owned(), |value| value.to_string()),
|
||||
self.recovery
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MigrationError {}
|
||||
|
||||
pub struct MigrationAuthority;
|
||||
|
||||
impl MigrationAuthority {
|
||||
pub fn registered_extension_migrations() -> &'static [(&'static str, ExtensionMigration)] {
|
||||
REGISTERED_EXTENSION_MIGRATIONS
|
||||
}
|
||||
|
||||
pub fn sequence() -> Vec<MigrationDescriptor> {
|
||||
vec![
|
||||
MigrationDescriptor {
|
||||
version: i64::from(BASELINE_VERSION),
|
||||
name: "community-baseline-v1",
|
||||
checksum: BASELINE_CHECKSUM.to_owned(),
|
||||
source_digest: BASELINE_SOURCE_SHA256.to_owned(),
|
||||
phase: "expand",
|
||||
compatibility: "legacy-baseline",
|
||||
owner: "crank-registry",
|
||||
transactional: true,
|
||||
backfill: BackfillPolicy::None,
|
||||
readable_schema_min: 1,
|
||||
readable_schema_max: 1,
|
||||
contract_evidence: None,
|
||||
},
|
||||
MigrationDescriptor {
|
||||
version: 2,
|
||||
name: "legacy-consolidation-v2",
|
||||
checksum: CONSOLIDATION_SOURCE_SHA256.to_owned(),
|
||||
source_digest: CONSOLIDATION_SOURCE_SHA256.to_owned(),
|
||||
phase: "expand",
|
||||
compatibility: "n-minus-one-readable",
|
||||
owner: "crank-registry",
|
||||
transactional: true,
|
||||
backfill: BackfillPolicy::None,
|
||||
readable_schema_min: 1,
|
||||
readable_schema_max: 2,
|
||||
contract_evidence: None,
|
||||
},
|
||||
MigrationDescriptor {
|
||||
version: 3,
|
||||
name: "request-trace-identity-v3",
|
||||
checksum: REQUEST_TRACE_IDENTITY_SOURCE_SHA256.to_owned(),
|
||||
source_digest: REQUEST_TRACE_IDENTITY_SOURCE_SHA256.to_owned(),
|
||||
phase: "expand",
|
||||
compatibility: "n-minus-one-readable",
|
||||
owner: "crank-registry",
|
||||
transactional: true,
|
||||
backfill: BackfillPolicy::None,
|
||||
readable_schema_min: 2,
|
||||
readable_schema_max: 3,
|
||||
contract_evidence: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn validate_sequence() -> Result<(), MigrationError> {
|
||||
validate_descriptors(&Self::sequence())
|
||||
}
|
||||
|
||||
pub async fn preflight(pool: &PgPool) -> Result<MigrationPreflight, MigrationError> {
|
||||
Self::validate_sequence()?;
|
||||
let mut connection = pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.connect"))?;
|
||||
inspect(&mut connection).await
|
||||
}
|
||||
|
||||
pub async fn require_current(pool: &PgPool) -> Result<(), MigrationError> {
|
||||
match Self::preflight(pool).await? {
|
||||
MigrationPreflight::Current { .. } => Ok(()),
|
||||
MigrationPreflight::MigrationRequired { current: 0, .. } => Err(MigrationError::new(
|
||||
"schema_missing",
|
||||
"preflight.compatibility",
|
||||
Some(0),
|
||||
"run_controlled_migration",
|
||||
)),
|
||||
MigrationPreflight::MigrationRequired { current, .. } => Err(MigrationError::new(
|
||||
"migration_required",
|
||||
"preflight.compatibility",
|
||||
Some(current),
|
||||
"run_controlled_migration",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply(pool: &PgPool) -> Result<MigrationApplyResult, MigrationError> {
|
||||
Self::validate_sequence()?;
|
||||
let mut transaction = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("apply.begin"))?;
|
||||
query("set local lock_timeout = '30s'")
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("apply.lock_policy"))?;
|
||||
query("select pg_advisory_xact_lock($1)")
|
||||
.bind(MIGRATION_LOCK_ID)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error
|
||||
.as_database_error()
|
||||
.and_then(|database| database.code())
|
||||
.is_some_and(|code| matches!(code.as_ref(), "55P03" | "57014"))
|
||||
{
|
||||
MigrationError::new("lock_timeout", "apply.lock", None, "run_preflight")
|
||||
} else {
|
||||
MigrationError::storage("apply.lock")
|
||||
}
|
||||
})?;
|
||||
|
||||
let before = inspect(&mut transaction).await?;
|
||||
let from = match before {
|
||||
MigrationPreflight::Current { version } => {
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("apply.commit"))?;
|
||||
return Ok(MigrationApplyResult::AlreadyCurrent { version });
|
||||
}
|
||||
MigrationPreflight::MigrationRequired { current, .. } => current,
|
||||
};
|
||||
|
||||
if from == 0 {
|
||||
create_core_ledger(&mut transaction).await?;
|
||||
apply_baseline(&mut transaction).await.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.baseline",
|
||||
Some(1),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_core_migrations (version, description, checksum)
|
||||
values (1, 'community baseline', $1)",
|
||||
)
|
||||
.bind(BASELINE_CHECKSUM)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.baseline_ledger",
|
||||
Some(1),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if from < 2 {
|
||||
apply_consolidation(&mut transaction).await?;
|
||||
}
|
||||
if from < 3 {
|
||||
apply_request_trace_identity(&mut transaction).await?;
|
||||
}
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("apply.commit"))?;
|
||||
Ok(MigrationApplyResult::Applied {
|
||||
from,
|
||||
to: CURRENT_VERSION,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn baseline_source_digest() -> String {
|
||||
let source = include_str!("baseline_v1.rs");
|
||||
let (_, baseline) = source
|
||||
.split_once("// baseline-v1:start\n")
|
||||
.expect("baseline start marker must exist");
|
||||
let (baseline, _) = baseline
|
||||
.split_once("// baseline-v1:end")
|
||||
.expect("baseline end marker must exist");
|
||||
sha256_hex(baseline.as_bytes())
|
||||
}
|
||||
|
||||
fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), MigrationError> {
|
||||
if descriptors.is_empty() || descriptors.len() > 1_024 {
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
"contract.sequence",
|
||||
None,
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
for (index, descriptor) in descriptors.iter().enumerate() {
|
||||
let expected_version = i64::try_from(index + 1).unwrap_or(i64::MAX);
|
||||
let valid_name = !descriptor.name.is_empty()
|
||||
&& descriptor.name.len() <= 128
|
||||
&& descriptor
|
||||
.name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& !descriptors[..index]
|
||||
.iter()
|
||||
.any(|prior| prior.name == descriptor.name);
|
||||
let valid_source_digest = descriptor.source_digest.len() == 64
|
||||
&& descriptor
|
||||
.source_digest
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
|
||||
let valid_checksum = if descriptor.version == 1 {
|
||||
descriptor.checksum == BASELINE_CHECKSUM
|
||||
} else {
|
||||
descriptor.checksum == descriptor.source_digest
|
||||
};
|
||||
let valid_window = descriptor.readable_schema_min >= 1
|
||||
&& descriptor.readable_schema_min <= descriptor.readable_schema_max
|
||||
&& descriptor.readable_schema_max <= descriptor.version;
|
||||
let valid_phase = match descriptor.phase {
|
||||
"expand" => descriptor.backfill == BackfillPolicy::None,
|
||||
"migrate" => matches!(
|
||||
descriptor.backfill,
|
||||
BackfillPolicy::Bounded {
|
||||
max_batch_rows: 1..=10_000,
|
||||
max_batch_ms: 1..=60_000,
|
||||
resumable: true,
|
||||
}
|
||||
),
|
||||
"contract" => {
|
||||
descriptor.compatibility == "window-closed"
|
||||
&& descriptor.contract_evidence.is_some_and(|evidence| {
|
||||
!evidence.is_empty()
|
||||
&& evidence.len() <= 256
|
||||
&& !evidence.starts_with('/')
|
||||
&& !evidence.contains("..")
|
||||
})
|
||||
&& descriptor.backfill == BackfillPolicy::None
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let valid_compatibility = matches!(
|
||||
descriptor.compatibility,
|
||||
"legacy-baseline" | "n-minus-one-readable" | "window-open" | "window-closed"
|
||||
);
|
||||
let valid_owner = !descriptor.owner.is_empty()
|
||||
&& descriptor.owner.len() <= 128
|
||||
&& descriptor
|
||||
.owner
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
|
||||
if descriptor.version != expected_version
|
||||
|| !valid_name
|
||||
|| !valid_source_digest
|
||||
|| !valid_checksum
|
||||
|| !valid_phase
|
||||
|| !valid_compatibility
|
||||
|| !valid_owner
|
||||
|| !valid_window
|
||||
|| !descriptor.transactional
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
"contract.sequence",
|
||||
Some(descriptor.version),
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
}
|
||||
if descriptors.len() != usize::try_from(CURRENT_VERSION).unwrap_or_default()
|
||||
|| descriptors
|
||||
.iter()
|
||||
.map(|descriptor| descriptor.version)
|
||||
.ne(IMPLEMENTED_VERSIONS.iter().copied())
|
||||
|| sha256_hex(CONSOLIDATION_SOURCE.as_bytes()) != CONSOLIDATION_SOURCE_SHA256
|
||||
|| sha256_hex(REQUEST_TRACE_IDENTITY_SOURCE.as_bytes())
|
||||
!= REQUEST_TRACE_IDENTITY_SOURCE_SHA256
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
"contract.implementation",
|
||||
Some(CURRENT_VERSION),
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, MigrationError> {
|
||||
let core_exists = relation_exists(connection, "__crank_core_migrations").await?;
|
||||
let canonical_exists = relation_exists(connection, "__crank_migrations").await?;
|
||||
|
||||
if !core_exists {
|
||||
let mut owned_exists = canonical_exists;
|
||||
for relation in OWNED_RELATIONS {
|
||||
owned_exists |= relation_exists(connection, relation).await?;
|
||||
}
|
||||
if owned_exists {
|
||||
return Err(MigrationError::new(
|
||||
"partial_sequence",
|
||||
"preflight.core",
|
||||
None,
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
inspect_optional_legacy(connection).await?;
|
||||
return Ok(MigrationPreflight::MigrationRequired {
|
||||
current: 0,
|
||||
target: CURRENT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
validate_core_ledger(connection).await?;
|
||||
validate_required_relations(connection, BASELINE_RELATIONS, 1).await?;
|
||||
inspect_optional_legacy(connection).await?;
|
||||
|
||||
if !canonical_exists {
|
||||
return Ok(MigrationPreflight::MigrationRequired {
|
||||
current: 1,
|
||||
target: CURRENT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
let descriptors = MigrationAuthority::sequence();
|
||||
let rows = query("select version, name, checksum, phase, compatibility from __crank_migrations order by version limit 1025")
|
||||
.fetch_all(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.canonical"))?;
|
||||
if rows.is_empty() {
|
||||
return Err(MigrationError::new(
|
||||
"partial_sequence",
|
||||
"preflight.canonical",
|
||||
None,
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
let version = row
|
||||
.try_get::<i64, _>("version")
|
||||
.map_err(|_| MigrationError::storage("preflight.canonical"))?;
|
||||
if version > CURRENT_VERSION {
|
||||
return Err(MigrationError::new(
|
||||
"future_version",
|
||||
"preflight.canonical",
|
||||
Some(version),
|
||||
"install_matching_application",
|
||||
));
|
||||
}
|
||||
let Some(expected) = descriptors.get(index) else {
|
||||
return Err(MigrationError::new(
|
||||
"future_version",
|
||||
"preflight.canonical",
|
||||
Some(version),
|
||||
"install_matching_application",
|
||||
));
|
||||
};
|
||||
if version != expected.version {
|
||||
return Err(MigrationError::new(
|
||||
"partial_sequence",
|
||||
"preflight.canonical",
|
||||
Some(version),
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
let checksum = row
|
||||
.try_get::<String, _>("checksum")
|
||||
.map_err(|_| MigrationError::storage("preflight.canonical"))?;
|
||||
if checksum != expected.checksum {
|
||||
return Err(MigrationError::new(
|
||||
"checksum_mismatch",
|
||||
"preflight.canonical",
|
||||
Some(version),
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
let name = row
|
||||
.try_get::<String, _>("name")
|
||||
.map_err(|_| MigrationError::storage("preflight.canonical"))?;
|
||||
let phase = row
|
||||
.try_get::<String, _>("phase")
|
||||
.map_err(|_| MigrationError::storage("preflight.canonical"))?;
|
||||
let compatibility = row
|
||||
.try_get::<String, _>("compatibility")
|
||||
.map_err(|_| MigrationError::storage("preflight.canonical"))?;
|
||||
if name != expected.name
|
||||
|| phase != expected.phase
|
||||
|| compatibility != expected.compatibility
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"checksum_mismatch",
|
||||
"preflight.metadata",
|
||||
Some(version),
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let current = rows
|
||||
.last()
|
||||
.and_then(|row| row.try_get::<i64, _>("version").ok())
|
||||
.ok_or_else(|| MigrationError::storage("preflight.canonical"))?;
|
||||
if rows.len() != usize::try_from(current).unwrap_or_default() {
|
||||
return Err(MigrationError::new(
|
||||
"partial_sequence",
|
||||
"preflight.canonical",
|
||||
Some(current),
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
validate_schema_fingerprint(connection, current).await?;
|
||||
if current < CURRENT_VERSION {
|
||||
Ok(MigrationPreflight::MigrationRequired {
|
||||
current,
|
||||
target: CURRENT_VERSION,
|
||||
})
|
||||
} else {
|
||||
validate_required_relations(connection, CONSOLIDATION_RELATIONS, CURRENT_VERSION).await?;
|
||||
validate_schema_fingerprint(connection, CURRENT_VERSION).await?;
|
||||
validate_legacy_audit(connection).await?;
|
||||
Ok(MigrationPreflight::Current { version: current })
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let rows = query("select version, description, checksum from __crank_core_migrations order by version limit 2")
|
||||
.fetch_all(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.core"))?;
|
||||
if rows.len() != 1 {
|
||||
return Err(MigrationError::new(
|
||||
"partial_sequence",
|
||||
"preflight.core",
|
||||
None,
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
let version = rows[0]
|
||||
.try_get::<i32, _>("version")
|
||||
.map_err(|_| MigrationError::storage("preflight.core"))?;
|
||||
let checksum = rows[0]
|
||||
.try_get::<String, _>("checksum")
|
||||
.map_err(|_| MigrationError::storage("preflight.core"))?;
|
||||
let description = rows[0]
|
||||
.try_get::<String, _>("description")
|
||||
.map_err(|_| MigrationError::storage("preflight.core"))?;
|
||||
if version != BASELINE_VERSION
|
||||
|| description != "community baseline"
|
||||
|| checksum != BASELINE_CHECKSUM
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"checksum_mismatch",
|
||||
"preflight.core",
|
||||
Some(i64::from(version)),
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
|
||||
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
|
||||
if mcp_ledger != mcp_sessions {
|
||||
return Err(MigrationError::new(
|
||||
"legacy_conflict",
|
||||
"preflight.legacy_mcp",
|
||||
None,
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
if mcp_ledger {
|
||||
let rows =
|
||||
query("select version, checksum from __crank_mcp_migrations order by version limit 2")
|
||||
.fetch_all(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_mcp"))?;
|
||||
if rows.len() != 1
|
||||
|| rows[0].try_get::<i32, _>("version").ok() != Some(1)
|
||||
|| rows[0].try_get::<String, _>("checksum").ok().as_deref()
|
||||
!= Some("mcp-transport-sessions-v1")
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"legacy_conflict",
|
||||
"preflight.legacy_mcp",
|
||||
None,
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
}
|
||||
if relation_exists(connection, "__crank_ext_migrations").await? {
|
||||
let count = query("select count(*)::bigint as count from __crank_ext_migrations")
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_extension"))?
|
||||
.try_get::<i64, _>("count")
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_extension"))?;
|
||||
if count > 1_000 {
|
||||
return Err(MigrationError::new(
|
||||
"legacy_conflict",
|
||||
"preflight.legacy_extension",
|
||||
None,
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
if count > 0 {
|
||||
let checksum_column = query(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = '__crank_ext_migrations'
|
||||
and column_name = 'checksum'
|
||||
) as present",
|
||||
)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_extension"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_extension"))?;
|
||||
if !checksum_column {
|
||||
return Err(MigrationError::new(
|
||||
"legacy_conflict",
|
||||
"preflight.legacy_extension",
|
||||
None,
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
let rows = query(
|
||||
"select extension_name, version, checksum
|
||||
from __crank_ext_migrations
|
||||
order by extension_name, version
|
||||
limit 1001",
|
||||
)
|
||||
.fetch_all(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_extension"))?;
|
||||
for row in rows {
|
||||
let name = row
|
||||
.try_get::<String, _>("extension_name")
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_extension"))?;
|
||||
let version = row
|
||||
.try_get::<i32, _>("version")
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_extension"))?;
|
||||
let checksum = row
|
||||
.try_get::<Option<String>, _>("checksum")
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_extension"))?;
|
||||
let registered = MigrationAuthority::registered_extension_migrations()
|
||||
.iter()
|
||||
.any(|(registered_name, descriptor)| {
|
||||
*registered_name == name
|
||||
&& descriptor.version == u32::try_from(version).unwrap_or_default()
|
||||
&& checksum.as_deref() == Some(descriptor.checksum)
|
||||
});
|
||||
if !registered {
|
||||
return Err(MigrationError::new(
|
||||
"legacy_conflict",
|
||||
"preflight.legacy_extension",
|
||||
Some(i64::from(version)),
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_legacy_audit(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let rows = query(
|
||||
"select source, source_version, source_checksum
|
||||
from __crank_migration_legacy_audit
|
||||
order by source, source_version
|
||||
limit 1002",
|
||||
)
|
||||
.fetch_all(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_audit"))?;
|
||||
if rows.len() > 1001 {
|
||||
return Err(MigrationError::new(
|
||||
"legacy_conflict",
|
||||
"preflight.legacy_audit",
|
||||
None,
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
for row in rows {
|
||||
let source = row
|
||||
.try_get::<String, _>("source")
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_audit"))?;
|
||||
let version = row
|
||||
.try_get::<i64, _>("source_version")
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_audit"))?;
|
||||
let checksum = row
|
||||
.try_get::<String, _>("source_checksum")
|
||||
.map_err(|_| MigrationError::storage("preflight.legacy_audit"))?;
|
||||
let valid = (source == "core" && version == 1 && checksum == BASELINE_CHECKSUM)
|
||||
|| (source == "mcp-session" && version == 1 && checksum == "mcp-transport-sessions-v1")
|
||||
|| source.strip_prefix("extension:").is_some_and(|name| {
|
||||
MigrationAuthority::registered_extension_migrations()
|
||||
.iter()
|
||||
.any(|(registered_name, descriptor)| {
|
||||
*registered_name == name
|
||||
&& i64::from(descriptor.version) == version
|
||||
&& descriptor.checksum == checksum
|
||||
})
|
||||
});
|
||||
if !valid {
|
||||
return Err(MigrationError::new(
|
||||
"legacy_conflict",
|
||||
"preflight.legacy_audit",
|
||||
Some(version),
|
||||
"restore_known_good_backup",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_core_ledger(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
query(
|
||||
"create table __crank_core_migrations (
|
||||
version integer primary key,
|
||||
description text not null,
|
||||
checksum text not null,
|
||||
applied_at timestamptz not null default now()
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.core_ledger",
|
||||
Some(1),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_consolidation(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(CONSOLIDATION_SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.consolidation",
|
||||
Some(2),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
for (name, extension) in MigrationAuthority::registered_extension_migrations() {
|
||||
query(
|
||||
"insert into __crank_migration_legacy_audit
|
||||
(source, source_version, source_checksum)
|
||||
select 'extension:' || $1, $2, $3
|
||||
from __crank_ext_migrations
|
||||
where extension_name = $1 and version = $2 and checksum = $3",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(i64::from(extension.version))
|
||||
.bind(extension.checksum)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.extension_audit",
|
||||
Some(2),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let descriptor = &MigrationAuthority::sequence()[1];
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(2),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_request_trace_identity(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(REQUEST_TRACE_IDENTITY_SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.request_trace_identity",
|
||||
Some(3),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
let descriptor = &MigrationAuthority::sequence()[2];
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(3),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "authority_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,115 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sequence_is_deterministic_and_append_only() {
|
||||
let first = MigrationAuthority::sequence();
|
||||
let second = MigrationAuthority::sequence();
|
||||
assert_eq!(first, second);
|
||||
MigrationAuthority::validate_sequence().unwrap();
|
||||
assert_eq!(
|
||||
first.iter().map(|item| item.version).collect::<Vec<_>>(),
|
||||
vec![1, 2, 3]
|
||||
);
|
||||
assert_eq!(first[0].checksum, "crank-community-baseline-v1");
|
||||
assert_eq!(first[0].source_digest, BASELINE_SOURCE_SHA256);
|
||||
assert_eq!(
|
||||
baseline_source_digest(),
|
||||
BASELINE_SOURCE_SHA256,
|
||||
"baseline v1 source changed; add a new migration instead"
|
||||
);
|
||||
assert_eq!(first[1].checksum.len(), 64);
|
||||
assert!(
|
||||
first[1]
|
||||
.checksum
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_gap_checksum_phase_and_unbounded_backfill_are_rejected() {
|
||||
let base = MigrationAuthority::sequence();
|
||||
for invalid in [
|
||||
{
|
||||
let mut value = base.clone();
|
||||
value[1].version = 3;
|
||||
value
|
||||
},
|
||||
{
|
||||
let mut value = base.clone();
|
||||
value[1].checksum = "0".repeat(64);
|
||||
value
|
||||
},
|
||||
{
|
||||
let mut value = base.clone();
|
||||
value[1].phase = "unknown";
|
||||
value
|
||||
},
|
||||
{
|
||||
let mut value = base.clone();
|
||||
value[1].name = value[0].name;
|
||||
value
|
||||
},
|
||||
{
|
||||
let mut value = base.clone();
|
||||
value[1].transactional = false;
|
||||
value
|
||||
},
|
||||
{
|
||||
let mut value = base.clone();
|
||||
value[1].phase = "migrate";
|
||||
value[1].backfill = BackfillPolicy::Bounded {
|
||||
max_batch_rows: 10_001,
|
||||
max_batch_ms: 60_001,
|
||||
resumable: false,
|
||||
};
|
||||
value
|
||||
},
|
||||
{
|
||||
let mut value = base.clone();
|
||||
value[1].phase = "contract";
|
||||
value[1].compatibility = "window-closed";
|
||||
value[1].contract_evidence = None;
|
||||
value
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_descriptors(&invalid).unwrap_err().code(),
|
||||
"invalid_contract"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_batches_are_bounded_and_resumable() {
|
||||
let policy = BackfillPolicy::Bounded {
|
||||
max_batch_rows: 100,
|
||||
max_batch_ms: 1_000,
|
||||
resumable: true,
|
||||
};
|
||||
BackfillBatch {
|
||||
cursor: Some("next-100".to_owned()),
|
||||
max_rows: 100,
|
||||
max_ms: 1_000,
|
||||
}
|
||||
.validate(policy)
|
||||
.unwrap();
|
||||
assert!(
|
||||
BackfillBatch {
|
||||
cursor: None,
|
||||
max_rows: 101,
|
||||
max_ms: 1_000,
|
||||
}
|
||||
.validate(policy)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_is_bounded_and_does_not_echo_storage_details() {
|
||||
let error = MigrationError::storage("preflight.connect");
|
||||
let rendered = error.to_string();
|
||||
assert!(rendered.len() < 512);
|
||||
assert!(!rendered.contains("postgres://"));
|
||||
assert_eq!(error.code(), "storage_unavailable");
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
use sqlx::{Postgres, Transaction, query};
|
||||
|
||||
pub(super) const BASELINE_VERSION: i32 = 1;
|
||||
pub(super) const BASELINE_CHECKSUM: &str = "crank-community-baseline-v1";
|
||||
|
||||
// baseline-v1:start
|
||||
pub(super) async fn apply_baseline(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
query(
|
||||
"create table if not exists workspaces (
|
||||
id text primary key,
|
||||
slug text not null unique,
|
||||
display_name text not null,
|
||||
status text not null,
|
||||
settings_json jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists users (
|
||||
id text primary key,
|
||||
email text not null unique,
|
||||
display_name text not null,
|
||||
password_hash text null,
|
||||
status text not null,
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table users add column if not exists password_hash text null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into users (
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
status,
|
||||
created_at
|
||||
) values (
|
||||
'user_default_owner',
|
||||
'owner@crank.local',
|
||||
'Workspace Owner',
|
||||
'active',
|
||||
now()
|
||||
)
|
||||
on conflict (id) do nothing",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists memberships (
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
user_id text not null references users(id) on delete cascade,
|
||||
role text not null,
|
||||
created_at timestamptz not null,
|
||||
primary key (workspace_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists user_sessions (
|
||||
id text primary key,
|
||||
user_id text not null references users(id) on delete cascade,
|
||||
current_workspace_id text null references workspaces(id) on delete set null,
|
||||
secret_hash text not null,
|
||||
status text not null,
|
||||
expires_at timestamptz not null,
|
||||
last_seen_at timestamptz null,
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"alter table user_sessions
|
||||
add column if not exists current_workspace_id text null references workspaces(id) on delete set null",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into workspaces (
|
||||
id,
|
||||
slug,
|
||||
display_name,
|
||||
status,
|
||||
settings_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
'ws_default',
|
||||
'default',
|
||||
'Default Workspace',
|
||||
'active',
|
||||
'{}'::jsonb,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
on conflict (id) do nothing",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into memberships (
|
||||
workspace_id,
|
||||
user_id,
|
||||
role,
|
||||
created_at
|
||||
) values (
|
||||
'ws_default',
|
||||
'user_default_owner',
|
||||
'owner',
|
||||
now()
|
||||
)
|
||||
on conflict (workspace_id, user_id) do nothing",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists invitation_tokens (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
email text not null,
|
||||
role text not null,
|
||||
status text not null,
|
||||
token_hash text not null,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists platform_api_keys (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text null,
|
||||
name text not null,
|
||||
prefix text not null,
|
||||
secret_hash text not null,
|
||||
key_kind text not null default 'mcp_client',
|
||||
scopes_json jsonb not null,
|
||||
status text not null,
|
||||
created_at timestamptz not null,
|
||||
last_used_at timestamptz null,
|
||||
revoked_at timestamptz null,
|
||||
expires_at timestamptz null,
|
||||
allowed_origins_json jsonb not null default '[]'::jsonb
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists platform_api_keys_workspace_name_idx on platform_api_keys(workspace_id, name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table platform_api_keys add column if not exists agent_id text null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table platform_api_keys add column if not exists key_kind text not null default 'mcp_client'",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table platform_api_keys add column if not exists expires_at timestamptz null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table platform_api_keys add column if not exists allowed_origins_json jsonb not null default '[]'::jsonb",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into workspaces (
|
||||
id,
|
||||
slug,
|
||||
display_name,
|
||||
status,
|
||||
settings_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
'ws_default',
|
||||
'default',
|
||||
'Default Workspace',
|
||||
'active',
|
||||
'{}'::jsonb,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
on conflict (id) do nothing",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists operations (
|
||||
id text primary key,
|
||||
workspace_id text null references workspaces(id) on delete cascade,
|
||||
name text not null,
|
||||
display_name text not null,
|
||||
category text not null default 'general',
|
||||
protocol text not null,
|
||||
security_level text not null default 'standard',
|
||||
status text not null,
|
||||
current_draft_version integer not null default 1,
|
||||
latest_published_version integer null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null,
|
||||
published_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table operations add column if not exists workspace_id text null references workspaces(id) on delete cascade")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table operations add column if not exists category text not null default 'general'",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"alter table operations add column if not exists security_level text not null default 'standard'",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("update operations set workspace_id = 'ws_default' where workspace_id is null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table operations alter column workspace_id set not null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table operations drop constraint if exists operations_name_key")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists operations_workspace_name_idx on operations(workspace_id, name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists operation_versions (
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
version integer not null,
|
||||
status text not null,
|
||||
target_json jsonb not null,
|
||||
input_schema_json jsonb not null,
|
||||
output_schema_json jsonb not null,
|
||||
input_mapping_json jsonb not null,
|
||||
output_mapping_json jsonb not null,
|
||||
execution_config_json jsonb not null,
|
||||
tool_description_json jsonb not null,
|
||||
samples_json jsonb null,
|
||||
generated_draft_json jsonb null,
|
||||
config_export_json jsonb null,
|
||||
wizard_state_json jsonb null,
|
||||
change_note text null,
|
||||
created_at timestamptz not null,
|
||||
created_by text null,
|
||||
primary key (operation_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table operation_versions add column if not exists wizard_state_json jsonb null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists published_operations (
|
||||
operation_id text primary key references operations(id) on delete cascade,
|
||||
version integer not null,
|
||||
published_at timestamptz not null,
|
||||
published_by text null,
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists operation_samples (
|
||||
id text primary key,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
version integer not null,
|
||||
sample_kind text not null,
|
||||
storage_ref text not null,
|
||||
content_type text not null,
|
||||
file_name text null,
|
||||
created_at timestamptz not null,
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists descriptors (
|
||||
id text primary key,
|
||||
operation_id text null references operations(id) on delete cascade,
|
||||
version integer null,
|
||||
descriptor_kind text not null,
|
||||
storage_ref text not null,
|
||||
source_name text null,
|
||||
package_index_json jsonb null,
|
||||
created_at timestamptz not null,
|
||||
foreign key (operation_id, version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists auth_profiles (
|
||||
id text primary key,
|
||||
workspace_id text null references workspaces(id) on delete cascade,
|
||||
name text not null,
|
||||
kind text not null,
|
||||
config_json jsonb not null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table auth_profiles add column if not exists workspace_id text null references workspaces(id) on delete cascade")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("update auth_profiles set workspace_id = 'ws_default' where workspace_id is null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table auth_profiles alter column workspace_id set not null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table auth_profiles drop constraint if exists auth_profiles_name_key")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists auth_profiles_workspace_name_idx on auth_profiles(workspace_id, name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists workspace_upstreams (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
name text not null,
|
||||
base_url text not null,
|
||||
static_headers_json jsonb not null default '{}'::jsonb,
|
||||
auth_profile_id text null references auth_profiles(id) on delete set null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists workspace_upstreams_workspace_name_idx on workspace_upstreams(workspace_id, name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists workspace_upstreams_workspace_base_auth_idx on workspace_upstreams(workspace_id, base_url, coalesce(auth_profile_id, ''))",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"insert into workspace_upstreams (
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
base_url,
|
||||
static_headers_json,
|
||||
auth_profile_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
select
|
||||
'upstream_frankfurter_' || w.id,
|
||||
w.id,
|
||||
'Frankfurter',
|
||||
'https://api.frankfurter.dev',
|
||||
'{}'::jsonb,
|
||||
null,
|
||||
now(),
|
||||
now()
|
||||
from workspaces w
|
||||
where not exists (
|
||||
select 1
|
||||
from workspace_upstreams wu
|
||||
where wu.workspace_id = w.id
|
||||
and wu.name = 'Frankfurter'
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists secrets (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
name text not null,
|
||||
kind text not null,
|
||||
status text not null,
|
||||
current_version integer not null,
|
||||
last_used_at timestamptz null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists secrets_workspace_name_idx on secrets(workspace_id, name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists secret_versions (
|
||||
secret_id text not null references secrets(id) on delete cascade,
|
||||
version integer not null,
|
||||
ciphertext text not null,
|
||||
key_version text not null,
|
||||
created_at timestamptz not null,
|
||||
created_by text null references users(id) on delete set null,
|
||||
primary key (secret_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists yaml_import_jobs (
|
||||
id text primary key,
|
||||
source_sample_id text null references operation_samples(id) on delete set null,
|
||||
status text not null,
|
||||
format_version text not null,
|
||||
mode text not null,
|
||||
result_operation_id text null references operations(id) on delete set null,
|
||||
result_version integer null,
|
||||
error_text text null,
|
||||
created_at timestamptz not null,
|
||||
finished_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists import_jobs (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
kind text not null,
|
||||
source_format text not null,
|
||||
source_version text null,
|
||||
status text not null,
|
||||
preview_payload jsonb not null,
|
||||
created_operation_ids jsonb not null default '[]'::jsonb,
|
||||
error_text text null,
|
||||
created_at timestamptz not null,
|
||||
expires_at timestamptz not null,
|
||||
finished_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists agents (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
slug text not null,
|
||||
display_name text not null,
|
||||
description text not null,
|
||||
status text not null,
|
||||
current_draft_version integer not null default 1,
|
||||
latest_published_version integer null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null,
|
||||
published_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists agents_workspace_slug_idx on agents(workspace_id, slug)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists agent_versions (
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
version integer not null,
|
||||
status text not null,
|
||||
instructions_json jsonb not null,
|
||||
tool_selection_policy_json jsonb not null,
|
||||
created_at timestamptz not null,
|
||||
primary key (agent_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists agent_operation_bindings (
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
agent_version integer not null,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
operation_version integer not null,
|
||||
tool_name text not null,
|
||||
tool_title text not null,
|
||||
tool_description_override text null,
|
||||
enabled boolean not null default true,
|
||||
foreign key (agent_id, agent_version) references agent_versions(agent_id, version) on delete cascade,
|
||||
foreign key (operation_id, operation_version) references operation_versions(operation_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists agent_bindings_tool_name_idx on agent_operation_bindings(agent_id, agent_version, tool_name)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists published_agents (
|
||||
agent_id text primary key references agents(id) on delete cascade,
|
||||
version integer not null,
|
||||
published_at timestamptz not null,
|
||||
published_by text null,
|
||||
foreign key (agent_id, version) references agent_versions(agent_id, version) on delete cascade
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists approval_requests (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
operation_version integer not null,
|
||||
status text not null,
|
||||
risk_level text not null,
|
||||
request_payload_json jsonb not null,
|
||||
response_payload_json jsonb null,
|
||||
created_at timestamptz not null,
|
||||
expires_at timestamptz not null,
|
||||
decided_at timestamptz null,
|
||||
decided_by_key_id text null references platform_api_keys(id) on delete set null,
|
||||
decision_note text null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query("alter table approval_requests add column if not exists execution_started_at timestamptz null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table approval_requests add column if not exists execution_attempts integer not null default 0")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query("alter table approval_requests add column if not exists request_fingerprint text null")
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create unique index if not exists 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",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
query(
|
||||
"create index if not exists approval_requests_agent_status_idx
|
||||
on approval_requests(workspace_id, agent_id, status, expires_at)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists invocation_logs (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text null references agents(id) on delete set null,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
source text not null,
|
||||
level text not null,
|
||||
status text not null,
|
||||
tool_name text not null,
|
||||
message text not null,
|
||||
request_id text null,
|
||||
status_code integer null,
|
||||
duration_ms bigint not null,
|
||||
error_kind text null,
|
||||
request_preview_json jsonb not null,
|
||||
response_preview_json jsonb not null,
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists invocation_logs_workspace_created_idx on invocation_logs(workspace_id, created_at desc)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists invocation_logs_workspace_operation_created_idx on invocation_logs(workspace_id, operation_id, created_at desc)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists invocation_logs_workspace_agent_created_idx on invocation_logs(workspace_id, agent_id, created_at desc)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists usage_rollups (
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text null references agents(id) on delete cascade,
|
||||
operation_id text null references operations(id) on delete cascade,
|
||||
period text not null,
|
||||
calls_total bigint not null,
|
||||
calls_ok bigint not null,
|
||||
calls_error bigint not null,
|
||||
p50_ms bigint not null,
|
||||
p95_ms bigint not null,
|
||||
p99_ms bigint not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
// baseline-v1:end
|
||||
@@ -0,0 +1,84 @@
|
||||
create temporary table __crank_v2_context (
|
||||
had_mcp_ledger boolean not null
|
||||
) on commit drop;
|
||||
|
||||
insert into __crank_v2_context (had_mcp_ledger)
|
||||
values (to_regclass(format('%I.%I', current_schema(), '__crank_mcp_migrations')) is not null);
|
||||
|
||||
create table __crank_migrations (
|
||||
version bigint primary key,
|
||||
name text not null unique,
|
||||
checksum text not null,
|
||||
phase text not null,
|
||||
compatibility text not null,
|
||||
applied_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table __crank_migration_legacy_audit (
|
||||
source text not null,
|
||||
source_version bigint not null,
|
||||
source_checksum text not null,
|
||||
imported_at timestamptz not null default now(),
|
||||
primary key (source, source_version)
|
||||
);
|
||||
|
||||
insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values (
|
||||
1,
|
||||
'community-baseline-v1',
|
||||
'crank-community-baseline-v1',
|
||||
'expand',
|
||||
'legacy-baseline'
|
||||
);
|
||||
|
||||
insert into __crank_migration_legacy_audit (source, source_version, source_checksum)
|
||||
values ('core', 1, 'crank-community-baseline-v1');
|
||||
|
||||
create table if not exists __crank_mcp_migrations (
|
||||
version integer primary key,
|
||||
checksum text not null,
|
||||
applied_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists mcp_transport_sessions (
|
||||
id text primary key,
|
||||
protocol_version text not null,
|
||||
initialized boolean not null default false,
|
||||
supports_elicitation 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
|
||||
);
|
||||
|
||||
alter table mcp_transport_sessions
|
||||
add column if not exists supports_elicitation boolean not null default false;
|
||||
alter table mcp_transport_sessions
|
||||
add column if not exists expires_at timestamptz null;
|
||||
|
||||
create index if not exists mcp_transport_sessions_workspace_agent_idx
|
||||
on mcp_transport_sessions(workspace_slug, agent_slug, updated_at desc);
|
||||
create index if not exists mcp_transport_sessions_expires_at_idx
|
||||
on mcp_transport_sessions(expires_at)
|
||||
where expires_at is not null;
|
||||
|
||||
insert into __crank_mcp_migrations (version, checksum)
|
||||
values (1, 'mcp-transport-sessions-v1')
|
||||
on conflict (version) do nothing;
|
||||
|
||||
insert into __crank_migration_legacy_audit (source, source_version, source_checksum)
|
||||
select 'mcp-session', 1, 'mcp-transport-sessions-v1'
|
||||
from __crank_v2_context
|
||||
where had_mcp_ledger;
|
||||
|
||||
create table if not exists __crank_ext_migrations (
|
||||
extension_name text not null,
|
||||
version integer not null,
|
||||
checksum text null,
|
||||
applied_at timestamptz not null default now(),
|
||||
primary key (extension_name, version)
|
||||
);
|
||||
|
||||
alter table __crank_ext_migrations
|
||||
add column if not exists checksum text null;
|
||||
@@ -0,0 +1,20 @@
|
||||
alter table invocation_logs
|
||||
add column trace_id text;
|
||||
|
||||
alter table invocation_logs
|
||||
add constraint invocation_logs_trace_id_format_check
|
||||
check (
|
||||
trace_id is null
|
||||
or (
|
||||
trace_id ~ '^[0-9a-f]{32}$'
|
||||
and trace_id <> '00000000000000000000000000000000'
|
||||
)
|
||||
) not valid;
|
||||
|
||||
create index invocation_logs_workspace_request_id_idx
|
||||
on invocation_logs(workspace_id, request_id)
|
||||
where request_id is not null and octet_length(request_id) <= 128;
|
||||
|
||||
create index invocation_logs_workspace_trace_id_idx
|
||||
on invocation_logs(workspace_id, trace_id)
|
||||
where trace_id is not null;
|
||||
@@ -0,0 +1,451 @@
|
||||
use sqlx::{PgConnection, Row, query};
|
||||
|
||||
use super::authority::MigrationError;
|
||||
|
||||
pub(super) const OWNED_RELATIONS: &[&str] = &[
|
||||
"__crank_core_migrations",
|
||||
"__crank_migrations",
|
||||
"__crank_migration_legacy_audit",
|
||||
"__crank_mcp_migrations",
|
||||
"__crank_ext_migrations",
|
||||
"mcp_transport_sessions",
|
||||
"workspaces",
|
||||
"users",
|
||||
"memberships",
|
||||
"user_sessions",
|
||||
"invitation_tokens",
|
||||
"platform_api_keys",
|
||||
"operations",
|
||||
"operation_versions",
|
||||
"published_operations",
|
||||
"operation_samples",
|
||||
"descriptors",
|
||||
"agents",
|
||||
"agent_versions",
|
||||
"published_agents",
|
||||
"agent_operation_bindings",
|
||||
"secrets",
|
||||
"secret_versions",
|
||||
"auth_profiles",
|
||||
"workspace_upstreams",
|
||||
"yaml_import_jobs",
|
||||
"import_jobs",
|
||||
"approval_requests",
|
||||
"invocation_logs",
|
||||
"usage_rollups",
|
||||
];
|
||||
|
||||
const REQUIRED_COLUMNS: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"__crank_core_migrations",
|
||||
&["version", "description", "checksum", "applied_at"],
|
||||
),
|
||||
(
|
||||
"__crank_migrations",
|
||||
&[
|
||||
"version",
|
||||
"name",
|
||||
"checksum",
|
||||
"phase",
|
||||
"compatibility",
|
||||
"applied_at",
|
||||
],
|
||||
),
|
||||
(
|
||||
"__crank_migration_legacy_audit",
|
||||
&["source", "source_version", "source_checksum", "imported_at"],
|
||||
),
|
||||
(
|
||||
"__crank_mcp_migrations",
|
||||
&["version", "checksum", "applied_at"],
|
||||
),
|
||||
(
|
||||
"__crank_ext_migrations",
|
||||
&["extension_name", "version", "checksum", "applied_at"],
|
||||
),
|
||||
(
|
||||
"mcp_transport_sessions",
|
||||
&[
|
||||
"id",
|
||||
"protocol_version",
|
||||
"initialized",
|
||||
"supports_elicitation",
|
||||
"workspace_slug",
|
||||
"agent_slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"expires_at",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
const REQUIRED_COLUMN_TYPES: &[(&str, &str, &str, bool)] = &[
|
||||
("__crank_core_migrations", "version", "integer", false),
|
||||
("__crank_core_migrations", "description", "text", false),
|
||||
("__crank_core_migrations", "checksum", "text", false),
|
||||
(
|
||||
"__crank_core_migrations",
|
||||
"applied_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
("__crank_migrations", "version", "bigint", false),
|
||||
("__crank_migrations", "name", "text", false),
|
||||
("__crank_migrations", "checksum", "text", false),
|
||||
("__crank_migrations", "phase", "text", false),
|
||||
("__crank_migrations", "compatibility", "text", false),
|
||||
(
|
||||
"__crank_migrations",
|
||||
"applied_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
("__crank_migration_legacy_audit", "source", "text", false),
|
||||
(
|
||||
"__crank_migration_legacy_audit",
|
||||
"source_version",
|
||||
"bigint",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"__crank_migration_legacy_audit",
|
||||
"source_checksum",
|
||||
"text",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"__crank_migration_legacy_audit",
|
||||
"imported_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
("__crank_mcp_migrations", "version", "integer", false),
|
||||
("__crank_mcp_migrations", "checksum", "text", false),
|
||||
(
|
||||
"__crank_mcp_migrations",
|
||||
"applied_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
("__crank_ext_migrations", "extension_name", "text", false),
|
||||
("__crank_ext_migrations", "version", "integer", false),
|
||||
("__crank_ext_migrations", "checksum", "text", true),
|
||||
(
|
||||
"__crank_ext_migrations",
|
||||
"applied_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
("mcp_transport_sessions", "id", "text", false),
|
||||
("mcp_transport_sessions", "protocol_version", "text", false),
|
||||
("mcp_transport_sessions", "initialized", "boolean", false),
|
||||
(
|
||||
"mcp_transport_sessions",
|
||||
"supports_elicitation",
|
||||
"boolean",
|
||||
false,
|
||||
),
|
||||
("mcp_transport_sessions", "workspace_slug", "text", false),
|
||||
("mcp_transport_sessions", "agent_slug", "text", false),
|
||||
(
|
||||
"mcp_transport_sessions",
|
||||
"created_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"mcp_transport_sessions",
|
||||
"updated_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"mcp_transport_sessions",
|
||||
"expires_at",
|
||||
"timestamp with time zone",
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
pub(super) async fn relation_exists(
|
||||
connection: &mut PgConnection,
|
||||
relation: &str,
|
||||
) -> Result<bool, MigrationError> {
|
||||
query("select to_regclass(format('%I.%I', current_schema(), $1))::text is not null as present")
|
||||
.bind(relation)
|
||||
.fetch_one(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.inventory"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.inventory"))
|
||||
}
|
||||
|
||||
pub(super) async fn validate_required_relations(
|
||||
connection: &mut PgConnection,
|
||||
relations: &[&str],
|
||||
version: i64,
|
||||
) -> Result<(), MigrationError> {
|
||||
for relation in relations {
|
||||
if !relation_exists(connection, relation).await? {
|
||||
return Err(schema_error(version));
|
||||
}
|
||||
let kind = query(
|
||||
"select c.relkind::text as kind
|
||||
from pg_catalog.pg_class c
|
||||
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = current_schema() and c.relname = $1",
|
||||
)
|
||||
.bind(relation)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<String, _>("kind")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if !matches!(kind.as_str(), "r" | "p") {
|
||||
return Err(schema_error(version));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn validate_schema_fingerprint(
|
||||
connection: &mut PgConnection,
|
||||
current_version: i64,
|
||||
) -> Result<(), MigrationError> {
|
||||
for (table, required) in REQUIRED_COLUMNS {
|
||||
if !relation_exists(connection, table).await? {
|
||||
continue;
|
||||
}
|
||||
let rows = query(
|
||||
"select column_name from information_schema.columns
|
||||
where table_schema = current_schema() and table_name = $1
|
||||
order by ordinal_position limit 257",
|
||||
)
|
||||
.bind(table)
|
||||
.fetch_all(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let actual = rows
|
||||
.iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("column_name").ok())
|
||||
.collect::<Vec<_>>();
|
||||
if actual.len() != required.len()
|
||||
|| required
|
||||
.iter()
|
||||
.any(|column| !actual.iter().any(|actual| actual == column))
|
||||
{
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
for (table, column, data_type, nullable) in REQUIRED_COLUMN_TYPES {
|
||||
if !relation_exists(connection, table).await? {
|
||||
continue;
|
||||
}
|
||||
let row = query(
|
||||
"select data_type, is_nullable from information_schema.columns
|
||||
where table_schema = current_schema() and table_name = $1 and column_name = $2",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(column)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("data_type").ok().as_deref() == Some(*data_type)
|
||||
&& row.try_get::<String, _>("is_nullable").ok().as_deref()
|
||||
== Some(if *nullable { "YES" } else { "NO" })
|
||||
});
|
||||
if !valid {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
if current_version < 3 {
|
||||
let trace_column_present = query(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = 'trace_id'
|
||||
) as present",
|
||||
)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let trace_constraint_present = named_constraint_exists(
|
||||
connection,
|
||||
"invocation_logs",
|
||||
"invocation_logs_trace_id_format_check",
|
||||
)
|
||||
.await?;
|
||||
let v3_index_present =
|
||||
relation_exists(connection, "invocation_logs_workspace_request_id_idx").await?
|
||||
|| relation_exists(connection, "invocation_logs_workspace_trace_id_idx").await?;
|
||||
if trace_column_present || trace_constraint_present || v3_index_present {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
let trace_id = query(
|
||||
"select data_type, is_nullable from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = 'trace_id'",
|
||||
)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = trace_id.is_some_and(|row| {
|
||||
row.try_get::<String, _>("data_type").ok().as_deref() == Some("text")
|
||||
&& row.try_get::<String, _>("is_nullable").ok().as_deref() == Some("YES")
|
||||
});
|
||||
if !valid {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
let constraint = query(
|
||||
"select pg_get_expr(c.conbin, c.conrelid) as expression, c.convalidated
|
||||
from pg_catalog.pg_constraint c
|
||||
join pg_catalog.pg_class t on t.oid = c.conrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and t.relname = 'invocation_logs'
|
||||
and c.conname = 'invocation_logs_trace_id_format_check'
|
||||
and c.contype = 'c'",
|
||||
)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let constraint_valid = constraint.is_some_and(|row| {
|
||||
row.try_get::<String, _>("expression")
|
||||
.ok()
|
||||
.is_some_and(|value| {
|
||||
normalize_definition(&value)
|
||||
== "trace_idisnullortrace_id~'^[0-9a-f]{32}$'::textandtrace_id<>'00000000000000000000000000000000'::text"
|
||||
})
|
||||
&& row.try_get::<bool, _>("convalidated").ok() == Some(false)
|
||||
});
|
||||
if !constraint_valid {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
let required_indexes = [
|
||||
"mcp_transport_sessions_workspace_agent_idx",
|
||||
"mcp_transport_sessions_expires_at_idx",
|
||||
];
|
||||
for index in required_indexes {
|
||||
let present = query(
|
||||
"select exists (select 1 from pg_catalog.pg_indexes
|
||||
where schemaname = current_schema() and indexname = $1) as present",
|
||||
)
|
||||
.bind(index)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if !present {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
if current_version >= 3 {
|
||||
validate_index(
|
||||
connection,
|
||||
"invocation_logs_workspace_request_id_idx",
|
||||
"request_id",
|
||||
"request_idisnotnullandoctet_lengthrequest_id<=128",
|
||||
)
|
||||
.await?;
|
||||
validate_index(
|
||||
connection,
|
||||
"invocation_logs_workspace_trace_id_idx",
|
||||
"trace_id",
|
||||
"trace_idisnotnull",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn named_constraint_exists(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
constraint: &str,
|
||||
) -> Result<bool, MigrationError> {
|
||||
query(
|
||||
"select exists (
|
||||
select 1 from pg_catalog.pg_constraint c
|
||||
join pg_catalog.pg_class t on t.oid = c.conrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and t.relname = $1
|
||||
and c.conname = $2
|
||||
) as present",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(constraint)
|
||||
.fetch_one(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
|
||||
async fn validate_index(
|
||||
connection: &mut PgConnection,
|
||||
index: &str,
|
||||
second_column: &str,
|
||||
expected_predicate: &str,
|
||||
) -> Result<(), MigrationError> {
|
||||
let row = query(
|
||||
"select
|
||||
t.relname as table_name,
|
||||
am.amname as access_method,
|
||||
i.indisvalid,
|
||||
i.indisready,
|
||||
i.indisunique,
|
||||
pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
|
||||
pg_get_expr(i.indpred, i.indrelid) as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema() and idx.relname = $1",
|
||||
)
|
||||
.bind(index)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some("invocation_logs")
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(false)
|
||||
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
|
||||
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some(second_column)
|
||||
&& row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.is_some_and(|value| normalize_definition(&value) == expected_predicate)
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(3)) }
|
||||
}
|
||||
|
||||
fn normalize_definition(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| !character.is_ascii_whitespace() && !matches!(character, '(' | ')'))
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn schema_error(version: i64) -> MigrationError {
|
||||
MigrationError::new(
|
||||
"partial_sequence",
|
||||
"preflight.schema",
|
||||
Some(version),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use sqlx::{
|
||||
postgres::{PgConnectOptions, PgPoolOptions},
|
||||
};
|
||||
|
||||
use crate::{error::RegistryError, migrations};
|
||||
use crate::{MigrationAuthority, error::RegistryError};
|
||||
|
||||
use super::{PostgresPoolConfig, PostgresRegistry};
|
||||
|
||||
@@ -33,7 +33,7 @@ impl PostgresRegistry {
|
||||
.max_lifetime(Duration::from_millis(pool_config.max_lifetime_ms))
|
||||
.connect_with(connect_options)
|
||||
.await?;
|
||||
migrations::apply_postgres(&pool).await?;
|
||||
MigrationAuthority::require_current(&pool).await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
@@ -46,11 +46,6 @@ impl PostgresRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn migrate(&self) -> Result<(), RegistryError> {
|
||||
migrations::apply_postgres(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_in_schema(
|
||||
database_url: &str,
|
||||
schema: Option<&str>,
|
||||
@@ -69,7 +64,7 @@ impl PostgresRegistry {
|
||||
}
|
||||
|
||||
let registry = Self { pool };
|
||||
registry.migrate().await?;
|
||||
MigrationAuthority::require_current(®istry.pool).await?;
|
||||
Ok(registry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,7 @@ fn map_invocation_log_record(row: &PgRow) -> Result<InvocationLogRecord, Registr
|
||||
tool_name: row.try_get("tool_name")?,
|
||||
message: row.try_get("message")?,
|
||||
request_id: row.try_get("request_id")?,
|
||||
trace_id: row.try_get("trace_id")?,
|
||||
status_code: match row.try_get::<Option<i32>, _>("status_code")? {
|
||||
Some(value) => {
|
||||
Some(
|
||||
|
||||
@@ -43,6 +43,26 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: CreateInvocationLogRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let request_id =
|
||||
request
|
||||
.log
|
||||
.request_id
|
||||
.as_deref()
|
||||
.ok_or(RegistryError::InvalidCorrelationIdentity {
|
||||
field: "request_id",
|
||||
})?;
|
||||
crank_core::RequestId::parse(request_id).map_err(|_| {
|
||||
RegistryError::InvalidCorrelationIdentity {
|
||||
field: "request_id",
|
||||
}
|
||||
})?;
|
||||
let trace_id = request
|
||||
.log
|
||||
.trace_id
|
||||
.as_deref()
|
||||
.ok_or(RegistryError::InvalidCorrelationIdentity { field: "trace_id" })?;
|
||||
crank_core::TraceId::parse(trace_id)
|
||||
.map_err(|_| RegistryError::InvalidCorrelationIdentity { field: "trace_id" })?;
|
||||
let request_preview = crank_core::sanitize_invocation_preview(&request.log.request_preview);
|
||||
let response_preview =
|
||||
crank_core::sanitize_invocation_preview(&request.log.response_preview);
|
||||
@@ -58,6 +78,7 @@ impl PostgresRegistry {
|
||||
tool_name,
|
||||
message,
|
||||
request_id,
|
||||
trace_id,
|
||||
status_code,
|
||||
duration_ms,
|
||||
error_kind,
|
||||
@@ -65,7 +86,7 @@ impl PostgresRegistry {
|
||||
response_preview_json,
|
||||
created_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::timestamptz
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::timestamptz
|
||||
)",
|
||||
)
|
||||
.bind(request.log.id.as_str())
|
||||
@@ -78,6 +99,7 @@ impl PostgresRegistry {
|
||||
.bind(&request.log.tool_name)
|
||||
.bind(&request.log.message)
|
||||
.bind(&request.log.request_id)
|
||||
.bind(&request.log.trace_id)
|
||||
.bind(request.log.status_code.map(i32::from))
|
||||
.bind(i64::try_from(request.log.duration_ms).map_err(|_| {
|
||||
RegistryError::InvalidNumericValue {
|
||||
@@ -111,6 +133,7 @@ impl PostgresRegistry {
|
||||
l.tool_name,
|
||||
l.message,
|
||||
l.request_id,
|
||||
l.trace_id,
|
||||
l.status_code,
|
||||
l.duration_ms,
|
||||
l.error_kind,
|
||||
@@ -183,6 +206,7 @@ impl PostgresRegistry {
|
||||
l.tool_name,
|
||||
l.message,
|
||||
l.request_id,
|
||||
l.trace_id,
|
||||
l.status_code,
|
||||
l.duration_ms,
|
||||
l.error_kind,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::{collections::BTreeMap, env};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -13,11 +11,9 @@ pub struct PostgresPoolConfig {
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum PostgresPoolConfigError {
|
||||
#[error("invalid postgres pool setting {name}={value}")]
|
||||
InvalidValue { name: &'static str, value: String },
|
||||
#[error("POSTGRES_MAX_CONNECTIONS must be greater than zero")]
|
||||
ZeroMaxConnections,
|
||||
#[error("POSTGRES_MIN_CONNECTIONS must not exceed POSTGRES_MAX_CONNECTIONS")]
|
||||
#[error("postgres pool setting is outside its allowed bounds: {field}")]
|
||||
OutOfRange { field: &'static str },
|
||||
#[error("minimum connections must not exceed maximum connections")]
|
||||
MinConnectionsExceedMax,
|
||||
}
|
||||
|
||||
@@ -34,173 +30,60 @@ impl Default for PostgresPoolConfig {
|
||||
}
|
||||
|
||||
impl PostgresPoolConfig {
|
||||
pub fn from_env() -> Result<Self, PostgresPoolConfigError> {
|
||||
Self::from_vars(env::vars())
|
||||
}
|
||||
|
||||
fn from_vars<I, K, V>(vars: I) -> Result<Self, PostgresPoolConfigError>
|
||||
where
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
K: AsRef<str>,
|
||||
V: AsRef<str>,
|
||||
{
|
||||
let vars = vars
|
||||
.into_iter()
|
||||
.map(|(name, value)| (name.as_ref().to_owned(), value.as_ref().to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let defaults = Self::default();
|
||||
let config = Self {
|
||||
max_connections: parse_u32_setting(
|
||||
&vars,
|
||||
"POSTGRES_MAX_CONNECTIONS",
|
||||
defaults.max_connections,
|
||||
)?,
|
||||
min_connections: parse_u32_setting(
|
||||
&vars,
|
||||
"POSTGRES_MIN_CONNECTIONS",
|
||||
defaults.min_connections,
|
||||
)?,
|
||||
acquire_timeout_ms: parse_u64_setting(
|
||||
&vars,
|
||||
"POSTGRES_ACQUIRE_TIMEOUT_MS",
|
||||
defaults.acquire_timeout_ms,
|
||||
)?,
|
||||
idle_timeout_ms: parse_u64_setting(
|
||||
&vars,
|
||||
"POSTGRES_IDLE_TIMEOUT_MS",
|
||||
defaults.idle_timeout_ms,
|
||||
)?,
|
||||
max_lifetime_ms: parse_u64_setting(
|
||||
&vars,
|
||||
"POSTGRES_MAX_LIFETIME_MS",
|
||||
defaults.max_lifetime_ms,
|
||||
)?,
|
||||
};
|
||||
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn validate(self) -> Result<Self, PostgresPoolConfigError> {
|
||||
if self.max_connections == 0 {
|
||||
return Err(PostgresPoolConfigError::ZeroMaxConnections);
|
||||
pub fn try_new(
|
||||
max_connections: u32,
|
||||
min_connections: u32,
|
||||
acquire_timeout_ms: u64,
|
||||
idle_timeout_ms: u64,
|
||||
max_lifetime_ms: u64,
|
||||
) -> Result<Self, PostgresPoolConfigError> {
|
||||
for (field, value, minimum, maximum) in [
|
||||
("max_connections", u64::from(max_connections), 1, 1024),
|
||||
("min_connections", u64::from(min_connections), 0, 1024),
|
||||
("acquire_timeout_ms", acquire_timeout_ms, 1, 300_000),
|
||||
("idle_timeout_ms", idle_timeout_ms, 1_000, 86_400_000),
|
||||
("max_lifetime_ms", max_lifetime_ms, 1_000, 86_400_000),
|
||||
] {
|
||||
if !(minimum..=maximum).contains(&value) {
|
||||
return Err(PostgresPoolConfigError::OutOfRange { field });
|
||||
}
|
||||
}
|
||||
if self.min_connections > self.max_connections {
|
||||
if min_connections > max_connections {
|
||||
return Err(PostgresPoolConfigError::MinConnectionsExceedMax);
|
||||
}
|
||||
|
||||
Ok(self)
|
||||
Ok(Self {
|
||||
max_connections,
|
||||
min_connections,
|
||||
acquire_timeout_ms,
|
||||
idle_timeout_ms,
|
||||
max_lifetime_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_u32_setting(
|
||||
vars: &BTreeMap<String, String>,
|
||||
name: &'static str,
|
||||
default: u32,
|
||||
) -> Result<u32, PostgresPoolConfigError> {
|
||||
vars.get(name)
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| PostgresPoolConfigError::InvalidValue {
|
||||
name,
|
||||
value: value.clone(),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
.map(|value| value.unwrap_or(default))
|
||||
}
|
||||
|
||||
fn parse_u64_setting(
|
||||
vars: &BTreeMap<String, String>,
|
||||
name: &'static str,
|
||||
default: u64,
|
||||
) -> Result<u64, PostgresPoolConfigError> {
|
||||
vars.get(name)
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| PostgresPoolConfigError::InvalidValue {
|
||||
name,
|
||||
value: value.clone(),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
.map(|value| value.unwrap_or(default))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pool_config_tests {
|
||||
mod tests {
|
||||
use super::{PostgresPoolConfig, PostgresPoolConfigError};
|
||||
|
||||
#[test]
|
||||
fn pool_config_uses_explicit_defaults() {
|
||||
let config = PostgresPoolConfig::from_vars(std::iter::empty::<(&str, &str)>()).unwrap();
|
||||
|
||||
fn explicit_defaults_remain_valid() {
|
||||
assert_eq!(
|
||||
config,
|
||||
PostgresPoolConfig {
|
||||
max_connections: 20,
|
||||
min_connections: 2,
|
||||
acquire_timeout_ms: 5_000,
|
||||
idle_timeout_ms: 600_000,
|
||||
max_lifetime_ms: 1_800_000,
|
||||
}
|
||||
PostgresPoolConfig::try_new(20, 2, 5_000, 600_000, 1_800_000).unwrap(),
|
||||
PostgresPoolConfig::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_config_parses_overrides() {
|
||||
let config = PostgresPoolConfig::from_vars([
|
||||
("POSTGRES_MAX_CONNECTIONS", "32"),
|
||||
("POSTGRES_MIN_CONNECTIONS", "4"),
|
||||
("POSTGRES_ACQUIRE_TIMEOUT_MS", "7000"),
|
||||
("POSTGRES_IDLE_TIMEOUT_MS", "900000"),
|
||||
("POSTGRES_MAX_LIFETIME_MS", "3600000"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
fn rejects_invalid_bounds_and_cross_fields() {
|
||||
assert_eq!(
|
||||
config,
|
||||
PostgresPoolConfig {
|
||||
max_connections: 32,
|
||||
min_connections: 4,
|
||||
acquire_timeout_ms: 7_000,
|
||||
idle_timeout_ms: 900_000,
|
||||
max_lifetime_ms: 3_600_000,
|
||||
PostgresPoolConfig::try_new(0, 0, 5_000, 600_000, 1_800_000).unwrap_err(),
|
||||
PostgresPoolConfigError::OutOfRange {
|
||||
field: "max_connections"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_config_rejects_invalid_numeric_value() {
|
||||
let error =
|
||||
PostgresPoolConfig::from_vars([("POSTGRES_MAX_CONNECTIONS", "abc")]).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
PostgresPoolConfigError::InvalidValue {
|
||||
name: "POSTGRES_MAX_CONNECTIONS",
|
||||
value: "abc".to_owned(),
|
||||
}
|
||||
PostgresPoolConfig::try_new(2, 3, 5_000, 600_000, 1_800_000).unwrap_err(),
|
||||
PostgresPoolConfigError::MinConnectionsExceedMax
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_config_rejects_zero_max_connections() {
|
||||
let error = PostgresPoolConfig::from_vars([("POSTGRES_MAX_CONNECTIONS", "0")]).unwrap_err();
|
||||
|
||||
assert_eq!(error, PostgresPoolConfigError::ZeroMaxConnections);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_config_rejects_min_connections_above_max() {
|
||||
let error = PostgresPoolConfig::from_vars([
|
||||
("POSTGRES_MAX_CONNECTIONS", "2"),
|
||||
("POSTGRES_MIN_CONNECTIONS", "3"),
|
||||
])
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, PostgresPoolConfigError::MinConnectionsExceedMax);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,7 @@ pub(super) fn test_invocation_log(
|
||||
tool_name: "create_lead".to_owned(),
|
||||
message: "invocation".to_owned(),
|
||||
request_id: Some(format!("req_{id}")),
|
||||
trace_id: Some("0af7651916cd43dd8448eb211c80319c".to_owned()),
|
||||
status_code: Some(200),
|
||||
duration_ms,
|
||||
error_kind: None,
|
||||
@@ -256,12 +257,15 @@ impl TestDatabase {
|
||||
}
|
||||
|
||||
pub(super) async fn registry(&self) -> PostgresRegistry {
|
||||
PostgresRegistry::connect(&format!(
|
||||
let database_url = format!(
|
||||
"{}?options=-csearch_path%3D{}",
|
||||
self.database_url, self.schema
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
crank_registry::MigrationAuthority::apply(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
PostgresRegistry::connect(&database_url).await.unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn cleanup(&self) {
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_registry::{MigrationAuthority, MigrationPreflight, PostgresRegistry};
|
||||
use sqlx::Row;
|
||||
|
||||
static EVENT_TRIGGER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_migration_is_versioned_and_safe_under_concurrent_startup() {
|
||||
async fn controlled_authority_is_versioned_and_safe_under_concurrent_apply() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_core_migration").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
|
||||
let (first, second) = tokio::join!(
|
||||
PostgresRegistry::connect(&database_url),
|
||||
PostgresRegistry::connect(&database_url),
|
||||
MigrationAuthority::apply(&pool),
|
||||
MigrationAuthority::apply(&pool),
|
||||
);
|
||||
let first = first.expect("first service startup must apply the migration");
|
||||
second.expect("second service startup must observe the applied migration");
|
||||
first.expect("first controlled runner must apply the sequence");
|
||||
second.expect("second controlled runner must observe the applied sequence");
|
||||
|
||||
let rows = sqlx::query(
|
||||
"select version, description, checksum from __crank_core_migrations order by version",
|
||||
)
|
||||
.fetch_all(first.pool())
|
||||
.await
|
||||
.expect("migration ledger must be readable");
|
||||
let first = PostgresRegistry::connect(&database_url)
|
||||
.await
|
||||
.expect("service startup must verify the migrated schema");
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].get::<i32, _>("version"), 1);
|
||||
assert_eq!(
|
||||
rows[0].get::<String, _>("description"),
|
||||
"community baseline"
|
||||
);
|
||||
let rows =
|
||||
sqlx::query("select version, name, checksum from __crank_migrations order by version")
|
||||
.fetch_all(first.pool())
|
||||
.await
|
||||
.expect("migration ledger must be readable");
|
||||
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0].get::<i64, _>("version"), 1);
|
||||
assert_eq!(rows[0].get::<String, _>("name"), "community-baseline-v1");
|
||||
assert_eq!(
|
||||
rows[0].get::<String, _>("checksum"),
|
||||
"crank-community-baseline-v1"
|
||||
);
|
||||
assert_eq!(rows[1].get::<i64, _>("version"), 2);
|
||||
assert_eq!(rows[1].get::<String, _>("name"), "legacy-consolidation-v2");
|
||||
assert_eq!(rows[1].get::<String, _>("checksum").len(), 64);
|
||||
assert_eq!(rows[2].get::<i64, _>("version"), 3);
|
||||
assert_eq!(
|
||||
rows[2].get::<String, _>("name"),
|
||||
"request-trace-identity-v3"
|
||||
);
|
||||
assert_eq!(rows[2].get::<String, _>("checksum").len(), 64);
|
||||
|
||||
let approval_columns = sqlx::query(
|
||||
"select column_name
|
||||
@@ -41,4 +53,657 @@ async fn core_migration_is_versioned_and_safe_under_concurrent_startup() {
|
||||
.await
|
||||
.expect("approval schema must be readable");
|
||||
assert_eq!(approval_columns.len(), 3);
|
||||
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(first.pool()).await.unwrap(),
|
||||
MigrationPreflight::Current { version: 3 }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_connect_is_read_only_on_fresh_database() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_read_only_startup").await;
|
||||
|
||||
let error = PostgresRegistry::connect(&database_url)
|
||||
.await
|
||||
.expect_err("fresh schema must require the controlled migration command");
|
||||
assert!(error.to_string().contains("schema_missing"));
|
||||
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
let ledger = sqlx::query("select to_regclass('__crank_migrations')::text as name")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_get::<Option<String>, _>("name")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger, None,
|
||||
"startup compatibility check must not create DDL"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_checksum_fails_closed_without_repair() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_changed_checksum").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query("update __crank_migrations set checksum = 'changed' where version = 1")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = MigrationAuthority::apply(&pool)
|
||||
.await
|
||||
.expect_err("published checksum mismatch must fail closed");
|
||||
assert_eq!(error.code(), "checksum_mismatch");
|
||||
let checksum = sqlx::query("select checksum from __crank_migrations where version = 1")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<String, _>("checksum");
|
||||
assert_eq!(
|
||||
checksum, "changed",
|
||||
"authority must not rewrite corrupt history"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_legacy_core").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"insert into workspaces (id, slug, display_name, status, settings_json, created_at, updated_at)
|
||||
values ('ws_preserved', 'preserved', 'Preserved', 'active', '{}'::jsonb, now(), now())",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"insert into operations
|
||||
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
|
||||
values ('op_preserved', 'ws_default', 'preserved', 'Preserved', 'rest', 'draft', now(), now());
|
||||
insert into operation_versions
|
||||
(operation_id, version, status, target_json, input_schema_json, output_schema_json,
|
||||
input_mapping_json, output_mapping_json, execution_config_json,
|
||||
tool_description_json, created_at)
|
||||
values ('op_preserved', 1, 'draft', '{}'::jsonb, '{}'::jsonb, '{}'::jsonb,
|
||||
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, now());
|
||||
insert into agents
|
||||
(id, workspace_id, slug, display_name, description, status, created_at, updated_at)
|
||||
values ('agent_preserved', 'ws_default', 'preserved', 'Preserved', '', 'draft', now(), now());
|
||||
insert into agent_versions
|
||||
(agent_id, version, status, instructions_json, tool_selection_policy_json, created_at)
|
||||
values ('agent_preserved', 1, 'draft', '{}'::jsonb, '{}'::jsonb, now());
|
||||
insert into platform_api_keys
|
||||
(id, workspace_id, agent_id, name, prefix, secret_hash, scopes_json, status, created_at)
|
||||
values ('key_preserved', 'ws_default', 'agent_preserved', 'Preserved', 'cp_', 'hash', '[]'::jsonb, 'active', now());
|
||||
insert into approval_requests
|
||||
(id, workspace_id, agent_id, operation_id, operation_version, status, risk_level,
|
||||
request_payload_json, created_at, expires_at)
|
||||
values ('approval_preserved', 'ws_default', 'agent_preserved', 'op_preserved', 1,
|
||||
'pending', 'high', '{}'::jsonb, now(), now() + interval '1 hour');
|
||||
insert into invocation_logs
|
||||
(id, workspace_id, agent_id, operation_id, source, level, status, tool_name,
|
||||
message, duration_ms, request_preview_json, response_preview_json, created_at)
|
||||
values ('log_preserved', 'ws_default', 'agent_preserved', 'op_preserved', 'mcp',
|
||||
'info', 'success', 'preserved', 'safe', 1, '{}'::jsonb, '{}'::jsonb, now());",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v3_schema(&pool).await;
|
||||
let tables = [
|
||||
"operations",
|
||||
"operation_versions",
|
||||
"agents",
|
||||
"agent_versions",
|
||||
"platform_api_keys",
|
||||
"approval_requests",
|
||||
"invocation_logs",
|
||||
];
|
||||
let mut before = Vec::new();
|
||||
for table in tables {
|
||||
let row = if table == "invocation_logs" {
|
||||
"to_jsonb(t) - 'trace_id'"
|
||||
} else {
|
||||
"to_jsonb(t)"
|
||||
};
|
||||
let sql = format!("select jsonb_agg({row} order by {row}::text)::text from {table} t");
|
||||
before.push(
|
||||
sqlx::query_scalar::<_, Option<String>>(sqlx::AssertSqlSafe(sql))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
sqlx::query(
|
||||
"drop table __crank_migrations, __crank_migration_legacy_audit,
|
||||
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 1,
|
||||
target: 3,
|
||||
}
|
||||
);
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let display_name = sqlx::query("select display_name from workspaces where id = 'ws_preserved'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<String, _>("display_name");
|
||||
assert_eq!(display_name, "Preserved");
|
||||
let mut after = Vec::new();
|
||||
for table in tables {
|
||||
let row = if table == "invocation_logs" {
|
||||
"to_jsonb(t) - 'trace_id'"
|
||||
} else {
|
||||
"to_jsonb(t)"
|
||||
};
|
||||
let sql = format!("select jsonb_agg({row} order by {row}::text)::text from {table} t");
|
||||
after.push(
|
||||
sqlx::query_scalar::<_, Option<String>>(sqlx::AssertSqlSafe(sql))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
assert_eq!(before, after, "brownfield rows must remain byte-equivalent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_mcp_sessions_survive_consolidation() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_legacy_mcp").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"insert into mcp_transport_sessions (
|
||||
id, protocol_version, initialized, supports_elicitation,
|
||||
workspace_slug, agent_slug, created_at, updated_at, expires_at
|
||||
) values ('session_preserved', '2025-11-25', true, false,
|
||||
'default', 'agent', now(), now(), null)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v3_schema(&pool).await;
|
||||
sqlx::query("drop table __crank_migrations, __crank_migration_legacy_audit")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let count = sqlx::query("select count(*)::bigint as count from mcp_transport_sessions where id = 'session_preserved'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<i64, _>("count");
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_apply_does_not_rewrite_audit_timestamps() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_repeat_apply").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let before = sqlx::query("select applied_at from __crank_migrations order by version")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|row| row.get::<time::OffsetDateTime, _>("applied_at"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
let after = sqlx::query("select applied_at from __crank_migrations order by version")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|row| row.get::<time::OffsetDateTime, _>("applied_at"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn future_sequence_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_future_sequence").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query("update __crank_migrations set version = 4 where version = 3")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
"future_version"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthy_v2_is_reported_as_migration_required_and_upgrades_to_v3() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v2_to_v3_identity").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query("delete from __crank_migrations where version = 3")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v3_schema(&pool).await;
|
||||
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 2,
|
||||
target: 3,
|
||||
}
|
||||
);
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::Current { version: 3 }
|
||||
);
|
||||
let trace_column: bool = sqlx::query_scalar(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = 'trace_id'
|
||||
)",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(trace_column);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v2_with_partial_v3_objects_fails_before_apply() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v2_partial_v3").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query("delete from __crank_migrations where version = 3")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"drop index invocation_logs_workspace_request_id_idx;
|
||||
drop index invocation_logs_workspace_trace_id_idx;
|
||||
alter table invocation_logs drop constraint invocation_logs_trace_id_format_check;",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "partial_sequence");
|
||||
assert_eq!(error.stage(), "preflight.schema");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v3_rejects_same_named_constraint_or_index_with_wrong_definition() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v3_named_drift").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"alter table invocation_logs drop constraint invocation_logs_trace_id_format_check;
|
||||
alter table invocation_logs add constraint invocation_logs_trace_id_format_check
|
||||
check (true) not valid;",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
"partial_sequence"
|
||||
);
|
||||
|
||||
sqlx::raw_sql(
|
||||
"alter table invocation_logs drop constraint invocation_logs_trace_id_format_check;
|
||||
alter table invocation_logs add constraint invocation_logs_trace_id_format_check
|
||||
check (
|
||||
trace_id is null or (
|
||||
trace_id ~ '^[0-9a-f]{32}$'
|
||||
and trace_id <> '00000000000000000000000000000000'
|
||||
)
|
||||
) not valid;
|
||||
drop index invocation_logs_workspace_trace_id_idx;
|
||||
create index invocation_logs_workspace_trace_id_idx on invocation_logs(trace_id);",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
"partial_sequence"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v3_upgrade_ignores_oversized_legacy_request_ids_in_partial_index() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_v3_legacy_request_id").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"insert into operations
|
||||
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
|
||||
values ('op_legacy_request', 'ws_default', 'legacy-request', 'Legacy Request',
|
||||
'rest', 'draft', now(), now());",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into invocation_logs
|
||||
(id, workspace_id, operation_id, source, level, status, tool_name, message,
|
||||
request_id, duration_ms, request_preview_json, response_preview_json, created_at)
|
||||
values ('legacy_request_log', 'ws_default', 'op_legacy_request', 'admin', 'info',
|
||||
'success', 'legacy_request', 'safe', $1, 1, '{}'::jsonb, '{}'::jsonb, now())",
|
||||
)
|
||||
.bind("x".repeat(10_000))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("delete from __crank_migrations where version = 3")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v3_schema(&pool).await;
|
||||
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::Current { version: 3 }
|
||||
);
|
||||
}
|
||||
|
||||
async fn remove_v3_schema(pool: &sqlx::PgPool) {
|
||||
sqlx::raw_sql(
|
||||
"drop index if exists invocation_logs_workspace_request_id_idx;
|
||||
alter table invocation_logs drop column if exists trace_id;",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn partial_sequence_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_partial_sequence").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query("delete from __crank_migrations where version = 1")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
"partial_sequence"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_relation_with_current_ledger_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_missing_relation").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query("drop table usage_rollups")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "partial_sequence");
|
||||
assert_eq!(error.stage(), "preflight.schema");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregistered_legacy_extension_provenance_is_rejected() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_legacy_extension").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query("drop table __crank_migrations, __crank_migration_legacy_audit")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let checksum = "a".repeat(64);
|
||||
sqlx::query(
|
||||
"insert into __crank_ext_migrations (extension_name, version, checksum)
|
||||
values ('known-extension', 1, $1)",
|
||||
)
|
||||
.bind(&checksum)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "legacy_conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn any_owned_relation_without_core_ledger_is_partial() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_owned_partial").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
sqlx::query("create table users (id text primary key)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "partial_sequence");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_ledger_with_structural_drift_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_structural_drift").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query("alter table mcp_transport_sessions drop column supports_elicitation")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "partial_sequence");
|
||||
assert_eq!(error.stage(), "preflight.schema");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tampered_legacy_audit_fails_closed() {
|
||||
let database_url = crank_test_support::postgres_schema_url("test_tampered_audit").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"update __crank_migration_legacy_audit set source_checksum = 'tampered' where source = 'core'",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
|
||||
assert_eq!(error.code(), "legacy_conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_consolidation_rolls_back_all_changes() {
|
||||
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
|
||||
let database_url = crank_test_support::postgres_schema_url("test_apply_rollback").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"insert into workspaces (id, slug, display_name, status, settings_json, created_at, updated_at)
|
||||
values ('rollback_preserved', 'rollback-preserved', 'Rollback Preserved', 'active', '{}'::jsonb, now(), now())",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"drop table __crank_migrations, __crank_migration_legacy_audit,
|
||||
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let schema: String = sqlx::query_scalar("select current_schema()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let failure_trigger = format!(
|
||||
"create function reject_story14_v2() returns event_trigger language plpgsql as $$
|
||||
begin
|
||||
if current_schema() = '{schema}' and current_query() like '%__crank_migrations%' then
|
||||
raise exception 'injected v2 ddl failure';
|
||||
end if;
|
||||
end $$;
|
||||
create event trigger reject_story14_v2 on ddl_command_start
|
||||
execute function reject_story14_v2();"
|
||||
);
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||
sqlx::raw_sql(
|
||||
"drop event trigger reject_story14_v2;
|
||||
drop function reject_story14_v2();",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(error.code(), "apply_failed");
|
||||
|
||||
for relation in [
|
||||
"__crank_migrations",
|
||||
"__crank_migration_legacy_audit",
|
||||
"__crank_mcp_migrations",
|
||||
"mcp_transport_sessions",
|
||||
"__crank_ext_migrations",
|
||||
] {
|
||||
let present: bool = sqlx::query_scalar(
|
||||
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
|
||||
)
|
||||
.bind(relation)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!present, "{relation} must roll back with failed v2 DDL");
|
||||
}
|
||||
let preserved: String =
|
||||
sqlx::query_scalar("select display_name from workspaces where id = 'rollback_preserved'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preserved, "Rollback Preserved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_request_trace_identity_migration_rolls_back_all_changes() {
|
||||
let _event_trigger_guard = EVENT_TRIGGER_TEST_LOCK.lock().await;
|
||||
let database_url =
|
||||
crank_test_support::postgres_schema_url("test_trace_identity_rollback").await;
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"insert into operations
|
||||
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
|
||||
values ('op_trace_rollback', 'ws_default', 'trace-rollback', 'Trace rollback',
|
||||
'rest', 'draft', now(), now());
|
||||
insert into invocation_logs
|
||||
(id, workspace_id, operation_id, source, level, status, tool_name, message,
|
||||
duration_ms, request_preview_json, response_preview_json, created_at)
|
||||
values ('trace_rollback_preserved', 'ws_default', 'op_trace_rollback', 'admin',
|
||||
'info', 'success', 'trace_rollback', 'safe preserved row', 1,
|
||||
'{}'::jsonb, '{}'::jsonb, now());",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("delete from __crank_migrations where version = 3")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
remove_v3_schema(&pool).await;
|
||||
let schema: String = sqlx::query_scalar("select current_schema()")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let failure_trigger = format!(
|
||||
"create function reject_story15_v3() returns event_trigger language plpgsql as $$
|
||||
begin
|
||||
if current_schema() = '{schema}' and current_query() like '%invocation_logs_workspace_trace_id_idx%' then
|
||||
raise exception 'injected v3 ddl failure';
|
||||
end if;
|
||||
end $$;
|
||||
create event trigger reject_story15_v3 on ddl_command_start
|
||||
execute function reject_story15_v3();"
|
||||
);
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(failure_trigger))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||
sqlx::raw_sql(
|
||||
"drop event trigger reject_story15_v3;
|
||||
drop function reject_story15_v3();",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(error.code(), "apply_failed");
|
||||
assert_eq!(error.version(), Some(3));
|
||||
|
||||
let trace_column: bool = sqlx::query_scalar(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = 'trace_id'
|
||||
)",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!trace_column, "trace_id column must roll back with v3");
|
||||
for index in [
|
||||
"invocation_logs_workspace_request_id_idx",
|
||||
"invocation_logs_workspace_trace_id_idx",
|
||||
] {
|
||||
let present: bool = sqlx::query_scalar(
|
||||
"select to_regclass(format('%I.%I', current_schema(), $1)) is not null",
|
||||
)
|
||||
.bind(index)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!present, "{index} must roll back with failed v3 DDL");
|
||||
}
|
||||
let ledger_v3: i64 =
|
||||
sqlx::query_scalar("select count(*) from __crank_migrations where version = 3")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ledger_v3, 0, "failed v3 must not be recorded as applied");
|
||||
let preserved: String = sqlx::query_scalar(
|
||||
"select message from invocation_logs where id = 'trace_rollback_preserved'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preserved, "safe preserved row");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user