feat: add ordered store migrations

This commit is contained in:
Eric Wendland 2026-07-05 17:54:02 +02:00
commit bf970fdc76
3 changed files with 178 additions and 22 deletions

View file

@ -1,12 +1,16 @@
use rusqlite::{Connection, params}; use rusqlite::{Connection, OptionalExtension, Transaction, params};
use std::path::Path; use std::path::Path;
pub const CURRENT_SCHEMA_VERSION: i64 = 2;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum StoreError { pub enum StoreError {
#[error("sqlite error: {0}")] #[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error), Sqlite(#[from] rusqlite::Error),
#[error("json error: {0}")] #[error("json error: {0}")]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("invalid schema version value: {0}")]
InvalidSchemaVersion(String),
} }
pub struct Store { pub struct Store {
@ -36,6 +40,45 @@ impl Store {
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
); );
"#,
)?;
let mut version = self.schema_version()?;
if version == 0 {
self.apply_schema_v1()?;
version = 1;
}
while version < CURRENT_SCHEMA_VERSION {
let next = version + 1;
match next {
2 => self.apply_migration_v2()?,
_ => unreachable!("missing migration for schema version {next}"),
}
version = next;
}
Ok(())
}
fn schema_version(&self) -> Result<i64, StoreError> {
let version = self
.conn
.query_row(
"SELECT value FROM meta WHERE key = 'schema_version'",
[],
|row| row.get::<_, String>(0),
)
.optional()?;
let Some(version) = version else {
return Ok(0);
};
version
.parse::<i64>()
.map_err(|_| StoreError::InvalidSchemaVersion(version))
}
fn apply_schema_v1(&self) -> Result<(), StoreError> {
let tx = self.conn.unchecked_transaction()?;
tx.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS agents ( CREATE TABLE IF NOT EXISTS agents (
agent_id TEXT PRIMARY KEY, agent_id TEXT PRIMARY KEY,
public_key TEXT NOT NULL, public_key TEXT NOT NULL,
@ -73,7 +116,6 @@ impl Store {
CREATE TABLE IF NOT EXISTS keychain_signatures ( CREATE TABLE IF NOT EXISTS keychain_signatures (
op_id TEXT NOT NULL, op_id TEXT NOT NULL,
signer TEXT NOT NULL, signer TEXT NOT NULL,
signer_public_key TEXT NOT NULL DEFAULT '',
namespace TEXT NOT NULL, namespace TEXT NOT NULL,
signature BLOB NOT NULL, signature BLOB NOT NULL,
created_at_ms INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,
@ -202,16 +244,14 @@ impl Store {
renewal_of TEXT, renewal_of TEXT,
reason TEXT, reason TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
created_at_ms INTEGER NOT NULL, created_at_ms INTEGER NOT NULL
provenance_json TEXT
); );
CREATE TABLE IF NOT EXISTS ssh_certificates ( CREATE TABLE IF NOT EXISTS ssh_certificates (
cert_id TEXT PRIMARY KEY, cert_id TEXT PRIMARY KEY,
request_id TEXT NOT NULL, request_id TEXT NOT NULL,
certificate TEXT NOT NULL, certificate TEXT NOT NULL,
certificate_fingerprint TEXT NOT NULL, certificate_fingerprint TEXT NOT NULL,
imported_at_ms INTEGER NOT NULL, imported_at_ms INTEGER NOT NULL
provenance_json TEXT
); );
CREATE TABLE IF NOT EXISTS ssh_revocations ( CREATE TABLE IF NOT EXISTS ssh_revocations (
revocation_id TEXT PRIMARY KEY, revocation_id TEXT PRIMARY KEY,
@ -219,35 +259,52 @@ impl Store {
target TEXT NOT NULL, target TEXT NOT NULL,
reason TEXT, reason TEXT,
created_at_ms INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,
published INTEGER NOT NULL DEFAULT 0, published INTEGER NOT NULL DEFAULT 0
provenance_json TEXT
); );
INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '1');
"#, "#,
)?; )?;
self.add_column_if_missing( self.set_schema_version_tx(&tx, 1)?;
tx.commit()?;
Ok(())
}
fn apply_migration_v2(&self) -> Result<(), StoreError> {
let tx = self.conn.unchecked_transaction()?;
Self::add_column_if_missing_tx(
&tx,
"keychain_signatures", "keychain_signatures",
"signer_public_key", "signer_public_key",
"TEXT NOT NULL DEFAULT ''", "TEXT NOT NULL DEFAULT ''",
)?; )?;
self.add_column_if_missing("ssh_cert_requests", "provenance_json", "TEXT")?; Self::add_column_if_missing_tx(&tx, "ssh_cert_requests", "provenance_json", "TEXT")?;
self.add_column_if_missing("ssh_certificates", "provenance_json", "TEXT")?; Self::add_column_if_missing_tx(&tx, "ssh_certificates", "provenance_json", "TEXT")?;
self.add_column_if_missing("ssh_revocations", "provenance_json", "TEXT")?; Self::add_column_if_missing_tx(&tx, "ssh_revocations", "provenance_json", "TEXT")?;
self.set_schema_version_tx(&tx, 2)?;
tx.commit()?;
Ok(()) Ok(())
} }
fn add_column_if_missing( fn set_schema_version_tx(&self, tx: &Transaction<'_>, version: i64) -> Result<(), StoreError> {
&self, tx.execute(
"INSERT INTO meta(key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[version.to_string()],
)?;
Ok(())
}
fn add_column_if_missing_tx(
tx: &Transaction<'_>,
table: &str, table: &str,
column: &str, column: &str,
definition: &str, definition: &str,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
let mut stmt = self.conn.prepare(&format!("PRAGMA table_info({table})"))?; let mut stmt = tx.prepare(&format!("PRAGMA table_info({table})"))?;
let columns = stmt let columns = stmt
.query_map([], |row| row.get::<_, String>(1))? .query_map([], |row| row.get::<_, String>(1))?
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
if !columns.iter().any(|name| name == column) { if !columns.iter().any(|name| name == column) {
self.conn.execute( tx.execute(
&format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"), &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
[], [],
)?; )?;
@ -1616,10 +1673,103 @@ mod tests {
let store = Store::open_memory().expect("open"); let store = Store::open_memory().expect("open");
store.migrate().expect("migrate again"); store.migrate().expect("migrate again");
store.migrate().expect("migrate third time"); store.migrate().expect("migrate third time");
assert_eq!(
store.schema_version().expect("schema version"),
CURRENT_SCHEMA_VERSION
);
let resources = store.list_resources().expect("resources"); let resources = store.list_resources().expect("resources");
assert!(resources.is_empty()); assert!(resources.is_empty());
} }
#[test]
fn schema_v1_fixture_migrates_to_current_schema() {
let path =
std::env::temp_dir().join(format!("geth-store-v1-migration-{}.sqlite", now_ms()));
{
let conn = Connection::open(&path).expect("open fixture");
conn.execute_batch(
r#"
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT INTO meta(key, value) VALUES ('schema_version', '1');
CREATE TABLE keychain_signatures (
op_id TEXT NOT NULL,
signer TEXT NOT NULL,
namespace TEXT NOT NULL,
signature BLOB NOT NULL,
created_at_ms INTEGER NOT NULL,
PRIMARY KEY (op_id, signer, namespace)
);
CREATE TABLE ssh_cert_requests (
request_id TEXT PRIMARY KEY,
requester_node TEXT NOT NULL,
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
cert_kind TEXT NOT NULL,
principals_json TEXT NOT NULL,
requested_validity TEXT,
renewal_of TEXT,
reason TEXT,
status TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE ssh_certificates (
cert_id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
certificate TEXT NOT NULL,
certificate_fingerprint TEXT NOT NULL,
imported_at_ms INTEGER NOT NULL
);
CREATE TABLE ssh_revocations (
revocation_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
target TEXT NOT NULL,
reason TEXT,
created_at_ms INTEGER NOT NULL,
published INTEGER NOT NULL DEFAULT 0
);
"#,
)
.expect("create v1 fixture");
}
let store = Store::open(&path).expect("migrate fixture");
assert_eq!(
store.schema_version().expect("schema version"),
CURRENT_SCHEMA_VERSION
);
assert!(column_exists(
&store,
"keychain_signatures",
"signer_public_key"
));
assert!(column_exists(
&store,
"ssh_cert_requests",
"provenance_json"
));
assert!(column_exists(&store, "ssh_certificates", "provenance_json"));
assert!(column_exists(&store, "ssh_revocations", "provenance_json"));
drop(store);
let _ = std::fs::remove_file(path);
}
fn column_exists(store: &Store, table: &str, column: &str) -> bool {
let mut stmt = store
.conn
.prepare(&format!("PRAGMA table_info({table})"))
.expect("table info");
let columns = stmt
.query_map([], |row| row.get::<_, String>(1))
.expect("columns")
.collect::<Result<Vec<_>, _>>()
.expect("column names");
columns.iter().any(|name| name == column)
}
#[test] #[test]
fn ssh_cert_request_and_revocation_roundtrip() { fn ssh_cert_request_and_revocation_roundtrip() {
let store = Store::open_memory().expect("open"); let store = Store::open_memory().expect("open");

View file

@ -6,6 +6,12 @@ the shared Iroh endpoint, resource registry, module routing, and local control
socket. Control commands connect to the Unix socket and send typed JSONL socket. Control commands connect to the Unix socket and send typed JSONL
requests. requests.
The local metadata store is SQLite product state. `geth-store` tracks a numeric
`schema_version` in the `meta` table and applies ordered migrations up to the
crate's current schema version when the store opens. Fresh database creation and
repeated opens are idempotent; migrations that change existing schemas run in a
SQLite transaction where SQLite supports it.
Service management is also exposed through the single binary. `geth daemon Service management is also exposed through the single binary. `geth daemon
service ...` installs and controls a user-level service definition for the local service ...` installs and controls a user-level service definition for the local
daemon. The initial backends are systemd user units on Linux, launchd user agents daemon. The initial backends are systemd user units on Linux, launchd user agents

View file

@ -120,12 +120,12 @@ and downstream projects.
Goal: treat local SQLite state as durable product data before users depend on Goal: treat local SQLite state as durable product data before users depend on
it. it.
- `[ ]` Replace opportunistic schema setup with ordered migrations. - `[x]` Replace opportunistic schema setup with ordered migrations.
Acceptance criteria: Acceptance criteria:
- `[ ]` The store tracks numeric schema versions. - `[x]` The store tracks numeric schema versions.
- `[ ]` Each migration is transactional where SQLite supports it. - `[x]` Each migration is transactional where SQLite supports it.
- `[ ]` Fresh database creation and repeated opens are idempotent. - `[x]` Fresh database creation and repeated opens are idempotent.
- `[ ]` Old schema fixtures migrate to the current schema in tests. - `[x]` Old schema fixtures migrate to the current schema in tests.
- `[ ]` Make multi-table writes transactional. - `[ ]` Make multi-table writes transactional.
Acceptance criteria: Acceptance criteria: