use turso::Connection;
use std::fmt;
pub const SCHEMA_VERSION: i64 = 4;
pub const TURSO_CRATE_VERSION: &str = "0.7.2";
const DDL: &str = "\
CREATE TABLE IF NOT EXISTS schema_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL,
engine_version TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS blocklist (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL,
generated_at_micros INTEGER NOT NULL,
expires_at_micros INTEGER NOT NULL,
snapshot BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
ecosystem TEXT NOT NULL,
name TEXT NOT NULL,
payload BLOB NOT NULL,
etag TEXT,
last_modified TEXT,
validated_at_micros INTEGER NOT NULL,
fetched_at_micros INTEGER NOT NULL,
generation INTEGER NOT NULL,
digest_generation INTEGER NOT NULL,
PRIMARY KEY (ecosystem, name)
);
CREATE TABLE IF NOT EXISTS artifact_references (
id BLOB PRIMARY KEY,
ecosystem TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
filename TEXT NOT NULL CHECK (length(filename) > 0),
upstream_url TEXT NOT NULL,
expected TEXT NOT NULL,
publication_micros INTEGER,
first_seen_micros INTEGER,
pinned_sha256 BLOB,
pinned_sha512 BLOB,
pinned_size INTEGER,
content_key BLOB
);
CREATE INDEX IF NOT EXISTS artifact_references_by_project
ON artifact_references (ecosystem, name);
CREATE TABLE IF NOT EXISTS content (
key BLOB PRIMARY KEY,
sha512 BLOB NOT NULL,
size INTEGER NOT NULL,
created_micros INTEGER NOT NULL,
accessed_micros INTEGER NOT NULL
);
";
pub async fn ensure(connection: &mut Connection) -> Result<(), SchemaError> {
let transaction = connection
.transaction()
.await
.map_err(SchemaError::Database)?;
for statement in DDL.split_inclusive(';').filter(|s| !s.trim().is_empty()) {
transaction
.execute(statement, ())
.await
.map_err(SchemaError::Database)?;
}
let mut rows = transaction
.query(
"SELECT schema_version, engine_version FROM schema_meta WHERE id = 1",
(),
)
.await
.map_err(SchemaError::Database)?;
let recorded = match rows.next().await.map_err(SchemaError::Database)? {
Some(row) => {
let version = row
.get_value(0)
.ok()
.and_then(|value| value.as_integer().copied())
.ok_or_else(|| SchemaError::Corrupt("schema_meta.schema_version".to_owned()))?;
let engine = row
.get_value(1)
.ok()
.and_then(|value| value.as_text().cloned())
.ok_or_else(|| SchemaError::Corrupt("schema_meta.engine_version".to_owned()))?;
Some((version, engine))
}
None => None,
};
drop(rows);
match recorded {
Some((version, _)) if version != SCHEMA_VERSION => {
return Err(SchemaError::Mismatch {
found: version,
expected: SCHEMA_VERSION,
});
}
Some((_, engine)) if engine != TURSO_CRATE_VERSION => {
tracing::warn!(
previous = %engine,
current = TURSO_CRATE_VERSION,
"the database was written by a different engine release; recording the current one"
);
transaction
.execute(
"UPDATE schema_meta SET engine_version = ?1 WHERE id = 1",
(TURSO_CRATE_VERSION,),
)
.await
.map_err(SchemaError::Database)?;
}
Some(_) => {}
None => {
transaction
.execute(
"INSERT INTO schema_meta (id, schema_version, engine_version) VALUES (1, ?1, ?2)",
(SCHEMA_VERSION, TURSO_CRATE_VERSION),
)
.await
.map_err(SchemaError::Database)?;
}
}
transaction.commit().await.map_err(SchemaError::Database)
}
#[derive(Debug)]
pub enum SchemaError {
Database(turso::Error),
Mismatch {
found: i64,
expected: i64,
},
Corrupt(String),
}
impl fmt::Display for SchemaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SchemaError::Database(err) => write!(f, "{err}"),
SchemaError::Mismatch { found, expected } => write!(
f,
"the database carries schema version {found}, this build understands {expected}; \
it is left untouched"
),
SchemaError::Corrupt(column) => write!(f, "unreadable {column}"),
}
}
}
impl std::error::Error for SchemaError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SchemaError::Database(err) => Some(err),
SchemaError::Mismatch { .. } | SchemaError::Corrupt(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn open(path: &std::path::Path) -> Connection {
turso::Builder::new_local(path.to_str().expect("a UTF-8 path"))
.build()
.await
.expect("the database opens")
.connect()
.expect("a connection")
}
#[tokio::test]
async fn another_schema_version_is_refused_and_the_rows_are_left_alone() {
let dir = tempfile::tempdir().expect("a temporary directory");
let path = dir.path().join("firewall.db");
let mut connection = open(&path).await;
ensure(&mut connection).await.expect("the first open");
connection
.execute(
"INSERT INTO blocklist (id, revision, generated_at_micros, expires_at_micros, snapshot) \
VALUES (1, 42, 1, 2, ?1)",
(vec![7u8, 8, 9],),
)
.await
.expect("a row that must survive the refusal");
connection
.execute(
"UPDATE schema_meta SET schema_version = 999 WHERE id = 1",
(),
)
.await
.expect("the schema row is aged by hand");
let refused = ensure(&mut connection).await;
assert!(
matches!(
refused,
Err(SchemaError::Mismatch {
found: 999,
expected: SCHEMA_VERSION
})
),
"a database this build does not understand is refused: {refused:?}"
);
let mut rows = connection
.query("SELECT schema_version FROM schema_meta WHERE id = 1", ())
.await
.expect("the schema row is still there");
let row = rows.next().await.expect("a row").expect("exactly one row");
assert_eq!(
row.get_value(0).expect("a value").as_integer().copied(),
Some(999),
"the refusal changed nothing: the recorded version is still the one on disk"
);
let mut rows = connection
.query("SELECT revision FROM blocklist WHERE id = 1", ())
.await
.expect("the blocklist row is still there");
let row = rows.next().await.expect("a row").expect("exactly one row");
assert_eq!(
row.get_value(0).expect("a value").as_integer().copied(),
Some(42),
"and the records the refusal exists to protect are untouched"
);
}
#[tokio::test]
async fn another_engine_version_is_recorded_rather_than_refused() {
let dir = tempfile::tempdir().expect("a temporary directory");
let path = dir.path().join("firewall.db");
let mut connection = open(&path).await;
ensure(&mut connection).await.expect("the first open");
connection
.execute(
"UPDATE schema_meta SET engine_version = '0.0.0-previous' WHERE id = 1",
(),
)
.await
.expect("the engine version is aged by hand");
ensure(&mut connection)
.await
.expect("a different engine release still opens");
let mut rows = connection
.query("SELECT engine_version FROM schema_meta WHERE id = 1", ())
.await
.expect("the schema row");
let row = rows.next().await.expect("a row").expect("exactly one row");
assert_eq!(
row.get_value(0).expect("a value").as_text().cloned(),
Some(TURSO_CRATE_VERSION.to_owned()),
"the engine that opened it is what the record now names"
);
}
#[test]
fn recorded_engine_version_matches_the_pinned_dependency() {
let manifest = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"))
.expect("the manifest is readable");
let declared = manifest
.lines()
.find_map(|line| line.strip_prefix("turso = "))
.expect("Cargo.toml declares turso")
.trim()
.trim_matches('"')
.to_owned();
assert_eq!(
declared, TURSO_CRATE_VERSION,
"TURSO_CRATE_VERSION is recorded in every database this build writes and must \
name the dependency actually compiled in"
);
}
}