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