2026-05-15 15:08:20 +02:00
|
|
|
use geth_types::{DbId, ResourceId};
|
2026-05-16 21:54:49 +02:00
|
|
|
use rusqlite::{Connection, OpenFlags};
|
2026-05-15 15:08:20 +02:00
|
|
|
use serde::{Deserialize, Serialize};
|
2026-05-16 21:54:49 +02:00
|
|
|
use std::path::Path;
|
2026-05-15 15:08:20 +02:00
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct DbResource {
|
|
|
|
|
pub id: DbId,
|
|
|
|
|
pub resource: ResourceId,
|
2026-05-16 21:13:33 +02:00
|
|
|
pub name: String,
|
2026-05-15 15:08:20 +02:00
|
|
|
pub path: String,
|
2026-05-16 21:13:33 +02:00
|
|
|
pub path_exists: bool,
|
|
|
|
|
pub size_bytes: Option<u64>,
|
|
|
|
|
pub schema_metadata: String,
|
2026-05-15 15:08:20 +02:00
|
|
|
pub sync_status: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 21:13:33 +02:00
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
|
|
|
pub enum DbError {
|
|
|
|
|
#[error("invalid db resource name: {0}")]
|
|
|
|
|
InvalidName(String),
|
2026-05-16 21:54:49 +02:00
|
|
|
#[error("sqlite error: {0}")]
|
|
|
|
|
Sqlite(#[from] rusqlite::Error),
|
2026-05-16 21:13:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn validate_db_name(name: &str) -> Result<(), DbError> {
|
|
|
|
|
if name.is_empty()
|
|
|
|
|
|| !name
|
|
|
|
|
.bytes()
|
|
|
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
|
|
|
|
{
|
|
|
|
|
return Err(DbError::InvalidName(name.to_owned()));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 21:54:49 +02:00
|
|
|
pub fn schema_metadata(path: &Path) -> Result<String, DbError> {
|
|
|
|
|
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
|
|
|
|
let mut stmt = conn.prepare(
|
|
|
|
|
r#"SELECT type, name, COALESCE(sql, '')
|
|
|
|
|
FROM sqlite_master
|
|
|
|
|
WHERE name NOT LIKE 'sqlite_%'
|
|
|
|
|
ORDER BY type, name"#,
|
|
|
|
|
)?;
|
|
|
|
|
let rows = stmt.query_map([], |row| {
|
|
|
|
|
Ok((
|
|
|
|
|
row.get::<_, String>(0)?,
|
|
|
|
|
row.get::<_, String>(1)?,
|
|
|
|
|
row.get::<_, String>(2)?,
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let mut canonical = String::new();
|
|
|
|
|
let mut table_count = 0_usize;
|
|
|
|
|
let mut index_count = 0_usize;
|
|
|
|
|
let mut view_count = 0_usize;
|
|
|
|
|
let mut trigger_count = 0_usize;
|
|
|
|
|
for row in rows {
|
|
|
|
|
let (kind, name, sql) = row?;
|
|
|
|
|
match kind.as_str() {
|
|
|
|
|
"table" => table_count += 1,
|
|
|
|
|
"index" => index_count += 1,
|
|
|
|
|
"view" => view_count += 1,
|
|
|
|
|
"trigger" => trigger_count += 1,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
canonical.push_str(&kind);
|
|
|
|
|
canonical.push('\0');
|
|
|
|
|
canonical.push_str(&name);
|
|
|
|
|
canonical.push('\0');
|
|
|
|
|
canonical.push_str(&sql);
|
|
|
|
|
canonical.push('\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(format!(
|
|
|
|
|
"tables={table_count} indexes={index_count} views={view_count} triggers={trigger_count} schema_hash={}",
|
|
|
|
|
blake3::hash(canonical.as_bytes())
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 15:08:20 +02:00
|
|
|
#[must_use]
|
|
|
|
|
pub fn crsqlite_sync_roadmap() -> &'static str {
|
|
|
|
|
"future db sync reads crsql_changes, exchanges changes over Iroh, and applies through crsql_changes"
|
|
|
|
|
}
|
2026-05-16 21:13:33 +02:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn db_name_validation_rejects_paths_and_empty_names() {
|
|
|
|
|
assert!(validate_db_name("notes").is_ok());
|
|
|
|
|
assert!(validate_db_name("notes.v1").is_ok());
|
|
|
|
|
assert!(validate_db_name("").is_err());
|
|
|
|
|
assert!(validate_db_name("../notes").is_err());
|
|
|
|
|
assert!(validate_db_name("notes/main").is_err());
|
|
|
|
|
assert!(validate_db_name("notes main").is_err());
|
|
|
|
|
}
|
2026-05-16 21:54:49 +02:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn schema_metadata_reports_counts_and_stable_hash() {
|
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let path = dir.path().join("notes.sqlite");
|
|
|
|
|
let conn = Connection::open(&path).expect("open sqlite");
|
|
|
|
|
conn.execute(
|
|
|
|
|
"CREATE TABLE notes(id INTEGER PRIMARY KEY, body TEXT NOT NULL)",
|
|
|
|
|
[],
|
|
|
|
|
)
|
|
|
|
|
.expect("create table");
|
|
|
|
|
conn.execute("CREATE INDEX notes_body ON notes(body)", [])
|
|
|
|
|
.expect("create index");
|
|
|
|
|
drop(conn);
|
|
|
|
|
|
|
|
|
|
let first = schema_metadata(&path).expect("schema metadata");
|
|
|
|
|
let second = schema_metadata(&path).expect("schema metadata again");
|
|
|
|
|
|
|
|
|
|
assert_eq!(first, second);
|
|
|
|
|
assert!(first.contains("tables=1"));
|
|
|
|
|
assert!(first.contains("indexes=1"));
|
|
|
|
|
assert!(first.contains("schema_hash="));
|
|
|
|
|
}
|
2026-05-16 21:13:33 +02:00
|
|
|
}
|