use turso::Connection;
use turso::transaction::Transaction;
use std::fmt;
pub const SCHEMA_VERSION: i64 = 5;
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 INDEX IF NOT EXISTS artifact_references_by_content
ON artifact_references (content_key);
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?;
let result = ensure_in(&transaction).await;
super::settle(transaction, result).await
}
async fn ensure_in(transaction: &Transaction<'_>) -> Result<(), SchemaError> {
for statement in DDL.split_inclusive(';').filter(|s| !s.trim().is_empty()) {
transaction.execute(statement, ()).await?;
}
let mut rows = transaction
.query(
"SELECT schema_version, engine_version FROM schema_meta WHERE id = 1",
(),
)
.await?;
let recorded = match rows.next().await? {
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((4, _)) => {
tracing::info!(
from = 4,
to = SCHEMA_VERSION,
"upgrading the database schema"
);
transaction
.execute(
"UPDATE schema_meta SET schema_version = ?1, engine_version = ?2 WHERE id = 1",
(SCHEMA_VERSION, TURSO_CRATE_VERSION),
)
.await?;
}
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?;
}
Some(_) => {}
None => {
transaction
.execute(
"INSERT INTO schema_meta (id, schema_version, engine_version) VALUES (1, ?1, ?2)",
(SCHEMA_VERSION, TURSO_CRATE_VERSION),
)
.await?;
}
}
Ok(())
}
#[derive(Debug)]
pub enum SchemaError {
Database(turso::Error),
Mismatch {
found: i64,
expected: i64,
},
Corrupt(String),
}
impl From<turso::Error> for SchemaError {
fn from(err: turso::Error) -> SchemaError {
SchemaError::Database(err)
}
}
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;
for statement in [
"CREATE TABLE schema_meta (id INTEGER PRIMARY KEY, schema_version INTEGER NOT NULL, \
engine_version TEXT NOT NULL)",
"INSERT INTO schema_meta (id, schema_version, engine_version) VALUES (1, 999, 'x')",
"CREATE TABLE blocklist (id INTEGER PRIMARY KEY, revision INTEGER NOT NULL, \
generated_at_micros INTEGER NOT NULL, expires_at_micros INTEGER NOT NULL, \
snapshot BLOB NOT NULL)",
"INSERT INTO blocklist (id, revision, generated_at_micros, expires_at_micros, snapshot) \
VALUES (1, 42, 1, 2, x'070809')",
] {
connection
.execute(statement, ())
.await
.expect("the other schema's database");
}
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 refused = refused.expect_err("the refusal");
assert_eq!(
refused.to_string(),
format!(
"the database carries schema version 999, this build understands \
{SCHEMA_VERSION}; it is left untouched"
)
);
assert!(std::error::Error::source(&refused).is_none());
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"
);
let mut rows = connection
.query("SELECT name FROM sqlite_schema ORDER BY name", ())
.await
.expect("the schema listing");
let mut tables = Vec::new();
while let Some(row) = rows.next().await.expect("a row") {
tables.push(row.get_value(0).expect("a name").as_text().cloned());
}
assert_eq!(
tables,
vec![Some("blocklist".to_owned()), Some("schema_meta".to_owned())],
"and nothing this build would have created was"
);
}
#[tokio::test]
async fn a_version_4_database_is_upgraded_with_its_records_intact() {
let dir = tempfile::tempdir().expect("a temporary directory");
let path = dir.path().join("firewall.db");
let content_index = "CREATE INDEX IF NOT EXISTS artifact_references_by_content\n \
ON artifact_references (content_key);\n";
let version_4 = DDL.replace(content_index, "");
assert_ne!(
version_4, DDL,
"the fixture lacks exactly the version 5 index"
);
let mut connection = open(&path).await;
connection
.execute_batch(&version_4)
.await
.expect("v0.1.0's tables");
for statement in [
"INSERT INTO schema_meta (id, schema_version, engine_version) VALUES (1, 4, '0.7.2')",
"INSERT INTO artifact_references (id, ecosystem, name, version, filename, \
upstream_url, expected, first_seen_micros, pinned_sha256, pinned_size) \
VALUES (x'01', 'npm', 'widget', '1.0.0', 'widget-1.0.0.tgz', \
'https://registry.npmjs.org/widget/-/widget-1.0.0.tgz', '[]', 1234, x'abcd', 7)",
] {
connection
.execute(statement, ())
.await
.expect("v0.1.0's rows");
}
ensure(&mut connection)
.await
.expect("version 4 is upgraded");
let mut rows = connection
.query("SELECT schema_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_integer().copied(),
Some(SCHEMA_VERSION)
);
drop(rows);
let mut rows = connection
.query(
"SELECT first_seen_micros, pinned_sha256, pinned_size FROM artifact_references",
(),
)
.await
.expect("the reference");
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(1234)
);
assert_eq!(
row.get_value(1).expect("a value").as_blob().cloned(),
Some(vec![0xab, 0xcd])
);
assert_eq!(
row.get_value(2).expect("a value").as_integer().copied(),
Some(7)
);
drop(rows);
let mut rows = connection
.query(
"SELECT name FROM sqlite_schema WHERE name = 'artifact_references_by_content'",
(),
)
.await
.expect("the schema listing");
assert!(
rows.next().await.expect("a row").is_some(),
"and the index version 5 adds is there"
);
}
#[tokio::test]
async fn clearing_a_content_key_is_an_index_lookup() {
let dir = tempfile::tempdir().expect("a temporary directory");
let mut connection = open(&dir.path().join("firewall.db")).await;
ensure(&mut connection).await.expect("the schema");
let mut rows = connection
.query(
&format!(
"EXPLAIN QUERY PLAN {}",
crate::store::CLEAR_REFERENCE_CONTENT
),
(vec![0u8; 32],),
)
.await
.expect("the plan");
let mut plan = Vec::new();
while let Some(row) = rows.next().await.expect("a plan row") {
plan.push(
row.get_value(3)
.ok()
.and_then(|value| value.as_text().cloned())
.unwrap_or_default(),
);
}
assert!(
plan.iter().any(|step| step.contains("USING INDEX")),
"the clear is a scan: {plan:?}"
);
}
#[tokio::test]
async fn a_schema_that_will_not_build_is_the_engines_error() {
let dir = tempfile::tempdir().expect("a temporary directory");
let mut connection = open(&dir.path().join("firewall.db")).await;
connection
.execute(
"CREATE TABLE artifact_references (id BLOB PRIMARY KEY, ecosystem TEXT, name TEXT)",
(),
)
.await
.expect("a table without the column the index needs");
let refused = ensure(&mut connection).await.expect_err("the DDL fails");
assert!(matches!(refused, SchemaError::Database(_)), "{refused:?}");
assert!(!refused.to_string().is_empty());
assert!(std::error::Error::source(&refused).is_some());
}
#[tokio::test]
async fn an_unreadable_schema_row_is_named() {
let dir = tempfile::tempdir().expect("a temporary directory");
let mut connection = open(&dir.path().join("firewall.db")).await;
for statement in [
"CREATE TABLE schema_meta (id INTEGER PRIMARY KEY, schema_version INTEGER NOT NULL, \
engine_version TEXT NOT NULL)",
"INSERT INTO schema_meta (id, schema_version, engine_version) VALUES (1, 'five', 'x')",
] {
connection.execute(statement, ()).await.expect("the damage");
}
let refused = ensure(&mut connection)
.await
.expect_err("the row is unreadable");
assert_eq!(refused.to_string(), "unreadable schema_meta.schema_version");
assert!(std::error::Error::source(&refused).is_none());
}
#[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"
);
}
}