| 1 | // This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| 4 | |
| 5 | //! Persistence layer: schema, migrations, and portable queries over SQLite and Postgres. |
| 6 | //! |
| 7 | //! # One code path, two backends |
| 8 | //! |
| 9 | //! Rather than duplicate every statement per dialect, the store runs over |
| 10 | //! [`sqlx::Any`]. That is sound here only because we stay inside a deliberately |
| 11 | //! narrow, portable slice of SQL and mediate the two places the backends |
| 12 | //! genuinely differ: |
| 13 | //! |
| 14 | //! * **Placeholders.** Both SQLite and Postgres accept `$1, $2, …` positional |
| 15 | //! placeholders (only MySQL needs `?`), so every query is written once with |
| 16 | //! `$N`. |
| 17 | //! * **Booleans.** Postgres has a real `BOOLEAN`; SQLite stores `INTEGER` 0/1. |
| 18 | //! Binding a Rust `bool` encodes correctly for both, but *reading* one back |
| 19 | //! differs — a SQLite boolean column decodes as an integer. [`Store::get_bool`] |
| 20 | //! is the single place that reconciles this. |
| 21 | //! * **Timestamps** are `BIGINT` Unix milliseconds UTC everywhere; **ids** are |
| 22 | //! lowercase 26-char ULID `TEXT`, lexicographically sortable so they double as |
| 23 | //! the default ordering key. |
| 24 | //! |
| 25 | //! Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`; the matching |
| 26 | //! set is embedded at build time and selected at runtime by [`Store::migrate`]. |
| 27 | |
| 28 | mod admin; |
| 29 | mod bookmarks; |
| 30 | mod follows; |
| 31 | mod groups; |
| 32 | mod invites; |
| 33 | mod issues; |
| 34 | mod keys; |
| 35 | mod labels; |
| 36 | mod lfs; |
| 37 | mod mirrors; |
| 38 | mod notifications; |
| 39 | mod releases; |
| 40 | mod repos; |
| 41 | mod sessions; |
| 42 | mod tokens; |
| 43 | mod users; |
| 44 | |
| 45 | use std::sync::Once; |
| 46 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 47 | |
| 48 | use sqlx::AnyPool; |
| 49 | use sqlx::Row; |
| 50 | use sqlx::any::{AnyPoolOptions, AnyRow}; |
| 51 | use sqlx::migrate::Migrator; |
| 52 | |
| 53 | pub use crate::admin::{AdminCounts, NewSignupInvite, SignupInvite}; |
| 54 | pub use crate::invites::NewInvite; |
| 55 | pub use crate::issues::StateCounts; |
| 56 | pub use crate::keys::NewKey; |
| 57 | pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror}; |
| 58 | pub use crate::notifications::{NewNotification, Notification}; |
| 59 | pub use crate::releases::{NewRelease, Release, ReleaseAsset}; |
| 60 | pub use crate::repos::{Collaborator, NewRepo}; |
| 61 | pub use crate::sessions::NewSession; |
| 62 | pub use crate::tokens::NewToken; |
| 63 | pub use crate::users::{NewUser, ProfileUpdate}; |
| 64 | |
| 65 | /// Migrations for the SQLite dialect, embedded at build time. |
| 66 | static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("../../migrations/sqlite"); |
| 67 | /// Migrations for the PostgreSQL dialect, embedded at build time. |
| 68 | static POSTGRES_MIGRATOR: Migrator = sqlx::migrate!("../../migrations/postgres"); |
| 69 | |
| 70 | /// Per-connection SQLite pragmas, applied on every fresh connection. WAL and a |
| 71 | /// busy timeout let the CLI and a running server share the file; foreign keys |
| 72 | /// must be turned on explicitly on each connection. |
| 73 | const SQLITE_PRAGMAS: &[&str] = &[ |
| 74 | "PRAGMA journal_mode = WAL", |
| 75 | "PRAGMA synchronous = NORMAL", |
| 76 | "PRAGMA foreign_keys = ON", |
| 77 | "PRAGMA busy_timeout = 5000", |
| 78 | ]; |
| 79 | |
| 80 | /// Ensure the `Any` driver knows about the SQLite and Postgres backends. Safe to |
| 81 | /// call repeatedly; the work happens exactly once. |
| 82 | fn install_drivers() { |
| 83 | static ONCE: Once = Once::new(); |
| 84 | ONCE.call_once(sqlx::any::install_default_drivers); |
| 85 | } |
| 86 | |
| 87 | /// Which concrete database a [`Store`] is talking to. The value is fixed at |
| 88 | /// connect time and drives the handful of dialect-dependent decisions. |
| 89 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 90 | pub enum Backend { |
| 91 | /// A SQLite database (file-backed or in-memory). |
| 92 | Sqlite, |
| 93 | /// A PostgreSQL database. |
| 94 | Postgres, |
| 95 | } |
| 96 | |
| 97 | impl Backend { |
| 98 | /// Infer the backend from a connection URL's scheme. |
| 99 | /// |
| 100 | /// # Errors |
| 101 | /// |
| 102 | /// Returns [`StoreError::UnsupportedUrl`] for any scheme other than |
| 103 | /// `sqlite:` or `postgres:`/`postgresql:`. |
| 104 | pub fn detect(url: &str) -> Result<Self, StoreError> { |
| 105 | if url.starts_with("sqlite:") { |
| 106 | Ok(Self::Sqlite) |
| 107 | } else if url.starts_with("postgres:") || url.starts_with("postgresql:") { |
| 108 | Ok(Self::Postgres) |
| 109 | } else { |
| 110 | Err(StoreError::UnsupportedUrl { |
| 111 | url: url.to_string(), |
| 112 | }) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | /// An error from the persistence layer. |
| 118 | #[derive(Debug, thiserror::Error)] |
| 119 | pub enum StoreError { |
| 120 | /// The connection URL used an unrecognized scheme. |
| 121 | #[error("unsupported database URL {url:?}: expected a sqlite: or postgres: scheme")] |
| 122 | UnsupportedUrl { |
| 123 | /// The offending URL. Connection URLs can carry credentials, so callers |
| 124 | /// should avoid surfacing this verbatim to end users. |
| 125 | url: String, |
| 126 | }, |
| 127 | /// A query or connection failed. Boxed to keep [`StoreError`] small. |
| 128 | #[error(transparent)] |
| 129 | Query(#[from] Box<sqlx::Error>), |
| 130 | /// A migration failed to apply. |
| 131 | #[error(transparent)] |
| 132 | Migrate(#[from] sqlx::migrate::MigrateError), |
| 133 | /// A name failed validation before it could be written. |
| 134 | #[error("invalid name: {0}")] |
| 135 | Name(#[from] model::NameError), |
| 136 | /// A group path nested deeper than [`model::MAX_GROUP_DEPTH`] levels. |
| 137 | #[error("groups may nest at most {max} levels deep")] |
| 138 | GroupTooDeep { |
| 139 | /// The maximum permitted nesting depth. |
| 140 | max: usize, |
| 141 | }, |
| 142 | /// A uniqueness constraint was violated (e.g. a duplicate username). |
| 143 | #[error("{entity} already exists with that {field}")] |
| 144 | Conflict { |
| 145 | /// The kind of entity that collided (`"user"`, `"repo"`, …). |
| 146 | entity: &'static str, |
| 147 | /// The field whose uniqueness was violated. |
| 148 | field: &'static str, |
| 149 | }, |
| 150 | } |
| 151 | |
| 152 | impl From<sqlx::Error> for StoreError { |
| 153 | fn from(err: sqlx::Error) -> Self { |
| 154 | Self::Query(Box::new(err)) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | /// A handle to the database: a connection pool plus the backend it targets. |
| 159 | /// |
| 160 | /// Cloneable and cheap to share — the underlying pool is reference-counted. |
| 161 | #[derive(Debug, Clone)] |
| 162 | pub struct Store { |
| 163 | pool: AnyPool, |
| 164 | backend: Backend, |
| 165 | } |
| 166 | |
| 167 | impl Store { |
| 168 | /// Open a pool against `url`, applying SQLite pragmas when appropriate. |
| 169 | /// |
| 170 | /// File-backed SQLite URLs are created if missing. This does **not** run |
| 171 | /// migrations — call [`Store::migrate`] separately so `--no-migrate` can skip |
| 172 | /// it. |
| 173 | /// |
| 174 | /// # Errors |
| 175 | /// |
| 176 | /// Returns [`StoreError::UnsupportedUrl`] for an unknown scheme, or |
| 177 | /// [`StoreError::Query`] if the pool cannot be established. |
| 178 | pub async fn connect(url: &str, max_connections: u32) -> Result<Self, StoreError> { |
| 179 | install_drivers(); |
| 180 | let backend = Backend::detect(url)?; |
| 181 | let url = normalize_url(url, backend); |
| 182 | |
| 183 | let mut options = AnyPoolOptions::new().max_connections(max_connections.max(1)); |
| 184 | if backend == Backend::Sqlite { |
| 185 | options = options.after_connect(|conn, _meta| { |
| 186 | Box::pin(async move { |
| 187 | for pragma in SQLITE_PRAGMAS { |
| 188 | sqlx::query(*pragma).execute(&mut *conn).await?; |
| 189 | } |
| 190 | Ok(()) |
| 191 | }) |
| 192 | }); |
| 193 | } |
| 194 | |
| 195 | let pool = options.connect(&url).await.map_err(Box::new)?; |
| 196 | Ok(Self { pool, backend }) |
| 197 | } |
| 198 | |
| 199 | /// Wrap an already-open pool. Primarily a test seam. |
| 200 | /// |
| 201 | /// The `backend` must match the pool's actual driver, or dialect-dependent |
| 202 | /// decoding (booleans) will misbehave. |
| 203 | #[must_use] |
| 204 | pub fn from_pool(pool: AnyPool, backend: Backend) -> Self { |
| 205 | Self { pool, backend } |
| 206 | } |
| 207 | |
| 208 | /// The backend this store targets. |
| 209 | #[must_use] |
| 210 | pub fn backend(&self) -> Backend { |
| 211 | self.backend |
| 212 | } |
| 213 | |
| 214 | /// The underlying connection pool, for callers that need raw access. |
| 215 | #[must_use] |
| 216 | pub fn pool(&self) -> &AnyPool { |
| 217 | &self.pool |
| 218 | } |
| 219 | |
| 220 | /// Apply all pending migrations for this backend's dialect. |
| 221 | /// |
| 222 | /// # Errors |
| 223 | /// |
| 224 | /// Returns [`StoreError::Migrate`] if a migration fails or the recorded |
| 225 | /// history is inconsistent with the embedded set. |
| 226 | pub async fn migrate(&self) -> Result<(), StoreError> { |
| 227 | let migrator = match self.backend { |
| 228 | Backend::Sqlite => &SQLITE_MIGRATOR, |
| 229 | Backend::Postgres => &POSTGRES_MIGRATOR, |
| 230 | }; |
| 231 | migrator.run(&self.pool).await?; |
| 232 | Ok(()) |
| 233 | } |
| 234 | |
| 235 | /// Read a boolean column, bridging SQLite's integer storage and Postgres's |
| 236 | /// native `BOOLEAN`. This is the one place the two dialects' boolean |
| 237 | /// representations are reconciled on the read path. |
| 238 | fn get_bool(&self, row: &AnyRow, column: &str) -> Result<bool, sqlx::Error> { |
| 239 | match self.backend { |
| 240 | Backend::Sqlite => Ok(row.try_get::<i64, _>(column)? != 0), |
| 241 | Backend::Postgres => row.try_get::<bool, _>(column), |
| 242 | } |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | /// Generate a fresh identifier: a lowercase, 26-character ULID. ULIDs are |
| 247 | /// lexicographically sortable by creation time, so they double as an ordering |
| 248 | /// key and never need a separate sequence. |
| 249 | fn new_id() -> String { |
| 250 | ulid::Ulid::new().to_string().to_lowercase() |
| 251 | } |
| 252 | |
| 253 | /// The current time as Unix milliseconds UTC, matching the schema's `BIGINT` |
| 254 | /// timestamp columns. Clamps rather than panicking on the impossible cases (a |
| 255 | /// clock before the epoch, or a time past year 292-million). |
| 256 | fn now_ms() -> i64 { |
| 257 | SystemTime::now() |
| 258 | .duration_since(UNIX_EPOCH) |
| 259 | .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) |
| 260 | } |
| 261 | |
| 262 | /// Ensure a file-backed SQLite URL will create its database if absent. In-memory |
| 263 | /// URLs and any URL that already specifies a `mode` are left untouched. |
| 264 | fn normalize_url(url: &str, backend: Backend) -> String { |
| 265 | if backend == Backend::Sqlite && !url.contains(":memory:") && !url.contains("mode=") { |
| 266 | let separator = if url.contains('?') { '&' } else { '?' }; |
| 267 | format!("{url}{separator}mode=rwc") |
| 268 | } else { |
| 269 | url.to_string() |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | /// Map a failed write to [`StoreError::Conflict`] when it was a uniqueness |
| 274 | /// violation, attributing it to the first matching field. Any other error is |
| 275 | /// passed through unchanged. |
| 276 | /// |
| 277 | /// `needles` pairs a lowercase substring to look for in the violated |
| 278 | /// constraint's name/message with the field label to report. The database's |
| 279 | /// error text names the offending column (`users.email_lower`, or a Postgres |
| 280 | /// constraint like `users_email_lower_key`), so a substring match reliably |
| 281 | /// identifies the field across both dialects. |
| 282 | fn map_conflict( |
| 283 | err: sqlx::Error, |
| 284 | entity: &'static str, |
| 285 | needles: &[(&str, &'static str)], |
| 286 | ) -> StoreError { |
| 287 | if let sqlx::Error::Database(db) = &err |
| 288 | && db.is_unique_violation() |
| 289 | { |
| 290 | let haystack = format!("{} {}", db.constraint().unwrap_or(""), db.message()).to_lowercase(); |
| 291 | let field = needles |
| 292 | .iter() |
| 293 | .find(|(needle, _)| haystack.contains(needle)) |
| 294 | .map_or("value", |(_, field)| *field); |
| 295 | return StoreError::Conflict { entity, field }; |
| 296 | } |
| 297 | err.into() |
| 298 | } |
| 299 | |
| 300 | #[cfg(test)] |
| 301 | mod tests; |