tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit b4589d1812fbea32daff4cbfb345d5178045b97e
parent 5d9c5fc540cc2a53ec68131cfe85fdffa61054dc
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 22:25:44 -0700

store-surreal: add migration model

Diffstat:
MCargo.lock | 3+++
Mcrates/tangle_store_surreal/Cargo.toml | 1+
Mcrates/tangle_store_surreal/src/lib.rs | 218++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 221 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -421,6 +421,9 @@ dependencies = [ [[package]] name = "tangle_store_surreal" version = "0.1.0" +dependencies = [ + "sha2", +] [[package]] name = "tangle_test_support" diff --git a/crates/tangle_store_surreal/Cargo.toml b/crates/tangle_store_surreal/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true description = "SurrealDB storage backend for tangle" [dependencies] +sha2 = "0.10" [lints] workspace = true diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] use core::fmt; +use sha2::{Digest, Sha256}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SurrealConnectionMode { @@ -125,9 +126,146 @@ fn normalized_endpoint(value: &str, field: &str) -> Result<String, SurrealConfig Ok(trimmed.to_owned()) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurrealMigration { + name: String, + surql: &'static str, + checksum: String, +} + +impl SurrealMigration { + pub fn new(name: &str, surql: &'static str) -> Result<Self, SurrealMigrationError> { + let name = normalized_migration_name(name)?; + if surql.trim().is_empty() { + return Err(SurrealMigrationError::new( + "surreal migration body must not be empty", + )); + } + Ok(Self { + name, + surql, + checksum: checksum(surql), + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn surql(&self) -> &'static str { + self.surql + } + + pub fn checksum(&self) -> &str { + &self.checksum + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurrealMigrationPlan { + migrations: Vec<SurrealMigration>, +} + +impl SurrealMigrationPlan { + pub fn new(migrations: Vec<SurrealMigration>) -> Result<Self, SurrealMigrationError> { + for pair in migrations.windows(2) { + if pair[0].name() >= pair[1].name() { + return Err(SurrealMigrationError::new( + "surreal migrations must be strictly ordered by name", + )); + } + } + Ok(Self { migrations }) + } + + pub fn migrations(&self) -> &[SurrealMigration] { + &self.migrations + } + + pub fn names(&self) -> Vec<&str> { + self.migrations.iter().map(SurrealMigration::name).collect() + } + + pub fn find(&self, name: &str) -> Option<&SurrealMigration> { + self.migrations + .iter() + .find(|migration| migration.name() == name) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurrealMigrationError { + message: String, +} + +impl SurrealMigrationError { + fn new(message: &str) -> Self { + Self { + message: message.to_owned(), + } + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for SurrealMigrationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for SurrealMigrationError {} + +fn normalized_migration_name(name: &str) -> Result<String, SurrealMigrationError> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(SurrealMigrationError::new( + "surreal migration name must not be empty", + )); + } + let mut parts = trimmed.splitn(2, '_'); + let version = parts.next().unwrap_or_default(); + let label = parts.next().unwrap_or_default(); + if version.len() != 4 || !version.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(SurrealMigrationError::new( + "surreal migration name must start with four digits", + )); + } + if label.is_empty() + || !label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(SurrealMigrationError::new( + "surreal migration label must use lowercase ASCII, digits, or underscore", + )); + } + Ok(trimmed.to_owned()) +} + +fn checksum(surql: &str) -> String { + let digest = Sha256::digest(surql.as_bytes()); + lower_hex(&digest) +} + +fn lower_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output +} + #[cfg(test)] mod tests { - use super::{SurrealConfigError, SurrealConnectionConfig, SurrealConnectionMode}; + use super::{ + SurrealConfigError, SurrealConnectionConfig, SurrealConnectionMode, SurrealMigration, + SurrealMigrationError, SurrealMigrationPlan, + }; #[test] fn memory_config_normalizes_namespace_and_database() { @@ -203,4 +341,82 @@ mod tests { "surreal database must use ASCII letters, digits, or underscore" ); } + + #[test] + fn migration_model_normalizes_names_and_hashes_surql() { + let migration = + SurrealMigration::new(" 0001_tracking ", "DEFINE TABLE migration SCHEMAFULL;") + .expect("migration"); + + assert_eq!(migration.name(), "0001_tracking"); + assert_eq!(migration.surql(), "DEFINE TABLE migration SCHEMAFULL;"); + assert_eq!( + migration.checksum(), + "ffedba540d84072a42d0e3f97bfdc054e688667e073b879e7409dd5253c8c896" + ); + } + + #[test] + fn migration_model_rejects_invalid_name_and_body() { + assert_eq!( + SurrealMigration::new("", "RETURN true;") + .expect_err("missing name") + .to_string(), + "surreal migration name must not be empty" + ); + assert_eq!( + SurrealMigration::new("001_tracking", "RETURN true;") + .expect_err("short version") + .message(), + "surreal migration name must start with four digits" + ); + assert_eq!( + SurrealMigration::new("0001_Tracking", "RETURN true;") + .expect_err("bad label") + .to_string(), + "surreal migration label must use lowercase ASCII, digits, or underscore" + ); + assert_eq!( + SurrealMigration::new("0001_tracking", " ") + .expect_err("empty body") + .to_string(), + "surreal migration body must not be empty" + ); + } + + #[test] + fn migration_plan_preserves_order_and_lookup() { + let first = SurrealMigration::new("0001_tracking", "RETURN true;").expect("first"); + let second = SurrealMigration::new("0002_events", "RETURN false;").expect("second"); + let plan = + SurrealMigrationPlan::new(vec![first.clone(), second.clone()]).expect("ordered plan"); + + assert_eq!(plan.migrations(), &[first, second]); + assert_eq!(plan.names(), vec!["0001_tracking", "0002_events"]); + assert_eq!( + plan.find("0002_events").expect("second migration").surql(), + "RETURN false;" + ); + assert_eq!(plan.find("9999_missing"), None); + } + + #[test] + fn migration_plan_rejects_duplicate_or_descending_names() { + let first = SurrealMigration::new("0002_events", "RETURN true;").expect("first"); + let duplicate = SurrealMigration::new("0002_events", "RETURN false;").expect("duplicate"); + let descending = SurrealMigration::new("0001_tracking", "RETURN false;").expect("older"); + + assert_eq!( + SurrealMigrationPlan::new(vec![first.clone(), duplicate]) + .expect_err("duplicate") + .to_string(), + "surreal migrations must be strictly ordered by name" + ); + assert_eq!( + SurrealMigrationPlan::new(vec![first, descending]).expect_err("descending"), + SurrealMigrationError { + message: "surreal migrations must be strictly ordered by name".to_owned() + } + ); + } }