fabrica

hanna/fabrica

10918 bytes
Raw
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
28mod admin;
29mod bookmarks;
30mod follows;
31mod groups;
32mod invites;
33mod issues;
34mod keys;
35mod labels;
36mod lfs;
37mod mirrors;
38mod notifications;
39mod releases;
40mod repos;
41mod sessions;
42mod tokens;
43mod users;
44
45use std::sync::Once;
46use std::time::{SystemTime, UNIX_EPOCH};
47
48use sqlx::AnyPool;
49use sqlx::Row;
50use sqlx::any::{AnyPoolOptions, AnyRow};
51use sqlx::migrate::Migrator;
52
53pub use crate::admin::{AdminCounts, NewSignupInvite, SignupInvite};
54pub use crate::invites::NewInvite;
55pub use crate::issues::StateCounts;
56pub use crate::keys::NewKey;
57pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror};
58pub use crate::notifications::{NewNotification, Notification};
59pub use crate::releases::{NewRelease, Release, ReleaseAsset};
60pub use crate::repos::{Collaborator, NewRepo};
61pub use crate::sessions::NewSession;
62pub use crate::tokens::NewToken;
63pub use crate::users::{NewUser, ProfileUpdate};
64
65/// Migrations for the SQLite dialect, embedded at build time.
66static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("../../migrations/sqlite");
67/// Migrations for the PostgreSQL dialect, embedded at build time.
68static 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.
73const 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.
82fn 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)]
90pub enum Backend {
91 /// A SQLite database (file-backed or in-memory).
92 Sqlite,
93 /// A PostgreSQL database.
94 Postgres,
95}
96
97impl 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)]
119pub 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
152impl 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)]
162pub struct Store {
163 pool: AnyPool,
164 backend: Backend,
165}
166
167impl 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.
249fn 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).
256fn 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.
264fn 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.
282fn 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)]
301mod tests;