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
176/// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses
177/// rusqlite directly (already in-tree via Toasty's SQLite driver); every
178/// statement is a no-op once applied.
179fn migrate_existing(path: &Path) -> Result<()> {
180 let migrate_err = |e: &dyn std::fmt::Display| crate::Error::Config(format!("schema shim: {e}"));
181 let conn = rusqlite::Connection::open(path)
182 .map_err(|e| crate::Error::Config(format!("opening {} to migrate: {e}", path.display())))?;
183 for ddl in SCHEMA_SHIMS {
184 conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?;
185 }
186 for (table, column, ddl) in COLUMN_SHIMS {
187 let present: i64 = conn
188 .query_row(
189 "SELECT count(*) FROM pragma_table_info(?1) WHERE name = ?2",
190 (table, column),
191 |r| r.get(0),
192 )
193 .map_err(|e| migrate_err(&e))?;
194 if present == 0 {
195 conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?;
196 }
197 }
198 Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 /// Tables created by shims (i.e. added after the first deployment).
206 const SHIMMED_TABLES: &[&str] = &[
207 "ci_artifacts",
208 "issues",
209 "issue_comments",
210 "attachments",
211 "api_tokens",
212 "repo_secrets",
213 ];
214
215 /// Every schema object (table + indexes) for `table`, normalized.
216 fn schema_objects(path: &Path, table: &str) -> Vec<String> {
217 let conn = rusqlite::Connection::open(path).unwrap();
218 let mut stmt = conn
219 .prepare(
220 "SELECT sql FROM sqlite_master WHERE tbl_name=?1 \
221 AND sql IS NOT NULL ORDER BY name",
222 )
223 .unwrap();
224 stmt.query_map([table], |r| r.get::<_, String>(0))
225 .unwrap()
226 .map(|s| {
227 s.unwrap()
228 .replace(" IF NOT EXISTS", "")
229 .split_whitespace()
230 .collect::<Vec<_>>()
231 .join(" ")
232 })
233 .collect()
234 }
235
236 /// The hand-written shim DDL must produce the same tables and indexes as
237 /// a fresh `push_schema`, or fresh and migrated databases would diverge.
238 #[tokio::test]
239 async fn schema_shim_matches_push_schema() {
240 let dir = tempfile::tempdir().unwrap();
241
242 let fresh = dir.path().join("fresh.db");
243 connect(&fresh).await.unwrap();
244
245 // A database from before the shimmed tables existed.
246 let migrated = dir.path().join("migrated.db");
247 connect(&migrated).await.unwrap();
248 let conn = rusqlite::Connection::open(&migrated).unwrap();
249 for table in SHIMMED_TABLES {
250 conn.execute_batch(&format!("DROP TABLE {table}")).unwrap();
251 }
252 drop(conn);
253 connect(&migrated).await.unwrap();
254
255 for table in SHIMMED_TABLES {
256 assert_eq!(
257 schema_objects(&fresh, table),
258 schema_objects(&migrated, table),
259 "schema diverges for {table}"
260 );
261 assert!(
262 !schema_objects(&fresh, table).is_empty(),
263 "no schema objects for {table}"
264 );
265 }
266 }
267
268 /// Column name/type/notnull triples for a table.
269 fn columns(path: &Path, table: &str) -> Vec<(String, String, bool)> {
270 let conn = rusqlite::Connection::open(path).unwrap();
271 let mut stmt = conn
272 .prepare("SELECT name, type, \"notnull\" FROM pragma_table_info(?1)")
273 .unwrap();
274 stmt.query_map([table], |r| {
275 Ok((r.get(0)?, r.get(1)?, r.get::<_, i64>(2)? != 0))
276 })
277 .unwrap()
278 .map(|r| r.unwrap())
279 .collect()
280 }
281
282 /// A column shim must converge an old table to the fresh schema's columns
283 /// (same names, order, types, nullability — the DDL text itself differs
284 /// because of the backfill `DEFAULT`).
285 #[tokio::test]
286 async fn column_shim_matches_push_schema() {
287 let dir = tempfile::tempdir().unwrap();
288
289 let fresh = dir.path().join("fresh.db");
290 connect(&fresh).await.unwrap();
291
292 let migrated = dir.path().join("migrated.db");
293 connect(&migrated).await.unwrap();
294 let conn = rusqlite::Connection::open(&migrated).unwrap();
295 conn.execute_batch(r#"ALTER TABLE "repositories" DROP COLUMN "mirror_url""#)
296 .unwrap();
297 drop(conn);
298 connect(&migrated).await.unwrap();
299 connect(&migrated).await.unwrap(); // idempotent
300
301 assert_eq!(
302 columns(&fresh, "repositories"),
303 columns(&migrated, "repositories")
304 );
305 }
306
307 /// Reconnecting to an existing database that predates a table must create
308 /// it (and reconnecting again must be a no-op).
309 #[tokio::test]
310 async fn migrate_existing_adds_missing_tables() {
311 let dir = tempfile::tempdir().unwrap();
312 let path = dir.path().join("old.db");
313 connect(&path).await.unwrap();
314
315 // Simulate a database from before the table existed.
316 let conn = rusqlite::Connection::open(&path).unwrap();
317 conn.execute_batch("DROP TABLE ci_artifacts").unwrap();
318 drop(conn);
319
320 connect(&path).await.unwrap();
321 connect(&path).await.unwrap(); // idempotent
322
323 let conn = rusqlite::Connection::open(&path).unwrap();
324 let n: i64 = conn
325 .query_row(
326 "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ci_artifacts'",
327 [],
328 |r| r.get(0),
329 )
330 .unwrap();
331 assert_eq!(n, 1);
332 }
333}