lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit b900266ab73b85effeb6be442483dac904ed9222
parent a5985c1a5b2b063f063c42c7f72a92669cdf5d79
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 13:07:38 +0000

service-sqlite: define migration ledger

- define immutable v1 migration names, kinds, checksums, and descriptors
- validate exact caller order, contiguous versions, uniqueness, and bounded catalogs
- pin SQL and callback content with independent domain-separated SHA-256 literals
- keep migration execution, ledger storage, and SQLx authority deferred

Diffstat:
MCargo.lock | 1+
Mcrates/service_sqlite/Cargo.toml | 1+
Mcrates/service_sqlite/src/lib.rs | 5+++++
Acrates/service_sqlite/src/migration.rs | 643+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_sqlite/tests/package_boundary.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 700 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3915,6 +3915,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_json", + "sha2", "sqlx", "tempfile", "tokio", diff --git a/crates/service_sqlite/Cargo.toml b/crates/service_sqlite/Cargo.toml @@ -17,6 +17,7 @@ radroots_runtime_paths = { workspace = true } radroots_storage = { workspace = true } rustix = { workspace = true } serde = { workspace = true, features = ["derive", "std"] } +sha2 = { workspace = true } sqlx = { workspace = true, features = ["runtime-tokio", "sqlite-bundled"] } [dev-dependencies] diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs @@ -7,6 +7,7 @@ mod config; mod error; mod initialize; mod metadata; +mod migration; mod open; mod status; @@ -20,5 +21,9 @@ pub use metadata::{ ServiceDatabaseIdentity, ServiceDatabaseMetadata, ServiceSqliteApplicationId, ServiceSqliteMetadataValueError, }; +pub use migration::{ + MigrationCatalog, MigrationChecksum, MigrationContractError, MigrationDescriptor, + MigrationKind, MigrationName, +}; pub use open::{OpenMode, ServiceSqlitePathError, ServiceSqlitePaths}; pub use status::{StorageHealth, StorageIntegrity, StorageStatus}; diff --git a/crates/service_sqlite/src/migration.rs b/crates/service_sqlite/src/migration.rs @@ -0,0 +1,643 @@ +//! Deterministic, execution-free migration identity contracts. + +use core::fmt; +use std::{collections::BTreeSet, error::Error}; + +use sha2::{Digest, Sha256}; + +const MIGRATION_CONTENT_DOMAIN: &[u8] = b"radroots.service_sqlite.migration_content.v1\0"; +const MIGRATION_CATALOG_DOMAIN: &[u8] = b"radroots.service_sqlite.migration_catalog.v1\0"; +const BASE_SCHEMA_VERSION: u32 = 1; +const MAX_MIGRATION_NAME_UTF8_BYTES: usize = 128; +const MAX_MIGRATION_CONTENT_BYTES: usize = 1024 * 1024; +const MAX_MIGRATION_COUNT: usize = 4096; + +/// A bounded stable lower-snake migration name. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MigrationName(&'static str); + +impl MigrationName { + /// Validates an embedded migration name. + pub fn new(value: &'static str) -> Result<Self, MigrationContractError> { + if !valid_name(value) { + return Err(MigrationContractError::InvalidName); + } + Ok(Self(value)) + } + + /// Returns the validated stable name. + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +impl fmt::Debug for MigrationName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("MigrationName") + .field(&self.0) + .finish() + } +} + +/// The execution kind whose canonical content is bound by a checksum. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MigrationKind { + Sql, + Callback, +} + +impl MigrationKind { + const fn tag(self) -> u8 { + match self { + Self::Sql => 0, + Self::Callback => 1, + } + } +} + +/// A SHA-256 digest over one migration body or one ordered catalog. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct MigrationChecksum([u8; 32]); + +impl MigrationChecksum { + /// Constructs an independently pinned checksum from exact reviewed bytes. + #[must_use] + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Computes the frozen SQL-content checksum. + #[must_use] + pub fn for_sql(sql: &str) -> Self { + Self::for_content(MigrationKind::Sql, sql.as_bytes()) + } + + /// Computes the frozen callback-definition checksum. + #[must_use] + pub fn for_callback(callback_definition: &[u8]) -> Self { + Self::for_content(MigrationKind::Callback, callback_definition) + } + + /// Returns the exact digest bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + fn for_content(kind: MigrationKind, content: &[u8]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(MIGRATION_CONTENT_DOMAIN); + hasher.update([kind.tag()]); + let content_len = u64::try_from(content.len()).expect("migration content bound fits u64"); + hasher.update(content_len.to_be_bytes()); + hasher.update(content); + Self(hasher.finalize().into()) + } +} + +impl fmt::Debug for MigrationChecksum { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MigrationChecksum([redacted])") + } +} + +/// One immutable future-schema migration identity. +#[derive(Clone, PartialEq, Eq)] +pub struct MigrationDescriptor { + target_version: u32, + name: MigrationName, + kind: MigrationKind, + checksum: MigrationChecksum, + content: &'static [u8], +} + +impl MigrationDescriptor { + /// Defines an embedded SQL migration after verifying its expected checksum. + pub fn sql( + target_version: u32, + name: &'static str, + sql: &'static str, + expected_checksum: MigrationChecksum, + ) -> Result<Self, MigrationContractError> { + Self::new( + target_version, + name, + MigrationKind::Sql, + sql.as_bytes(), + expected_checksum, + ) + } + + /// Defines an execution-free callback identity from canonical embedded bytes. + pub fn callback( + target_version: u32, + name: &'static str, + callback_definition: &'static [u8], + expected_checksum: MigrationChecksum, + ) -> Result<Self, MigrationContractError> { + Self::new( + target_version, + name, + MigrationKind::Callback, + callback_definition, + expected_checksum, + ) + } + + fn new( + target_version: u32, + name: &'static str, + kind: MigrationKind, + content: &'static [u8], + expected_checksum: MigrationChecksum, + ) -> Result<Self, MigrationContractError> { + if target_version <= BASE_SCHEMA_VERSION { + return Err(MigrationContractError::InvalidTargetVersion); + } + if content.is_empty() { + return Err(MigrationContractError::EmptyContent); + } + if content.len() > MAX_MIGRATION_CONTENT_BYTES { + return Err(MigrationContractError::ContentTooLarge); + } + let name = MigrationName::new(name)?; + let actual_checksum = MigrationChecksum::for_content(kind, content); + if actual_checksum != expected_checksum { + return Err(MigrationContractError::ChecksumMismatch); + } + Ok(Self { + target_version, + name, + kind, + checksum: actual_checksum, + content, + }) + } + + /// Returns the schema version produced by this migration. + #[must_use] + pub const fn target_version(&self) -> u32 { + self.target_version + } + + /// Returns the stable migration name. + #[must_use] + pub const fn name(&self) -> MigrationName { + self.name + } + + /// Returns whether this descriptor binds SQL or callback-definition bytes. + #[must_use] + pub const fn kind(&self) -> MigrationKind { + self.kind + } + + /// Returns the verified content checksum. + #[must_use] + pub const fn checksum(&self) -> MigrationChecksum { + self.checksum + } +} + +impl fmt::Debug for MigrationDescriptor { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MigrationDescriptor") + .field("target_version", &self.target_version) + .field("name", &self.name) + .field("kind", &self.kind) + .field("checksum", &self.checksum) + .field("content", &"[redacted]") + .finish() + } +} + +/// One validated ordered migration catalog whose baseline is schema v1. +#[derive(Clone, PartialEq, Eq)] +pub struct MigrationCatalog { + descriptors: Box<[MigrationDescriptor]>, + current_version: u32, + digest: MigrationChecksum, +} + +impl MigrationCatalog { + /// Validates and owns at most 4096 future migrations in exact caller order. + pub fn new<I>(descriptors: I) -> Result<Self, MigrationContractError> + where + I: IntoIterator<Item = MigrationDescriptor>, + { + let descriptors: Vec<_> = descriptors + .into_iter() + .take(MAX_MIGRATION_COUNT + 1) + .collect(); + if descriptors.len() > MAX_MIGRATION_COUNT { + return Err(MigrationContractError::TooManyMigrations); + } + validate_catalog(&descriptors)?; + let current_version = descriptors + .last() + .map_or(BASE_SCHEMA_VERSION, MigrationDescriptor::target_version); + let digest = catalog_digest(&descriptors); + Ok(Self { + descriptors: descriptors.into_boxed_slice(), + current_version, + digest, + }) + } + + /// Returns the immutable descriptors in exact execution order. + #[must_use] + pub fn descriptors(&self) -> &[MigrationDescriptor] { + &self.descriptors + } + + /// Returns schema v1 for an empty catalog or the last target version. + #[must_use] + pub const fn current_version(&self) -> u32 { + self.current_version + } + + /// Returns the deterministic digest of the ordered catalog identity. + #[must_use] + pub const fn digest(&self) -> MigrationChecksum { + self.digest + } +} + +impl fmt::Debug for MigrationCatalog { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MigrationCatalog") + .field("descriptor_count", &self.descriptors.len()) + .field("current_version", &self.current_version) + .field("digest", &self.digest) + .finish() + } +} + +/// Stable, content-free migration contract failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MigrationContractError { + InvalidName, + InvalidTargetVersion, + EmptyContent, + ContentTooLarge, + ChecksumMismatch, + TooManyMigrations, + DuplicateVersion, + DuplicateName, + OutOfOrder, + VersionGap, +} + +impl fmt::Display for MigrationContractError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidName => "migration name is invalid", + Self::InvalidTargetVersion => "migration target version is invalid", + Self::EmptyContent => "migration content is empty", + Self::ContentTooLarge => "migration content exceeds the limit", + Self::ChecksumMismatch => "migration checksum does not match", + Self::TooManyMigrations => "migration catalog exceeds the limit", + Self::DuplicateVersion => "migration target version is duplicated", + Self::DuplicateName => "migration name is duplicated", + Self::OutOfOrder => "migration catalog is out of order", + Self::VersionGap => "migration catalog contains a version gap", + }) + } +} + +impl Error for MigrationContractError {} + +fn valid_name(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.is_empty() || bytes.len() > MAX_MIGRATION_NAME_UTF8_BYTES { + return false; + } + let is_boundary = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); + if !is_boundary(bytes[0]) || !is_boundary(bytes[bytes.len() - 1]) { + return false; + } + let mut previous_underscore = false; + for byte in bytes { + if *byte == b'_' { + if previous_underscore { + return false; + } + previous_underscore = true; + } else if is_boundary(*byte) { + previous_underscore = false; + } else { + return false; + } + } + true +} + +fn validate_catalog(descriptors: &[MigrationDescriptor]) -> Result<(), MigrationContractError> { + let mut versions = BTreeSet::new(); + let mut names = BTreeSet::new(); + for descriptor in descriptors { + if !versions.insert(descriptor.target_version) { + return Err(MigrationContractError::DuplicateVersion); + } + if !names.insert(descriptor.name) { + return Err(MigrationContractError::DuplicateName); + } + } + if descriptors + .windows(2) + .any(|pair| pair[0].target_version > pair[1].target_version) + { + return Err(MigrationContractError::OutOfOrder); + } + for (index, descriptor) in descriptors.iter().enumerate() { + let expected = + BASE_SCHEMA_VERSION + u32::try_from(index).expect("catalog bound fits u32") + 1; + if descriptor.target_version != expected { + return Err(MigrationContractError::VersionGap); + } + } + Ok(()) +} + +fn catalog_digest(descriptors: &[MigrationDescriptor]) -> MigrationChecksum { + let mut hasher = Sha256::new(); + hasher.update(MIGRATION_CATALOG_DOMAIN); + let descriptor_count = + u32::try_from(descriptors.len()).expect("migration catalog bound fits u32"); + hasher.update(descriptor_count.to_be_bytes()); + for descriptor in descriptors { + hasher.update(descriptor.target_version.to_be_bytes()); + let name = descriptor.name.as_str().as_bytes(); + let name_len = u64::try_from(name.len()).expect("migration name bound fits u64"); + hasher.update(name_len.to_be_bytes()); + hasher.update(name); + hasher.update([descriptor.kind.tag()]); + hasher.update(descriptor.checksum.as_bytes()); + } + MigrationChecksum(hasher.finalize().into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SQL_TWO: &str = "CREATE TABLE alpha (id INTEGER PRIMARY KEY);"; + const CALLBACK_THREE: &[u8] = b"callback:rebuild_projection:v1"; + const SQL_TWO_CHECKSUM: MigrationChecksum = MigrationChecksum::from_bytes([ + 0xd9, 0xa8, 0x5f, 0x7a, 0x59, 0x04, 0x0b, 0x3b, 0x25, 0x86, 0x56, 0x48, 0x02, 0x44, 0x10, + 0x93, 0x07, 0xaa, 0x3d, 0x1a, 0x5d, 0xec, 0x04, 0x06, 0xa7, 0x50, 0x99, 0x4f, 0x17, 0xe8, + 0x91, 0x13, + ]); + const CALLBACK_SQL_BYTES_CHECKSUM: MigrationChecksum = MigrationChecksum::from_bytes([ + 0x7a, 0x6e, 0x62, 0xf7, 0xf7, 0xa4, 0xf6, 0x1a, 0xb9, 0x14, 0x84, 0xbf, 0xe6, 0xa1, 0x2f, + 0xf5, 0x0d, 0x62, 0x3d, 0x8d, 0xa2, 0x74, 0x8a, 0x16, 0xf9, 0x18, 0xd9, 0x9a, 0x52, 0xae, + 0xf6, 0x15, + ]); + const CALLBACK_THREE_CHECKSUM: MigrationChecksum = MigrationChecksum::from_bytes([ + 0x7d, 0xca, 0x22, 0x77, 0x1b, 0x17, 0xa9, 0xf2, 0xc8, 0x04, 0x4b, 0xdc, 0xf6, 0xa6, 0xfa, + 0xea, 0x41, 0x46, 0xc3, 0x56, 0xb2, 0x20, 0x17, 0xe1, 0x91, 0xd1, 0xe5, 0x42, 0xbb, 0x69, + 0x47, 0x66, + ]); + + fn sql(version: u32, name: &'static str, source: &'static str) -> MigrationDescriptor { + MigrationDescriptor::sql(version, name, source, MigrationChecksum::for_sql(source)) + .expect("valid SQL descriptor") + } + + #[test] + fn exact_content_checksums_are_deterministic_and_kind_separated() { + let sql = MigrationChecksum::for_sql(SQL_TWO); + let callback = MigrationChecksum::for_callback(SQL_TWO.as_bytes()); + assert_eq!(sql, SQL_TWO_CHECKSUM); + assert_eq!(callback, CALLBACK_SQL_BYTES_CHECKSUM); + assert_eq!(sql, MigrationChecksum::for_sql(SQL_TWO)); + assert_ne!(sql, callback); + assert_ne!( + sql, + MigrationChecksum::for_sql(" CREATE TABLE alpha (id INTEGER PRIMARY KEY);") + ); + assert_ne!( + sql, + MigrationChecksum::for_sql("CREATE TABLE alpha (id INTEGER PRIMARY KEY);\n") + ); + assert_eq!( + MigrationDescriptor::sql(2, "create_alpha", SQL_TWO, SQL_TWO_CHECKSUM) + .unwrap() + .checksum(), + SQL_TWO_CHECKSUM + ); + assert_eq!( + MigrationDescriptor::callback( + 2, + "callback_alpha", + SQL_TWO.as_bytes(), + CALLBACK_SQL_BYTES_CHECKSUM + ) + .unwrap() + .checksum(), + CALLBACK_SQL_BYTES_CHECKSUM + ); + } + + #[test] + fn descriptor_checksum_name_version_and_content_bounds_fail_closed() { + assert_eq!( + MigrationDescriptor::sql(2, "alpha", SQL_TWO, MigrationChecksum::for_sql("other")), + Err(MigrationContractError::ChecksumMismatch) + ); + assert_eq!( + MigrationDescriptor::callback( + 2, + "alpha", + CALLBACK_THREE, + MigrationChecksum::for_callback(b"other") + ), + Err(MigrationContractError::ChecksumMismatch) + ); + for invalid in ["", "_alpha", "alpha_", "Alpha", "alpha-beta", "alpha__beta"] { + assert_eq!( + MigrationName::new(invalid), + Err(MigrationContractError::InvalidName) + ); + } + let max_name = Box::leak("a".repeat(MAX_MIGRATION_NAME_UTF8_BYTES).into_boxed_str()); + assert_eq!(MigrationName::new(max_name).unwrap().as_str(), max_name); + let long_name = Box::leak( + "a".repeat(MAX_MIGRATION_NAME_UTF8_BYTES + 1) + .into_boxed_str(), + ); + assert_eq!( + MigrationName::new(long_name), + Err(MigrationContractError::InvalidName) + ); + for version in [0, 1] { + assert_eq!( + MigrationDescriptor::sql( + version, + "alpha", + SQL_TWO, + MigrationChecksum::for_sql(SQL_TWO) + ), + Err(MigrationContractError::InvalidTargetVersion) + ); + } + assert_eq!( + MigrationDescriptor::sql(2, "alpha", "", MigrationChecksum::for_sql("")), + Err(MigrationContractError::EmptyContent) + ); + let max_content = Box::leak(vec![b'x'; MAX_MIGRATION_CONTENT_BYTES].into_boxed_slice()); + assert!( + MigrationDescriptor::callback( + 2, + "alpha", + max_content, + MigrationChecksum::for_callback(max_content) + ) + .is_ok() + ); + let oversized = Box::leak(vec![b'x'; MAX_MIGRATION_CONTENT_BYTES + 1].into_boxed_slice()); + assert_eq!( + MigrationDescriptor::callback( + 2, + "alpha", + oversized, + MigrationChecksum::for_callback(oversized) + ), + Err(MigrationContractError::ContentTooLarge) + ); + } + + #[test] + fn empty_v1_and_ordered_catalog_digest_are_exact() { + let empty = MigrationCatalog::new([]).expect("empty v1 catalog"); + assert!(empty.descriptors().is_empty()); + assert_eq!(empty.current_version(), 1); + assert_eq!( + hex(empty.digest()), + "ec89dc8f7b6c2a11b967e33808e4031e29b3970ffee4959bff9bad352877ee9b" + ); + + let catalog = MigrationCatalog::new([ + MigrationDescriptor::sql(2, "create_alpha", SQL_TWO, SQL_TWO_CHECKSUM).unwrap(), + MigrationDescriptor::callback( + 3, + "rebuild_projection", + CALLBACK_THREE, + CALLBACK_THREE_CHECKSUM, + ) + .unwrap(), + ]) + .expect("ordered catalog"); + assert_eq!(catalog.current_version(), 3); + assert_eq!( + catalog + .descriptors() + .iter() + .map(MigrationDescriptor::target_version) + .collect::<Vec<_>>(), + vec![2, 3] + ); + assert_eq!( + hex(catalog.digest()), + "318e8b0143859e58ffe995b7d97d1cc2488097d307367979666cb47b98665838" + ); + } + + #[test] + fn duplicate_gap_and_ordering_failures_are_distinct() { + assert_eq!( + MigrationCatalog::new([sql(2, "alpha", "alpha"), sql(2, "beta", "beta")]), + Err(MigrationContractError::DuplicateVersion) + ); + assert_eq!( + MigrationCatalog::new([sql(2, "alpha", "alpha"), sql(3, "alpha", "beta")]), + Err(MigrationContractError::DuplicateName) + ); + assert_eq!( + MigrationCatalog::new([sql(3, "alpha", "alpha")]), + Err(MigrationContractError::VersionGap) + ); + assert_eq!( + MigrationCatalog::new([sql(2, "alpha", "alpha"), sql(4, "beta", "beta")]), + Err(MigrationContractError::VersionGap) + ); + assert_eq!( + MigrationCatalog::new([sql(3, "alpha", "alpha"), sql(2, "beta", "beta")]), + Err(MigrationContractError::OutOfOrder) + ); + assert_eq!( + MigrationCatalog::new([sql(u32::MAX, "alpha", "alpha")]), + Err(MigrationContractError::VersionGap) + ); + } + + #[test] + fn catalog_count_is_bounded_during_ingestion() { + let maximum = (0..MAX_MIGRATION_COUNT).map(|index| { + let version = u32::try_from(index).unwrap() + 2; + let name = Box::leak(format!("migration_{version}").into_boxed_str()); + sql(version, name, "SELECT 1;") + }); + assert_eq!( + MigrationCatalog::new(maximum).unwrap().descriptors().len(), + MAX_MIGRATION_COUNT + ); + + let excessive = (0..=MAX_MIGRATION_COUNT).map(|index| { + let version = u32::try_from(index).unwrap() + 2; + let name = Box::leak(format!("migration_{version}").into_boxed_str()); + sql(version, name, "SELECT 1;") + }); + assert_eq!( + MigrationCatalog::new(excessive), + Err(MigrationContractError::TooManyMigrations) + ); + + let infinite = (2_u32..).map(|version| { + let name = Box::leak(format!("migration_{version}").into_boxed_str()); + sql(version, name, "SELECT 1;") + }); + assert_eq!( + MigrationCatalog::new(infinite), + Err(MigrationContractError::TooManyMigrations) + ); + } + + #[test] + fn debug_and_errors_never_expose_migration_content() { + const SECRET_SQL: &str = "SELECT 'migration-secret';"; + let descriptor = sql(2, "safe_name", SECRET_SQL); + let catalog = MigrationCatalog::new([descriptor.clone()]).unwrap(); + for rendered in [format!("{descriptor:?}"), format!("{catalog:?}")] { + assert!(!rendered.contains(SECRET_SQL)); + assert!(!rendered.contains("migration-secret")); + } + for error in [ + MigrationContractError::InvalidName, + MigrationContractError::InvalidTargetVersion, + MigrationContractError::EmptyContent, + MigrationContractError::ContentTooLarge, + MigrationContractError::ChecksumMismatch, + MigrationContractError::TooManyMigrations, + MigrationContractError::DuplicateVersion, + MigrationContractError::DuplicateName, + MigrationContractError::OutOfOrder, + MigrationContractError::VersionGap, + ] { + assert!(!error.to_string().contains("secret")); + assert!(error.source().is_none()); + } + } + + fn hex(checksum: MigrationChecksum) -> String { + checksum + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } +} diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs @@ -7,6 +7,7 @@ const CONFIG_SOURCE: &str = include_str!("../src/config.rs"); const ERROR_SOURCE: &str = include_str!("../src/error.rs"); const INITIALIZE_SOURCE: &str = include_str!("../src/initialize.rs"); const METADATA_SOURCE: &str = include_str!("../src/metadata.rs"); +const MIGRATION_SOURCE: &str = include_str!("../src/migration.rs"); const OPEN_SOURCE: &str = include_str!("../src/open.rs"); const STATUS_SOURCE: &str = include_str!("../src/status.rs"); @@ -32,6 +33,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "radroots_storage", "rustix", "serde", + "sha2", "sqlx" ]) ); @@ -47,6 +49,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "error", "initialize", "metadata", + "migration", "open", "status" ]) @@ -60,6 +63,10 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { .split_once("#[cfg(test)]") .map(|(production, _)| production) .expect("open source must keep tests separated"); + let migration_production = MIGRATION_SOURCE + .split_once("#[cfg(test)]") + .map(|(production, _)| production) + .expect("migration source must keep tests separated"); for required in [ "ServiceSqliteErrorCode", @@ -74,6 +81,12 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "ServiceDatabaseMetadata", "ServiceSqliteApplicationId", "ServiceSqliteMetadataValueError", + "MigrationCatalog", + "MigrationChecksum", + "MigrationContractError", + "MigrationDescriptor", + "MigrationKind", + "MigrationName", "ServiceSqlitePathError", "ServiceSqlitePaths", "OpenMode", @@ -87,6 +100,43 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { ); } + for required in [ + "radroots.service_sqlite.migration_content.v1\\0", + "radroots.service_sqlite.migration_catalog.v1\\0", + "MAX_MIGRATION_NAME_UTF8_BYTES", + "MAX_MIGRATION_CONTENT_BYTES", + "MAX_MIGRATION_COUNT", + "pub const fn from_bytes", + "pub fn for_sql", + "pub fn for_callback", + ".take(MAX_MIGRATION_COUNT + 1)", + ] { + assert!( + migration_production.contains(required), + "Step 058 migration source is missing `{required}`" + ); + } + + for forbidden in [ + "sqlx::", + "schema_migrations", + "applied_at", + "build_identity", + "CREATE TABLE", + "INSERT INTO", + "BEGIN TRANSACTION", + "pub fn content(&self", + "pub fn callback_definition", + "pub fn migration_sql", + "Serialize", + "Deserialize", + ] { + assert!( + !migration_production.contains(forbidden), + "Step 058 migration source contains deferred surface `{forbidden}`" + ); + } + for forbidden in [ "radroots_service_host", "rusqlite",