commit b4589d1812fbea32daff4cbfb345d5178045b97e
parent 5d9c5fc540cc2a53ec68131cfe85fdffa61054dc
Author: triesap <tyson@radroots.org>
Date: Fri, 5 Jun 2026 22:25:44 -0700
store-surreal: add migration model
Diffstat:
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()
+ }
+ );
+ }
}