| 1 | //! Database connection and schema setup via Toasty. |
| 2 | |
| 3 | use std::path::Path; |
| 4 | |
| 5 | use crate::{ |
| 6 | error::Result, |
| 7 | models::{ |
| 8 | AdminCache, |
| 9 | ApiToken, |
| 10 | Attachment, |
| 11 | CiArtifact, |
| 12 | CiRun, |
| 13 | Issue, |
| 14 | IssueComment, |
| 15 | Passkey, |
| 16 | RepoSecret, |
| 17 | Repository, |
| 18 | Session, |
| 19 | SshKey, |
| 20 | User, |
| 21 | }, |
| 22 | }; |
| 23 | |
| 24 | /// Open (creating if necessary) the SQLite database at `path`, creating the |
| 25 | /// schema on first run. |
| 26 | /// |
| 27 | /// Note: Toasty 0.7's `push_schema` emits plain `CREATE TABLE` (no |
| 28 | /// `IF NOT EXISTS`) for model tables, so it is *not* idempotent — calling it on |
| 29 | /// an existing database errors. We therefore only push the schema when the |
| 30 | /// database file does not yet exist. Skipping it on an existing database issues |
| 31 | /// no DDL, so data is never touched. Schema evolution will move to Toasty's |
| 32 | /// migration system when models start changing. |
| 33 | pub async fn connect(path: impl AsRef<Path>) -> Result<toasty::Db> { |
| 34 | let path = path.as_ref(); |
| 35 | let fresh = !path.exists(); |
| 36 | let url = format!("sqlite:{}", path.display()); |
| 37 | |
| 38 | let db = toasty::Db::builder() |
| 39 | .models(toasty::models!( |
| 40 | User, |
| 41 | Repository, |
| 42 | SshKey, |
| 43 | Session, |
| 44 | CiRun, |
| 45 | CiArtifact, |
| 46 | Issue, |
| 47 | IssueComment, |
| 48 | Attachment, |
| 49 | ApiToken, |
| 50 | AdminCache, |
| 51 | RepoSecret, |
| 52 | Passkey |
| 53 | )) |
| 54 | .connect(&url) |
| 55 | .await?; |
| 56 | |
| 57 | if fresh { |
| 58 | db.push_schema().await?; |
| 59 | } else { |
| 60 | migrate_existing(path)?; |
| 61 | } |
| 62 | Ok(db) |
| 63 | } |
| 64 | |
| 65 | /// Tables added after a deployment's database was first created, as idempotent |
| 66 | /// DDL applied to existing databases (Toasty's `push_schema` only runs on a |
| 67 | /// fresh file; see above). Each statement must match what `push_schema` would |
| 68 | /// generate for the model — `schema_shim_matches_push_schema` asserts that. |
| 69 | const SCHEMA_SHIMS: &[&str] = &[ |
| 70 | CI_ARTIFACTS_DDL, |
| 71 | r#"CREATE INDEX IF NOT EXISTS "index_ci_artifacts_by_repo_id" ON "ci_artifacts" ("repo_id")"#, |
| 72 | r#"CREATE INDEX IF NOT EXISTS "index_ci_artifacts_by_run_id" ON "ci_artifacts" ("run_id")"#, |
| 73 | ISSUES_DDL, |
| 74 | r#"CREATE INDEX IF NOT EXISTS "index_issues_by_repo_id" ON "issues" ("repo_id")"#, |
| 75 | ISSUE_COMMENTS_DDL, |
| 76 | r#"CREATE INDEX IF NOT EXISTS "index_issue_comments_by_issue_id" ON "issue_comments" ("issue_id")"#, |
| 77 | ATTACHMENTS_DDL, |
| 78 | r#"CREATE INDEX IF NOT EXISTS "index_attachments_by_repo_id" ON "attachments" ("repo_id")"#, |
| 79 | API_TOKENS_DDL, |
| 80 | r#"CREATE INDEX IF NOT EXISTS "index_api_tokens_by_user_id" ON "api_tokens" ("user_id")"#, |
| 81 | r#"CREATE UNIQUE INDEX IF NOT EXISTS "index_api_tokens_by_token_hash" ON "api_tokens" ("token_hash")"#, |
| 82 | ADMIN_CACHE_DDL, |
| 83 | REPO_SECRETS_DDL, |
| 84 | r#"CREATE INDEX IF NOT EXISTS "index_repo_secrets_by_repo_id" ON "repo_secrets" ("repo_id")"#, |
| 85 | PASSKEYS_DDL, |
| 86 | r#"CREATE INDEX IF NOT EXISTS "index_passkeys_by_user_id" ON "passkeys" ("user_id")"#, |
| 87 | r#"CREATE UNIQUE INDEX IF NOT EXISTS "index_passkeys_by_credential_id" ON "passkeys" ("credential_id")"#, |
| 88 | ]; |
| 89 | |
| 90 | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( |
| 91 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 92 | "run_id" BIGINT NOT NULL, |
| 93 | "repo_id" BIGINT NOT NULL, |
| 94 | "commit" TEXT NOT NULL, |
| 95 | "name" TEXT NOT NULL, |
| 96 | "size" BIGINT NOT NULL, |
| 97 | "is_dir" BOOLEAN NOT NULL, |
| 98 | "browse" BOOLEAN NOT NULL, |
| 99 | "meta" TEXT NOT NULL, |
| 100 | "created_at" BIGINT NOT NULL )"#; |
| 101 | |
| 102 | const ISSUES_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "issues" ( |
| 103 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 104 | "repo_id" BIGINT NOT NULL, |
| 105 | "number" BIGINT NOT NULL, |
| 106 | "title" TEXT NOT NULL, |
| 107 | "body" TEXT NOT NULL, |
| 108 | "author_id" BIGINT NOT NULL, |
| 109 | "state" TEXT NOT NULL, |
| 110 | "created_at" BIGINT NOT NULL, |
| 111 | "updated_at" BIGINT NOT NULL )"#; |
| 112 | |
| 113 | const ISSUE_COMMENTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "issue_comments" ( |
| 114 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 115 | "issue_id" BIGINT NOT NULL, |
| 116 | "author_id" BIGINT NOT NULL, |
| 117 | "body" TEXT NOT NULL, |
| 118 | "created_at" BIGINT NOT NULL )"#; |
| 119 | |
| 120 | const ATTACHMENTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "attachments" ( |
| 121 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 122 | "repo_id" BIGINT NOT NULL, |
| 123 | "hash" TEXT NOT NULL, |
| 124 | "content_type" TEXT NOT NULL, |
| 125 | "size" BIGINT NOT NULL, |
| 126 | "uploader_id" BIGINT NOT NULL, |
| 127 | "created_at" BIGINT NOT NULL )"#; |
| 128 | |
| 129 | const API_TOKENS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "api_tokens" ( |
| 130 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 131 | "user_id" BIGINT NOT NULL, |
| 132 | "name" TEXT NOT NULL, |
| 133 | "token_hash" TEXT NOT NULL, |
| 134 | "scopes" TEXT NOT NULL, |
| 135 | "created_at" BIGINT NOT NULL )"#; |
| 136 | |
| 137 | const REPO_SECRETS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "repo_secrets" ( |
| 138 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 139 | "repo_id" BIGINT NOT NULL, |
| 140 | "name" TEXT NOT NULL, |
| 141 | "envelope" TEXT NOT NULL, |
| 142 | "recipients" TEXT NOT NULL, |
| 143 | "created_at" BIGINT NOT NULL, |
| 144 | "updated_at" BIGINT NOT NULL )"#; |
| 145 | |
| 146 | const PASSKEYS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "passkeys" ( |
| 147 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 148 | "user_id" BIGINT NOT NULL, |
| 149 | "name" TEXT NOT NULL, |
| 150 | "credential_id" TEXT NOT NULL, |
| 151 | "user_handle" TEXT NOT NULL, |
| 152 | "static_state" TEXT NOT NULL, |
| 153 | "dynamic_state" TEXT NOT NULL, |
| 154 | "transports" BIGINT NOT NULL, |
| 155 | "created_at" BIGINT NOT NULL, |
| 156 | "last_used_at" BIGINT NOT NULL )"#; |
| 157 | |
| 158 | const ADMIN_CACHE_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "admin_cache" ( |
| 159 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 160 | "key" TEXT NOT NULL, |
| 161 | "value" TEXT NOT NULL, |
| 162 | "computed_at" BIGINT NOT NULL )"#; |
| 163 | |
| 164 | /// Columns added to existing tables after deployment, applied as |
| 165 | /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS` |
| 166 | /// for columns, so presence is checked via `pragma_table_info`). The model |
| 167 | /// must declare the field *last* so fresh and migrated column orders agree. |
| 168 | /// The `DEFAULT` backfills existing rows; Toasty's fresh DDL omits it, which |
| 169 | /// is fine — inserts always provide every field. |
| 170 | const COLUMN_SHIMS: &[(&str, &str, &str)] = &[ |
| 171 | ( |
| 172 | "repositories", |
| 173 | "mirror_url", |
| 174 | r#"ALTER TABLE "repositories" ADD COLUMN "mirror_url" TEXT NOT NULL DEFAULT ''"#, |
| 175 | ), |
| 176 | ( |
| 177 | "repositories", |
| 178 | "preview_image_hash", |
| 179 | r#"ALTER TABLE "repositories" ADD COLUMN "preview_image_hash" TEXT NOT NULL DEFAULT ''"#, |
| 180 | ), |
| 181 | ( |
| 182 | "repositories", |
| 183 | "primary_language", |
| 184 | r#"ALTER TABLE "repositories" ADD COLUMN "primary_language" TEXT NOT NULL DEFAULT ''"#, |
| 185 | ), |
| 186 | ( |
| 187 | "repositories", |
| 188 | "languages_json", |
| 189 | r#"ALTER TABLE "repositories" ADD COLUMN "languages_json" TEXT NOT NULL DEFAULT ''"#, |
| 190 | ), |
| 191 | ]; |
| 192 | |
| 193 | /// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses |
| 194 | /// rusqlite directly (already in-tree via Toasty's SQLite driver); every |
| 195 | /// statement is a no-op once applied. |
| 196 | fn migrate_existing(path: &Path) -> Result<()> { |
| 197 | let migrate_err = |e: &dyn std::fmt::Display| crate::Error::Config(format!("schema shim: {e}")); |
| 198 | let conn = rusqlite::Connection::open(path) |
| 199 | .map_err(|e| crate::Error::Config(format!("opening {} to migrate: {e}", path.display())))?; |
| 200 | for ddl in SCHEMA_SHIMS { |
| 201 | conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?; |
| 202 | } |
| 203 | for (table, column, ddl) in COLUMN_SHIMS { |
| 204 | let present: i64 = conn |
| 205 | .query_row( |
| 206 | "SELECT count(*) FROM pragma_table_info(?1) WHERE name = ?2", |
| 207 | (table, column), |
| 208 | |r| r.get(0), |
| 209 | ) |
| 210 | .map_err(|e| migrate_err(&e))?; |
| 211 | if present == 0 { |
| 212 | conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?; |
| 213 | } |
| 214 | } |
| 215 | Ok(()) |
| 216 | } |
| 217 | |
| 218 | #[cfg(test)] |
| 219 | mod tests { |
| 220 | use super::*; |
| 221 | |
| 222 | /// Tables created by shims (i.e. added after the first deployment). |
| 223 | const SHIMMED_TABLES: &[&str] = &[ |
| 224 | "ci_artifacts", |
| 225 | "issues", |
| 226 | "issue_comments", |
| 227 | "attachments", |
| 228 | "api_tokens", |
| 229 | "repo_secrets", |
| 230 | "passkeys", |
| 231 | ]; |
| 232 | |
| 233 | /// Every schema object (table + indexes) for `table`, normalized. |
| 234 | fn schema_objects(path: &Path, table: &str) -> Vec<String> { |
| 235 | let conn = rusqlite::Connection::open(path).unwrap(); |
| 236 | let mut stmt = conn |
| 237 | .prepare( |
| 238 | "SELECT sql FROM sqlite_master WHERE tbl_name=?1 \ |
| 239 | AND sql IS NOT NULL ORDER BY name", |
| 240 | ) |
| 241 | .unwrap(); |
| 242 | stmt.query_map([table], |r| r.get::<_, String>(0)) |
| 243 | .unwrap() |
| 244 | .map(|s| { |
| 245 | s.unwrap() |
| 246 | .replace(" IF NOT EXISTS", "") |
| 247 | .split_whitespace() |
| 248 | .collect::<Vec<_>>() |
| 249 | .join(" ") |
| 250 | }) |
| 251 | .collect() |
| 252 | } |
| 253 | |
| 254 | /// The hand-written shim DDL must produce the same tables and indexes as |
| 255 | /// a fresh `push_schema`, or fresh and migrated databases would diverge. |
| 256 | #[tokio::test] |
| 257 | async fn schema_shim_matches_push_schema() { |
| 258 | let dir = tempfile::tempdir().unwrap(); |
| 259 | |
| 260 | let fresh = dir.path().join("fresh.db"); |
| 261 | connect(&fresh).await.unwrap(); |
| 262 | |
| 263 | // A database from before the shimmed tables existed. |
| 264 | let migrated = dir.path().join("migrated.db"); |
| 265 | connect(&migrated).await.unwrap(); |
| 266 | let conn = rusqlite::Connection::open(&migrated).unwrap(); |
| 267 | for table in SHIMMED_TABLES { |
| 268 | conn.execute_batch(&format!("DROP TABLE {table}")).unwrap(); |
| 269 | } |
| 270 | drop(conn); |
| 271 | connect(&migrated).await.unwrap(); |
| 272 | |
| 273 | for table in SHIMMED_TABLES { |
| 274 | assert_eq!( |
| 275 | schema_objects(&fresh, table), |
| 276 | schema_objects(&migrated, table), |
| 277 | "schema diverges for {table}" |
| 278 | ); |
| 279 | assert!( |
| 280 | !schema_objects(&fresh, table).is_empty(), |
| 281 | "no schema objects for {table}" |
| 282 | ); |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | /// Column name/type/notnull triples for a table. |
| 287 | fn columns(path: &Path, table: &str) -> Vec<(String, String, bool)> { |
| 288 | let conn = rusqlite::Connection::open(path).unwrap(); |
| 289 | let mut stmt = conn |
| 290 | .prepare("SELECT name, type, \"notnull\" FROM pragma_table_info(?1)") |
| 291 | .unwrap(); |
| 292 | stmt.query_map([table], |r| { |
| 293 | Ok((r.get(0)?, r.get(1)?, r.get::<_, i64>(2)? != 0)) |
| 294 | }) |
| 295 | .unwrap() |
| 296 | .map(|r| r.unwrap()) |
| 297 | .collect() |
| 298 | } |
| 299 | |
| 300 | /// A column shim must converge an old table to the fresh schema's columns |
| 301 | /// (same names, order, types, nullability — the DDL text itself differs |
| 302 | /// because of the backfill `DEFAULT`). |
| 303 | #[tokio::test] |
| 304 | async fn column_shim_matches_push_schema() { |
| 305 | let dir = tempfile::tempdir().unwrap(); |
| 306 | |
| 307 | let fresh = dir.path().join("fresh.db"); |
| 308 | connect(&fresh).await.unwrap(); |
| 309 | |
| 310 | let migrated = dir.path().join("migrated.db"); |
| 311 | connect(&migrated).await.unwrap(); |
| 312 | let conn = rusqlite::Connection::open(&migrated).unwrap(); |
| 313 | conn.execute_batch(r#"ALTER TABLE "repositories" DROP COLUMN "mirror_url""#) |
| 314 | .unwrap(); |
| 315 | drop(conn); |
| 316 | connect(&migrated).await.unwrap(); |
| 317 | connect(&migrated).await.unwrap(); // idempotent |
| 318 | |
| 319 | assert_eq!( |
| 320 | columns(&fresh, "repositories"), |
| 321 | columns(&migrated, "repositories") |
| 322 | ); |
| 323 | } |
| 324 | |
| 325 | /// Reconnecting to an existing database that predates a table must create |
| 326 | /// it (and reconnecting again must be a no-op). |
| 327 | #[tokio::test] |
| 328 | async fn migrate_existing_adds_missing_tables() { |
| 329 | let dir = tempfile::tempdir().unwrap(); |
| 330 | let path = dir.path().join("old.db"); |
| 331 | connect(&path).await.unwrap(); |
| 332 | |
| 333 | // Simulate a database from before the table existed. |
| 334 | let conn = rusqlite::Connection::open(&path).unwrap(); |
| 335 | conn.execute_batch("DROP TABLE ci_artifacts").unwrap(); |
| 336 | drop(conn); |
| 337 | |
| 338 | connect(&path).await.unwrap(); |
| 339 | connect(&path).await.unwrap(); // idempotent |
| 340 | |
| 341 | let conn = rusqlite::Connection::open(&path).unwrap(); |
| 342 | let n: i64 = conn |
| 343 | .query_row( |
| 344 | "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ci_artifacts'", |
| 345 | [], |
| 346 | |r| r.get(0), |
| 347 | ) |
| 348 | .unwrap(); |
| 349 | assert_eq!(n, 1); |
| 350 | } |
| 351 | } |