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
141const ADMIN_CACHE_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "admin_cache" (
142"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
143"key" TEXT NOT NULL,
144"value" TEXT NOT NULL,
145"computed_at" BIGINT NOT NULL )"#;
146
147/// Columns added to existing tables after deployment, applied as
148/// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS`
149/// for columns, so presence is checked via `pragma_table_info`). The model
150/// must declare the field *last* so fresh and migrated column orders agree.
151/// The `DEFAULT` backfills existing rows; Toasty's fresh DDL omits it, which
152/// is fine — inserts always provide every field.
153const COLUMN_SHIMS: &[(&str, &str, &str)] = &[
154 (
155 "repositories",
156 "mirror_url",
157 r#"ALTER TABLE "repositories" ADD COLUMN "mirror_url" TEXT NOT NULL DEFAULT ''"#,
158 ),
159 (
160 "repositories",
161 "preview_image_hash",
162 r#"ALTER TABLE "repositories" ADD COLUMN "preview_image_hash" TEXT NOT NULL DEFAULT ''"#,
163 ),
164 (
165 "repositories",
166 "primary_language",
167 r#"ALTER TABLE "repositories" ADD COLUMN "primary_language" TEXT NOT NULL DEFAULT ''"#,
168 ),
169 (
170 "repositories",
171 "languages_json",
172 r#"ALTER TABLE "repositories" ADD COLUMN "languages_json" TEXT NOT NULL DEFAULT ''"#,
173 ),
174 (
175 "users",
176 "sso_sub",
177 r#"ALTER TABLE "users" ADD COLUMN "sso_sub" TEXT NOT NULL DEFAULT ''"#,
178 ),
179];
180
181/// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses
182/// rusqlite directly (already in-tree via Toasty's SQLite driver); every
183/// statement is a no-op once applied.
184fn migrate_existing(path: &Path) -> Result<()> {
185 let migrate_err = |e: &dyn std::fmt::Display| crate::Error::Config(format!("schema shim: {e}"));
186 let conn = rusqlite::Connection::open(path)
187 .map_err(|e| crate::Error::Config(format!("opening {} to migrate: {e}", path.display())))?;
188 for ddl in SCHEMA_SHIMS {
189 conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?;
190 }
191 for (table, column, ddl) in COLUMN_SHIMS {
192 let present: i64 = conn
193 .query_row(
194 "SELECT count(*) FROM pragma_table_info(?1) WHERE name = ?2",
195 (table, column),
196 |r| r.get(0),
197 )
198 .map_err(|e| migrate_err(&e))?;
199 if present == 0 {
200 conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?;
201 }
202 }
203 Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 /// Tables created by shims (i.e. added after the first deployment).
211 const SHIMMED_TABLES: &[&str] = &[
212 "ci_artifacts",
213 "issues",
214 "issue_comments",
215 "attachments",
216 "api_tokens",
217 "repo_secrets",
218 ];
219
220 /// Every schema object (table + indexes) for `table`, normalized.
221 fn schema_objects(path: &Path, table: &str) -> Vec<String> {
222 let conn = rusqlite::Connection::open(path).unwrap();
223 let mut stmt = conn
224 .prepare(
225 "SELECT sql FROM sqlite_master WHERE tbl_name=?1 \
226 AND sql IS NOT NULL ORDER BY name",
227 )
228 .unwrap();
229 stmt.query_map([table], |r| r.get::<_, String>(0))
230 .unwrap()
231 .map(|s| {
232 s.unwrap()
233 .replace(" IF NOT EXISTS", "")
234 .split_whitespace()
235 .collect::<Vec<_>>()
236 .join(" ")
237 })
238 .collect()
239 }
240
241 /// The hand-written shim DDL must produce the same tables and indexes as
242 /// a fresh `push_schema`, or fresh and migrated databases would diverge.
243 #[tokio::test]
244 async fn schema_shim_matches_push_schema() {
245 let dir = tempfile::tempdir().unwrap();
246
247 let fresh = dir.path().join("fresh.db");
248 connect(&fresh).await.unwrap();
249
250 // A database from before the shimmed tables existed.
251 let migrated = dir.path().join("migrated.db");
252 connect(&migrated).await.unwrap();
253 let conn = rusqlite::Connection::open(&migrated).unwrap();
254 for table in SHIMMED_TABLES {
255 conn.execute_batch(&format!("DROP TABLE {table}")).unwrap();
256 }
257 drop(conn);
258 connect(&migrated).await.unwrap();
259
260 for table in SHIMMED_TABLES {
261 assert_eq!(
262 schema_objects(&fresh, table),
263 schema_objects(&migrated, table),
264 "schema diverges for {table}"
265 );
266 assert!(
267 !schema_objects(&fresh, table).is_empty(),
268 "no schema objects for {table}"
269 );
270 }
271 }
272
273 /// Column name/type/notnull triples for a table.
274 fn columns(path: &Path, table: &str) -> Vec<(String, String, bool)> {
275 let conn = rusqlite::Connection::open(path).unwrap();
276 let mut stmt = conn
277 .prepare("SELECT name, type, \"notnull\" FROM pragma_table_info(?1)")
278 .unwrap();
279 stmt.query_map([table], |r| {
280 Ok((r.get(0)?, r.get(1)?, r.get::<_, i64>(2)? != 0))
281 })
282 .unwrap()
283 .map(|r| r.unwrap())
284 .collect()
285 }
286
287 /// A column shim must converge an old table to the fresh schema's columns
288 /// (same names, order, types, nullability — the DDL text itself differs
289 /// because of the backfill `DEFAULT`).
290 ///
291 /// The shimmed columns are dropped newest-first, which is the only shape a
292 /// real database takes: each was appended by a deployment, so an older one
293 /// is missing that column *and every column added after it*. Dropping one
294 /// from the middle instead would re-add it at the end and diverge — which
295 /// says nothing about the migration, only about `ALTER TABLE`.
296 #[tokio::test]
297 async fn column_shim_matches_push_schema() {
298 let dir = tempfile::tempdir().unwrap();
299
300 let fresh = dir.path().join("fresh.db");
301 connect(&fresh).await.unwrap();
302
303 let migrated = dir.path().join("migrated.db");
304 connect(&migrated).await.unwrap();
305 let conn = rusqlite::Connection::open(&migrated).unwrap();
306 for (table, column, _) in COLUMN_SHIMS.iter().rev() {
307 conn.execute_batch(&format!(r#"ALTER TABLE "{table}" DROP COLUMN "{column}""#))
308 .unwrap();
309 }
310 drop(conn);
311 connect(&migrated).await.unwrap();
312 connect(&migrated).await.unwrap(); // idempotent
313
314 for table in ["repositories", "users"] {
315 assert_eq!(
316 columns(&fresh, table),
317 columns(&migrated, table),
318 "columns diverge for {table}"
319 );
320 }
321 }
322
323 /// Reconnecting to an existing database that predates a table must create
324 /// it (and reconnecting again must be a no-op).
325 #[tokio::test]
326 async fn migrate_existing_adds_missing_tables() {
327 let dir = tempfile::tempdir().unwrap();
328 let path = dir.path().join("old.db");
329 connect(&path).await.unwrap();
330
331 // Simulate a database from before the table existed.
332 let conn = rusqlite::Connection::open(&path).unwrap();
333 conn.execute_batch("DROP TABLE ci_artifacts").unwrap();
334 drop(conn);
335
336 connect(&path).await.unwrap();
337 connect(&path).await.unwrap(); // idempotent
338
339 let conn = rusqlite::Connection::open(&path).unwrap();
340 let n: i64 = conn
341 .query_row(
342 "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ci_artifacts'",
343 [],
344 |r| r.get(0),
345 )
346 .unwrap();
347 assert_eq!(n, 1);
348 }
349}