anvilsign in

collin/anvil

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