Add crsql changes metadata status

This commit is contained in:
Eric Wendland 2026-05-17 20:01:36 +02:00
commit 677945073b
8 changed files with 182 additions and 15 deletions

View file

@ -878,6 +878,19 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
.unwrap_or_else(|| "unknown".to_owned())
);
println!("schema_metadata: {}", db.schema_metadata);
println!(
"crsqlite_changes_available: {}",
db.crsqlite_changes.available
);
if let Some(count) = db.crsqlite_changes.change_count {
println!("crsqlite_change_count: {count}");
}
if let Some(version) = db.crsqlite_changes.max_db_version {
println!("crsqlite_max_db_version: {version}");
}
if let Some(error) = db.crsqlite_changes.error {
println!("crsqlite_changes_error: {error}");
}
println!("sync_status: {}", db.sync_status);
}
ControlResponse::KvCreated { kv } => {

View file

@ -12,9 +12,43 @@ pub struct DbResource {
pub path_exists: bool,
pub size_bytes: Option<u64>,
pub schema_metadata: String,
pub crsqlite_changes: CrSqliteChangeMetadata,
pub sync_status: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrSqliteChangeMetadata {
pub available: bool,
pub change_count: Option<u64>,
pub max_db_version: Option<i64>,
pub columns: Vec<String>,
pub error: Option<String>,
}
impl CrSqliteChangeMetadata {
#[must_use]
pub fn unavailable() -> Self {
Self {
available: false,
change_count: None,
max_db_version: None,
columns: Vec::new(),
error: None,
}
}
#[must_use]
pub fn error(error: impl Into<String>) -> Self {
Self {
available: false,
change_count: None,
max_db_version: None,
columns: Vec::new(),
error: Some(error.into()),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("invalid db resource name: {0}")]
@ -78,6 +112,43 @@ pub fn schema_metadata(path: &Path) -> Result<String, DbError> {
))
}
pub fn crsqlite_change_metadata(path: &Path) -> Result<CrSqliteChangeMetadata, DbError> {
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
let available: bool = conn.query_row(
r#"SELECT EXISTS(
SELECT 1 FROM sqlite_master
WHERE name = 'crsql_changes' AND type IN ('table', 'view')
)"#,
[],
|row| row.get(0),
)?;
if !available {
return Ok(CrSqliteChangeMetadata::unavailable());
}
let mut columns_stmt = conn.prepare("PRAGMA table_info('crsql_changes')")?;
let columns = columns_stmt
.query_map([], |row| row.get::<_, String>(1))?
.collect::<Result<Vec<_>, _>>()?;
let change_count: u64 =
conn.query_row("SELECT COUNT(*) FROM crsql_changes", [], |row| row.get(0))?;
let max_db_version = if columns.iter().any(|column| column == "db_version") {
conn.query_row("SELECT MAX(db_version) FROM crsql_changes", [], |row| {
row.get(0)
})?
} else {
None
};
Ok(CrSqliteChangeMetadata {
available: true,
change_count: Some(change_count),
max_db_version,
columns,
error: None,
})
}
#[must_use]
pub fn crsqlite_sync_roadmap() -> &'static str {
"future db sync reads crsql_changes, exchanges changes over Iroh, and applies through crsql_changes"
@ -119,4 +190,39 @@ mod tests {
assert!(first.contains("indexes=1"));
assert!(first.contains("schema_hash="));
}
#[test]
fn crsqlite_change_metadata_reports_mock_change_table() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("notes.sqlite");
let conn = Connection::open(&path).expect("open sqlite");
conn.execute(
r#"CREATE TABLE crsql_changes(
table_name TEXT NOT NULL,
pk TEXT NOT NULL,
cid TEXT NOT NULL,
val BLOB,
col_version INTEGER NOT NULL,
db_version INTEGER NOT NULL,
site_id BLOB,
cl INTEGER,
seq INTEGER
)"#,
[],
)
.expect("create crsql_changes table");
conn.execute(
"INSERT INTO crsql_changes(table_name, pk, cid, val, col_version, db_version) VALUES ('notes', '1', 'body', 'hello', 1, 7)",
[],
)
.expect("insert change");
drop(conn);
let metadata = crsqlite_change_metadata(&path).expect("change metadata");
assert!(metadata.available);
assert_eq!(metadata.change_count, Some(1));
assert_eq!(metadata.max_db_version, Some(7));
assert!(metadata.columns.contains(&"db_version".to_owned()));
}
}

View file

@ -716,6 +716,7 @@ pub fn handle_request(
}
let path = std::fs::canonicalize(path)?;
let schema_metadata = geth_db::schema_metadata(&path)?;
let crsqlite_changes = geth_db::crsqlite_change_metadata(&path)?;
let resource_id = format!("resource:db:{name}");
let db_id = format!("db:{name}");
let resource = StoredResource {
@ -733,7 +734,7 @@ pub fn handle_request(
};
store.insert_db_resource(&stored)?;
Ok(ControlResponse::DbAdded {
db: db_resource_from_stored_with_schema(&stored, schema_metadata),
db: db_resource_from_stored_with_schema(&stored, schema_metadata, crsqlite_changes),
})
}
ControlRequest::DbStatus { name } => {
@ -910,17 +911,32 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescr
fn db_resource_from_stored(stored: &StoredDbResource) -> Result<DbResource, NodeError> {
let path = Path::new(&stored.path);
let metadata = path.metadata().ok();
let schema_metadata = if metadata.as_ref().is_some_and(std::fs::Metadata::is_file) {
geth_db::schema_metadata(path).unwrap_or_else(|error| format!("unavailable:{error}"))
let (schema_metadata, crsqlite_changes) = if metadata
.as_ref()
.is_some_and(std::fs::Metadata::is_file)
{
(
geth_db::schema_metadata(path).unwrap_or_else(|error| format!("unavailable:{error}")),
geth_db::crsqlite_change_metadata(path)
.unwrap_or_else(|error| geth_db::CrSqliteChangeMetadata::error(error.to_string())),
)
} else {
"unavailable:path-missing".to_owned()
(
"unavailable:path-missing".to_owned(),
geth_db::CrSqliteChangeMetadata::error("path-missing"),
)
};
Ok(db_resource_from_stored_with_schema(stored, schema_metadata))
Ok(db_resource_from_stored_with_schema(
stored,
schema_metadata,
crsqlite_changes,
))
}
fn db_resource_from_stored_with_schema(
stored: &StoredDbResource,
schema_metadata: String,
crsqlite_changes: geth_db::CrSqliteChangeMetadata,
) -> DbResource {
let path = Path::new(&stored.path);
let metadata = path.metadata().ok();
@ -932,6 +948,7 @@ fn db_resource_from_stored_with_schema(
path_exists: metadata.as_ref().is_some_and(std::fs::Metadata::is_file),
size_bytes: metadata.map(|metadata| metadata.len()),
schema_metadata,
crsqlite_changes,
sync_status: "local-only".to_owned(),
}
}

View file

@ -399,6 +399,23 @@ fn db_add_and_status_register_local_db_metadata() {
[],
)
.expect("create notes table");
conn.execute(
r#"CREATE TABLE crsql_changes(
table_name TEXT NOT NULL,
pk TEXT NOT NULL,
cid TEXT NOT NULL,
val BLOB,
col_version INTEGER NOT NULL,
db_version INTEGER NOT NULL
)"#,
[],
)
.expect("create crsql_changes");
conn.execute(
"INSERT INTO crsql_changes(table_name, pk, cid, val, col_version, db_version) VALUES ('notes', '1', 'body', 'hello', 1, 3)",
[],
)
.expect("insert crsql change");
drop(conn);
let response = geth_node::handle_request(
@ -415,8 +432,11 @@ fn db_add_and_status_register_local_db_metadata() {
assert_eq!(db.sync_status, "local-only");
assert!(db.path_exists);
assert!(db.size_bytes.unwrap_or_default() > 0);
assert!(db.schema_metadata.contains("tables=1"));
assert!(db.schema_metadata.contains("tables=2"));
assert!(db.schema_metadata.contains("schema_hash="));
assert!(db.crsqlite_changes.available);
assert_eq!(db.crsqlite_changes.change_count, Some(1));
assert_eq!(db.crsqlite_changes.max_db_version, Some(3));
}
other => panic!("unexpected response: {other:?}"),
}
@ -432,8 +452,13 @@ fn db_add_and_status_register_local_db_metadata() {
geth_control::ControlResponse::DbStatus { db } => {
assert_eq!(db.name, "notes");
assert!(db.path.ends_with("notes.sqlite"));
assert!(db.schema_metadata.contains("tables=1"));
assert!(db.schema_metadata.contains("tables=2"));
assert!(db.schema_metadata.contains("schema_hash="));
assert!(
db.crsqlite_changes
.columns
.contains(&"db_version".to_owned())
);
}
other => panic!("unexpected response: {other:?}"),
}