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 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.
33pub 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.
69const 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
90const 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
102const 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
113const 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
120const 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
129const 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
137const 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
146const 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
158const 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.
170const 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.
196fn 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)]
219mod 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}