radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 45219382810b0ab9787e5e0086246ca793f617b0
parent f99415f606ffaab96d324f59f4c31beacaf924c9
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 09:35:27 +0000

transport: validate publish schema contracts

- split fresh schema creation from existing database validation
- require current user_version before accepting existing publish storage
- validate publish table keys, foreign keys, unique constraints, and partial indexes
- cover malformed current-looking schemas without runtime repair or version stamping

Diffstat:
Msrc/core/transport_publish.rs | 748++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 650 insertions(+), 98 deletions(-)

diff --git a/src/core/transport_publish.rs b/src/core/transport_publish.rs @@ -44,6 +44,85 @@ const TOKEN_HASH_PREFIX: &str = "sha256:"; const SCHEMA_VERSION: i64 = 3; const TRANSPORT_KIND_NOSTR: &str = "nostr"; const TRANSPORT_KIND_RETICULUM: &str = "reticulum"; +const TRANSPORT_PUBLISH_SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS transport_publish_principals ( + principal_id TEXT PRIMARY KEY NOT NULL, + label TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + allowed_pubkeys_json TEXT NOT NULL, + allowed_kinds_json TEXT NOT NULL, + allowed_target_policies_json TEXT NOT NULL, + allowed_explicit_transport_kinds_json TEXT NOT NULL, + allowed_nostr_source_policies_json TEXT NOT NULL, + allow_request_targets INTEGER NOT NULL, + job_visibility TEXT NOT NULL, + expires_at_unix INTEGER, + revoked_at_unix INTEGER, + created_at_unix INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS transport_publish_jobs ( + job_id TEXT PRIMARY KEY NOT NULL, + principal_id TEXT NOT NULL, + idempotency_key TEXT, + request_fingerprint TEXT NOT NULL, + status TEXT NOT NULL, + event_id TEXT NOT NULL, + event_pubkey TEXT NOT NULL, + event_kind INTEGER NOT NULL, + target_policy_json TEXT NOT NULL, + delivery_policy_json TEXT NOT NULL, + requested_target_count INTEGER NOT NULL, + effective_target_count INTEGER NOT NULL, + request_json TEXT NOT NULL, + requested_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + completed_at_ms INTEGER, + last_error TEXT, + FOREIGN KEY(principal_id) REFERENCES transport_publish_principals(principal_id) +); +CREATE UNIQUE INDEX IF NOT EXISTS transport_publish_jobs_principal_idempotency_idx + ON transport_publish_jobs(principal_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; +CREATE TABLE IF NOT EXISTS transport_publish_target_results ( + job_id TEXT NOT NULL, + transport_kind TEXT NOT NULL, + endpoint_uri TEXT NOT NULL, + source TEXT NOT NULL, + attempted INTEGER NOT NULL, + outcome_kind TEXT NOT NULL, + message TEXT, + latency_ms INTEGER, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY(job_id, transport_kind, endpoint_uri), + FOREIGN KEY(job_id) REFERENCES transport_publish_jobs(job_id) +); +CREATE TABLE IF NOT EXISTS transport_publish_target_snapshots ( + job_id TEXT NOT NULL, + target_index INTEGER NOT NULL, + transport_kind TEXT NOT NULL, + endpoint_uri TEXT NOT NULL, + source TEXT NOT NULL, + attempted INTEGER NOT NULL, + outcome_kind TEXT NOT NULL, + message TEXT, + latency_ms INTEGER, + created_at_ms INTEGER NOT NULL, + PRIMARY KEY(job_id, target_index), + FOREIGN KEY(job_id) REFERENCES transport_publish_jobs(job_id) +); +CREATE TABLE IF NOT EXISTS transport_publish_nostr_author_cache ( + pubkey TEXT PRIMARY KEY NOT NULL, + relays_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL +); +"#; +const TRANSPORT_PUBLISH_TABLES: &[&str] = &[ + "transport_publish_principals", + "transport_publish_jobs", + "transport_publish_target_results", + "transport_publish_target_snapshots", + "transport_publish_nostr_author_cache", +]; #[derive(Debug, Error)] pub enum TransportPublishError { @@ -843,84 +922,19 @@ impl TransportPublishStore { } fn from_connection(connection: Connection) -> Result<Self, TransportPublishError> { - connection.execute_batch( - r#" - PRAGMA foreign_keys = ON; - CREATE TABLE IF NOT EXISTS transport_publish_principals ( - principal_id TEXT PRIMARY KEY NOT NULL, - label TEXT NOT NULL, - token_hash TEXT NOT NULL UNIQUE, - allowed_pubkeys_json TEXT NOT NULL, - allowed_kinds_json TEXT NOT NULL, - allowed_target_policies_json TEXT NOT NULL, - allowed_explicit_transport_kinds_json TEXT NOT NULL, - allowed_nostr_source_policies_json TEXT NOT NULL, - allow_request_targets INTEGER NOT NULL, - job_visibility TEXT NOT NULL, - expires_at_unix INTEGER, - revoked_at_unix INTEGER, - created_at_unix INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS transport_publish_jobs ( - job_id TEXT PRIMARY KEY NOT NULL, - principal_id TEXT NOT NULL, - idempotency_key TEXT, - request_fingerprint TEXT NOT NULL, - status TEXT NOT NULL, - event_id TEXT NOT NULL, - event_pubkey TEXT NOT NULL, - event_kind INTEGER NOT NULL, - target_policy_json TEXT NOT NULL, - delivery_policy_json TEXT NOT NULL, - requested_target_count INTEGER NOT NULL, - effective_target_count INTEGER NOT NULL, - request_json TEXT NOT NULL, - requested_at_ms INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - completed_at_ms INTEGER, - last_error TEXT, - FOREIGN KEY(principal_id) REFERENCES transport_publish_principals(principal_id) - ); - CREATE UNIQUE INDEX IF NOT EXISTS transport_publish_jobs_principal_idempotency_idx - ON transport_publish_jobs(principal_id, idempotency_key) - WHERE idempotency_key IS NOT NULL; - CREATE TABLE IF NOT EXISTS transport_publish_target_results ( - job_id TEXT NOT NULL, - transport_kind TEXT NOT NULL, - endpoint_uri TEXT NOT NULL, - source TEXT NOT NULL, - attempted INTEGER NOT NULL, - outcome_kind TEXT NOT NULL, - message TEXT, - latency_ms INTEGER, - updated_at_ms INTEGER NOT NULL, - PRIMARY KEY(job_id, transport_kind, endpoint_uri), - FOREIGN KEY(job_id) REFERENCES transport_publish_jobs(job_id) - ); - CREATE TABLE IF NOT EXISTS transport_publish_target_snapshots ( - job_id TEXT NOT NULL, - target_index INTEGER NOT NULL, - transport_kind TEXT NOT NULL, - endpoint_uri TEXT NOT NULL, - source TEXT NOT NULL, - attempted INTEGER NOT NULL, - outcome_kind TEXT NOT NULL, - message TEXT, - latency_ms INTEGER, - created_at_ms INTEGER NOT NULL, - PRIMARY KEY(job_id, target_index), - FOREIGN KEY(job_id) REFERENCES transport_publish_jobs(job_id) - ); - CREATE TABLE IF NOT EXISTS transport_publish_nostr_author_cache ( - pubkey TEXT PRIMARY KEY NOT NULL, - relays_json TEXT NOT NULL, - updated_at_ms INTEGER NOT NULL - ); - "#, - )?; - validate_transport_publish_schema(&connection)?; + connection.execute_batch("PRAGMA foreign_keys = ON;")?; + match transport_publish_schema_state(&connection)? { + TransportPublishSchemaState::Fresh => { + connection.execute_batch(TRANSPORT_PUBLISH_SCHEMA_SQL)?; + connection.pragma_update(None, "user_version", SCHEMA_VERSION)?; + validate_transport_publish_schema(&connection)?; + } + TransportPublishSchemaState::Existing => { + validate_transport_publish_schema_version(&connection)?; + validate_transport_publish_schema(&connection)?; + } + } recover_interrupted_publish_jobs(&connection)?; - connection.pragma_update(None, "user_version", SCHEMA_VERSION)?; Ok(Self { inner: Arc::new(Mutex::new(connection)), }) @@ -1364,7 +1378,57 @@ struct PublishJobRow { view: TransportPublishJobView, } +enum TransportPublishSchemaState { + Fresh, + Existing, +} + +fn transport_publish_schema_state( + connection: &Connection, +) -> Result<TransportPublishSchemaState, TransportPublishError> { + let names = connection + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name LIKE 'transport_publish_%'", + )? + .query_map([], |row| row.get::<_, String>(0))? + .collect::<Result<BTreeSet<_>, _>>()?; + if names.is_empty() { + let version = transport_publish_schema_version(connection)?; + if version == 0 { + Ok(TransportPublishSchemaState::Fresh) + } else { + Err(TransportPublishError::Schema { + table: "transport_publish_schema", + detail: format!( + "fresh schema initialization requires user_version 0, got {version}" + ), + }) + } + } else { + Ok(TransportPublishSchemaState::Existing) + } +} + +fn transport_publish_schema_version(connection: &Connection) -> Result<i64, TransportPublishError> { + Ok(connection.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?) +} + +fn validate_transport_publish_schema_version( + connection: &Connection, +) -> Result<(), TransportPublishError> { + let version = transport_publish_schema_version(connection)?; + if version == SCHEMA_VERSION { + Ok(()) + } else { + Err(TransportPublishError::Schema { + table: "transport_publish_schema", + detail: format!("user_version must be {SCHEMA_VERSION}, got {version}"), + }) + } +} + fn validate_transport_publish_schema(connection: &Connection) -> Result<(), TransportPublishError> { + validate_foreign_keys_enabled(connection)?; validate_table_columns( connection, "transport_publish_principals", @@ -1384,6 +1448,17 @@ fn validate_transport_publish_schema(connection: &Connection) -> Result<(), Tran RequiredColumn::integer_not_null("created_at_unix"), ], )?; + validate_primary_key( + connection, + "transport_publish_principals", + &["principal_id"], + )?; + validate_unique_index( + connection, + "transport_publish_principals", + &["token_hash"], + None, + )?; validate_table_columns( connection, "transport_publish_jobs", @@ -1407,6 +1482,20 @@ fn validate_transport_publish_schema(connection: &Connection) -> Result<(), Tran RequiredColumn::text_nullable("last_error"), ], )?; + validate_primary_key(connection, "transport_publish_jobs", &["job_id"])?; + validate_foreign_key( + connection, + "transport_publish_jobs", + &["principal_id"], + "transport_publish_principals", + &["principal_id"], + )?; + validate_unique_index( + connection, + "transport_publish_jobs", + &["principal_id", "idempotency_key"], + Some("WHERE idempotency_key IS NOT NULL"), + )?; validate_table_columns( connection, "transport_publish_target_results", @@ -1422,6 +1511,18 @@ fn validate_transport_publish_schema(connection: &Connection) -> Result<(), Tran RequiredColumn::integer_not_null("updated_at_ms"), ], )?; + validate_primary_key( + connection, + "transport_publish_target_results", + &["job_id", "transport_kind", "endpoint_uri"], + )?; + validate_foreign_key( + connection, + "transport_publish_target_results", + &["job_id"], + "transport_publish_jobs", + &["job_id"], + )?; validate_table_columns( connection, "transport_publish_target_snapshots", @@ -1438,6 +1539,18 @@ fn validate_transport_publish_schema(connection: &Connection) -> Result<(), Tran RequiredColumn::integer_not_null("created_at_ms"), ], )?; + validate_primary_key( + connection, + "transport_publish_target_snapshots", + &["job_id", "target_index"], + )?; + validate_foreign_key( + connection, + "transport_publish_target_snapshots", + &["job_id"], + "transport_publish_jobs", + &["job_id"], + )?; validate_table_columns( connection, "transport_publish_nostr_author_cache", @@ -1446,7 +1559,16 @@ fn validate_transport_publish_schema(connection: &Connection) -> Result<(), Tran RequiredColumn::text_not_null("relays_json"), RequiredColumn::integer_not_null("updated_at_ms"), ], - ) + )?; + validate_primary_key( + connection, + "transport_publish_nostr_author_cache", + &["pubkey"], + )?; + for table in TRANSPORT_PUBLISH_TABLES { + validate_table_present(connection, table)?; + } + Ok(()) } #[derive(Clone, Copy)] @@ -1490,23 +1612,267 @@ impl RequiredColumn { } } -fn validate_table_columns( +struct TableColumnInfo { + column_type: String, + not_null: bool, + primary_key_position: i64, +} + +struct ForeignKeyEntry { + target_table: String, + from_columns: Vec<String>, + to_columns: Vec<String>, +} + +fn validate_foreign_keys_enabled(connection: &Connection) -> Result<(), TransportPublishError> { + let enabled = + connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))?; + if enabled == 1 { + Ok(()) + } else { + Err(TransportPublishError::Schema { + table: "transport_publish_schema", + detail: "foreign key enforcement must be enabled".to_owned(), + }) + } +} + +fn validate_table_present( connection: &Connection, table: &'static str, - required_columns: &[RequiredColumn], ) -> Result<(), TransportPublishError> { - let columns = connection + let exists = connection + .query_row( + "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1", + params![table], + |_| Ok(()), + ) + .optional()? + .is_some(); + if exists { + Ok(()) + } else { + Err(TransportPublishError::Schema { + table, + detail: "table is missing".to_owned(), + }) + } +} + +fn table_columns( + connection: &Connection, + table: &'static str, +) -> Result<BTreeMap<String, TableColumnInfo>, TransportPublishError> { + Ok(connection .prepare(format!("PRAGMA table_info({table})").as_str())? .query_map([], |row| { Ok(( row.get::<_, String>(1)?, - ( - row.get::<_, String>(2)?.to_ascii_uppercase(), - row.get::<_, i64>(3)? != 0, - ), + TableColumnInfo { + column_type: row.get::<_, String>(2)?.to_ascii_uppercase(), + not_null: row.get::<_, i64>(3)? != 0, + primary_key_position: row.get::<_, i64>(5)?, + }, + )) + })? + .collect::<Result<BTreeMap<_, _>, _>>()?) +} + +fn validate_primary_key( + connection: &Connection, + table: &'static str, + expected_columns: &[&'static str], +) -> Result<(), TransportPublishError> { + let columns = table_columns(connection, table)?; + let mut primary_key = columns + .iter() + .filter_map(|(name, column)| { + (column.primary_key_position > 0) + .then_some((column.primary_key_position, name.as_str())) + }) + .collect::<Vec<_>>(); + primary_key.sort_by_key(|(position, _)| *position); + let actual_columns = primary_key + .into_iter() + .map(|(_, name)| name) + .collect::<Vec<_>>(); + if actual_columns == expected_columns { + Ok(()) + } else { + Err(TransportPublishError::Schema { + table, + detail: format!("primary key must be ({})", expected_columns.join(", ")), + }) + } +} + +fn validate_unique_index( + connection: &Connection, + table: &'static str, + expected_columns: &[&'static str], + partial_where: Option<&'static str>, +) -> Result<(), TransportPublishError> { + let indexes = connection + .prepare(format!("PRAGMA index_list({table})").as_str())? + .query_map([], |row| { + Ok(( + row.get::<_, String>(1)?, + row.get::<_, i64>(2)? != 0, + row.get::<_, i64>(4)? != 0, + )) + })? + .collect::<Result<Vec<_>, _>>()?; + for (index_name, unique, partial) in indexes { + if !unique { + continue; + } + if partial_where.is_some() != partial { + continue; + } + let index_columns = index_columns(connection, index_name.as_str())?; + if !columns_match(index_columns.as_slice(), expected_columns) { + continue; + } + let where_matches = match partial_where { + Some(required_where) => { + index_sql_contains_where(connection, table, index_name.as_str(), required_where)? + } + None => true, + }; + if where_matches { + return Ok(()); + } + } + Err(TransportPublishError::Schema { + table, + detail: format!( + "missing required unique index on ({})", + expected_columns.join(", ") + ), + }) +} + +fn index_columns( + connection: &Connection, + index_name: &str, +) -> Result<Vec<String>, TransportPublishError> { + let mut columns = connection + .prepare(format!("PRAGMA index_info({index_name})").as_str())? + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(2)?)) + })? + .collect::<Result<Vec<_>, _>>()?; + columns.sort_by_key(|(position, _)| *position); + Ok(columns.into_iter().map(|(_, name)| name).collect()) +} + +fn index_sql_contains_where( + connection: &Connection, + table: &'static str, + index_name: &str, + required_where: &str, +) -> Result<bool, TransportPublishError> { + let sql = connection + .query_row( + "SELECT sql FROM sqlite_schema WHERE type = 'index' AND tbl_name = ?1 AND name = ?2", + params![table, index_name], + |row| row.get::<_, Option<String>>(0), + ) + .optional()? + .flatten() + .unwrap_or_default(); + Ok(normalized_sql(sql.as_str()).contains(normalized_sql(required_where).as_str())) +} + +fn normalized_sql(sql: &str) -> String { + sql.split_whitespace() + .collect::<Vec<_>>() + .join(" ") + .to_ascii_uppercase() +} + +fn validate_foreign_key( + connection: &Connection, + table: &'static str, + expected_from_columns: &[&'static str], + expected_target_table: &'static str, + expected_to_columns: &[&'static str], +) -> Result<(), TransportPublishError> { + for foreign_key in foreign_keys(connection, table)? { + if foreign_key.target_table == expected_target_table + && columns_match(foreign_key.from_columns.as_slice(), expected_from_columns) + && columns_match(foreign_key.to_columns.as_slice(), expected_to_columns) + { + return Ok(()); + } + } + Err(TransportPublishError::Schema { + table, + detail: format!( + "missing required foreign key ({}) references {}({})", + expected_from_columns.join(", "), + expected_target_table, + expected_to_columns.join(", ") + ), + }) +} + +fn foreign_keys( + connection: &Connection, + table: &'static str, +) -> Result<Vec<ForeignKeyEntry>, TransportPublishError> { + let mut groups = BTreeMap::<i64, (String, Vec<(i64, String, String)>)>::new(); + let rows = connection + .prepare(format!("PRAGMA foreign_key_list({table})").as_str())? + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, )) })? - .collect::<Result<BTreeMap<_, _>, _>>()?; + .collect::<Result<Vec<_>, _>>()?; + for (id, seq, target_table, from_column, to_column) in rows { + let entry = groups + .entry(id) + .or_insert_with(|| (target_table, Vec::new())); + entry.1.push((seq, from_column, to_column)); + } + let mut foreign_keys = Vec::new(); + for (_, (target_table, mut columns)) in groups { + columns.sort_by_key(|(seq, _, _)| *seq); + foreign_keys.push(ForeignKeyEntry { + target_table, + from_columns: columns + .iter() + .map(|(_, from_column, _)| from_column.clone()) + .collect(), + to_columns: columns + .into_iter() + .map(|(_, _, to_column)| to_column) + .collect(), + }); + } + Ok(foreign_keys) +} + +fn columns_match(actual: &[String], expected: &[&'static str]) -> bool { + actual.len() == expected.len() + && actual + .iter() + .map(String::as_str) + .zip(expected.iter().copied()) + .all(|(actual, expected)| actual == expected) +} + +fn validate_table_columns( + connection: &Connection, + table: &'static str, + required_columns: &[RequiredColumn], +) -> Result<(), TransportPublishError> { + let columns = table_columns(connection, table)?; if columns.is_empty() { return Err(TransportPublishError::Schema { table, @@ -1514,22 +1880,22 @@ fn validate_table_columns( }); } for required in required_columns { - let Some((column_type, not_null)) = columns.get(required.name) else { + let Some(column) = columns.get(required.name) else { return Err(TransportPublishError::Schema { table, detail: format!("missing required column `{}`", required.name), }); }; - if column_type != required.column_type { + if column.column_type != required.column_type { return Err(TransportPublishError::Schema { table, detail: format!( "column `{}` must have type {}, got {}", - required.name, required.column_type, column_type + required.name, required.column_type, column.column_type ), }); } - if *not_null != required.not_null { + if column.not_null != required.not_null { return Err(TransportPublishError::Schema { table, detail: format!( @@ -1544,6 +1910,21 @@ fn validate_table_columns( }); } } + if columns.len() != required_columns.len() { + let required = required_columns + .iter() + .map(|column| column.name) + .collect::<BTreeSet<_>>(); + let extras = columns + .keys() + .filter(|column| !required.contains(column.as_str())) + .cloned() + .collect::<Vec<_>>(); + return Err(TransportPublishError::Schema { + table, + detail: format!("unexpected columns: {}", extras.join(", ")), + }); + } Ok(()) } @@ -2418,9 +2799,9 @@ fn current_unix_millis() -> i64 { mod tests { use super::{ PublishJobInsert, PublishJobVisibility, PublishPrincipal, PublishPrincipalInit, - SCHEMA_VERSION, TRANSPORT_KIND_NOSTR, TRANSPORT_KIND_RETICULUM, TransportPublish, - TransportPublishError, TransportPublishStore, generate_bearer_token, hash_bearer_token, - parse_nostr_source_policy, + SCHEMA_VERSION, TRANSPORT_KIND_NOSTR, TRANSPORT_KIND_RETICULUM, + TRANSPORT_PUBLISH_SCHEMA_SQL, TransportPublish, TransportPublishError, + TransportPublishStore, generate_bearer_token, hash_bearer_token, parse_nostr_source_policy, }; use crate::app::config::{ NostrRelayUrlPolicy, TransportPublishConfig, TransportPublishNostrConfig, @@ -2471,6 +2852,56 @@ mod tests { } } + fn schema_with_replacement(fragment: &str, replacement: &str) -> String { + assert!(TRANSPORT_PUBLISH_SCHEMA_SQL.contains(fragment)); + TRANSPORT_PUBLISH_SCHEMA_SQL.replace(fragment, replacement) + } + + fn create_existing_schema( + database_path: &std::path::Path, + schema_sql: &str, + user_version: Option<i64>, + ) { + let connection = rusqlite::Connection::open(database_path).expect("open schema database"); + connection.execute_batch(schema_sql).expect("create schema"); + if let Some(version) = user_version { + connection + .pragma_update(None, "user_version", version) + .expect("set schema version"); + } + } + + fn open_schema_error(database_path: &std::path::Path) -> TransportPublishError { + match TransportPublishStore::open(database_path.to_path_buf()) { + Ok(_) => panic!("malformed schema opened"), + Err(error) => error, + } + } + + fn assert_schema_error( + error: TransportPublishError, + expected_table: &'static str, + expected_detail: &str, + ) { + match error { + TransportPublishError::Schema { table, detail } => { + assert_eq!(table, expected_table); + assert!( + detail.contains(expected_detail), + "schema detail `{detail}` did not contain `{expected_detail}`" + ); + } + error => panic!("unexpected error: {error}"), + } + } + + fn database_user_version(database_path: &std::path::Path) -> i64 { + let connection = rusqlite::Connection::open(database_path).expect("open schema database"); + connection + .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) + .expect("user version") + } + fn signed_event(identity: &RadrootsIdentity, content: &str) -> SignedNostrEventWire { let event = radroots_nostr_build_event( 30_402, @@ -3231,7 +3662,7 @@ mod tests { connection .execute_batch( r#" - PRAGMA user_version = 1; + PRAGMA user_version = 3; CREATE TABLE transport_publish_principals ( principal_id TEXT PRIMARY KEY NOT NULL, label TEXT NOT NULL, @@ -3313,10 +3744,131 @@ mod tests { .iter() .any(|column| column == "allowed_explicit_transport_kinds_json") ); - let version = connection - .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) - .expect("user version"); - assert_eq!(version, 1); + assert_eq!( + database_user_version(database_path.as_path()), + SCHEMA_VERSION + ); + } + + #[test] + fn transport_store_open_rejects_current_schema_missing_user_version() { + let directory = tempfile::tempdir().expect("tempdir"); + let database_path = directory + .path() + .join("publish-proxy-missing-version.sqlite"); + create_existing_schema(database_path.as_path(), TRANSPORT_PUBLISH_SCHEMA_SQL, None); + + assert_schema_error( + open_schema_error(database_path.as_path()), + "transport_publish_schema", + "user_version", + ); + assert_eq!(database_user_version(database_path.as_path()), 0); + } + + #[test] + fn transport_store_open_rejects_current_schema_without_token_hash_unique_index() { + let directory = tempfile::tempdir().expect("tempdir"); + let database_path = directory + .path() + .join("publish-proxy-missing-token-unique.sqlite"); + let schema = schema_with_replacement( + "token_hash TEXT NOT NULL UNIQUE", + "token_hash TEXT NOT NULL", + ); + create_existing_schema( + database_path.as_path(), + schema.as_str(), + Some(SCHEMA_VERSION), + ); + + assert_schema_error( + open_schema_error(database_path.as_path()), + "transport_publish_principals", + "token_hash", + ); + assert_eq!( + database_user_version(database_path.as_path()), + SCHEMA_VERSION + ); + } + + #[test] + fn transport_store_open_rejects_current_schema_without_idempotency_unique_index() { + let directory = tempfile::tempdir().expect("tempdir"); + let database_path = directory + .path() + .join("publish-proxy-missing-idempotency-index.sqlite"); + let schema = schema_with_replacement( + r#"CREATE UNIQUE INDEX IF NOT EXISTS transport_publish_jobs_principal_idempotency_idx + ON transport_publish_jobs(principal_id, idempotency_key) + WHERE idempotency_key IS NOT NULL;"#, + "", + ); + create_existing_schema( + database_path.as_path(), + schema.as_str(), + Some(SCHEMA_VERSION), + ); + + assert_schema_error( + open_schema_error(database_path.as_path()), + "transport_publish_jobs", + "principal_id, idempotency_key", + ); + assert_eq!( + database_user_version(database_path.as_path()), + SCHEMA_VERSION + ); + } + + #[test] + fn transport_store_open_rejects_current_schema_without_job_primary_key() { + let directory = tempfile::tempdir().expect("tempdir"); + let database_path = directory.path().join("publish-proxy-missing-job-pk.sqlite"); + let schema = + schema_with_replacement("job_id TEXT PRIMARY KEY NOT NULL", "job_id TEXT NOT NULL"); + create_existing_schema( + database_path.as_path(), + schema.as_str(), + Some(SCHEMA_VERSION), + ); + + assert_schema_error( + open_schema_error(database_path.as_path()), + "transport_publish_jobs", + "primary key", + ); + assert_eq!( + database_user_version(database_path.as_path()), + SCHEMA_VERSION + ); + } + + #[test] + fn transport_store_open_rejects_current_schema_without_job_foreign_key() { + let directory = tempfile::tempdir().expect("tempdir"); + let database_path = directory.path().join("publish-proxy-missing-job-fk.sqlite"); + let schema = schema_with_replacement( + ", + FOREIGN KEY(principal_id) REFERENCES transport_publish_principals(principal_id)", + "", + ); + create_existing_schema( + database_path.as_path(), + schema.as_str(), + Some(SCHEMA_VERSION), + ); + + assert_schema_error( + open_schema_error(database_path.as_path()), + "transport_publish_jobs", + "foreign key", + ); + assert_eq!( + database_user_version(database_path.as_path()), + SCHEMA_VERSION + ); } #[tokio::test]