1use crate::types::BackendId;
4use alembic_core::{TypeName, Uid};
5use anyhow::{anyhow, Context, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::fs::{self, File, OpenOptions, TryLockError};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use tokio::sync::Mutex;
12use tokio_postgres::Client;
13
14#[derive(Debug, Default, Clone, Serialize, Deserialize)]
16pub struct StateData {
17 #[serde(default)]
18 pub mappings: BTreeMap<TypeName, BTreeMap<Uid, BackendId>>,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PostgresTlsMode {
24 Disable,
25 Require,
26}
27
28#[async_trait::async_trait]
30pub trait StateBackend: Send + Sync + std::fmt::Debug {
31 async fn load(&mut self) -> Result<StateData>;
32 async fn save(&mut self, data: &StateData) -> Result<()>;
33}
34
35#[derive(Debug, Clone)]
37pub struct StateStore {
38 backend: Option<Arc<Mutex<dyn StateBackend>>>,
39 data: StateData,
40 journal_dir: Option<PathBuf>,
41 lock: Option<Arc<File>>,
47}
48
49impl StateStore {
50 pub fn new(backend: Option<Arc<Mutex<dyn StateBackend>>>, data: StateData) -> Self {
52 Self {
53 backend,
54 data,
55 journal_dir: None,
56 lock: None,
57 }
58 }
59
60 pub fn with_journal_dir(mut self, dir: PathBuf) -> Self {
62 self.journal_dir = Some(dir);
63 self
64 }
65
66 pub fn journal_dir(&self) -> Option<&Path> {
68 self.journal_dir.as_deref()
69 }
70
71 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
73 let path = path.as_ref().to_path_buf();
74 let lock = acquire_state_lock(&path)?;
78 let backend: Option<Arc<Mutex<dyn StateBackend>>> =
81 Some(Arc::new(Mutex::new(LocalBackend { path: path.clone() }))
82 as Arc<Mutex<dyn StateBackend>>);
83 let data = if path.exists() {
84 let raw = fs::read_to_string(&path)
85 .with_context(|| format!("read state: {}", path.display()))?;
86 serde_json::from_str::<StateData>(&raw)
87 .with_context(|| format!("parse state: {}", path.display()))?
88 } else {
89 StateData::default()
90 };
91 let mut store = Self::new(backend, data);
92 store.lock = Some(lock);
93 Ok(store)
94 }
95
96 pub async fn load_postgres(
98 url: impl Into<String>,
99 key: impl Into<String>,
100 tls_mode: PostgresTlsMode,
101 ) -> Result<Self> {
102 let mut postgres_backend = PostgresBackend {
103 url: url.into(),
104 key: key.into(),
105 tls_mode,
106 loaded_version: None,
107 table_ensured: false,
108 };
109 let data = postgres_backend.load().await?;
110 let backend: Arc<Mutex<dyn StateBackend>> = Arc::new(Mutex::new(postgres_backend));
111 Ok(Self::new(Some(backend), data))
112 }
113
114 pub async fn load_async(&mut self) -> Result<()> {
116 if let Some(backend) = &self.backend {
117 self.data = backend.lock().await.load().await?;
118 }
119 Ok(())
120 }
121
122 pub async fn save_async(&self) -> Result<()> {
124 if let Some(backend) = &self.backend {
125 backend.lock().await.save(&self.data).await?;
126 }
127 Ok(())
128 }
129
130 pub fn backend_id(&self, type_name: TypeName, uid: Uid) -> Option<BackendId> {
132 self.data
133 .mappings
134 .get(&type_name)
135 .and_then(|map| map.get(&uid).cloned())
136 }
137
138 pub fn set_backend_id(&mut self, type_name: TypeName, uid: Uid, backend_id: BackendId) {
140 self.data
141 .mappings
142 .entry(type_name)
143 .or_default()
144 .insert(uid, backend_id);
145 }
146
147 pub fn remove_backend_id(&mut self, type_name: TypeName, uid: Uid) {
149 if let Some(type_map) = self.data.mappings.get_mut(&type_name) {
150 type_map.remove(&uid);
151 }
152 }
153
154 pub fn all_mappings(&self) -> &BTreeMap<TypeName, BTreeMap<Uid, BackendId>> {
156 &self.data.mappings
157 }
158}
159
160fn acquire_state_lock(path: &Path) -> Result<Arc<File>> {
165 if let Some(parent) = path.parent() {
166 if !parent.as_os_str().is_empty() {
167 fs::create_dir_all(parent)
168 .with_context(|| format!("create state dir: {}", parent.display()))?;
169 }
170 }
171 let lock_path = {
172 let mut p = path.as_os_str().to_owned();
173 p.push(".lock");
174 PathBuf::from(p)
175 };
176 let file = OpenOptions::new()
177 .create(true)
178 .write(true)
179 .truncate(false)
180 .open(&lock_path)
181 .with_context(|| format!("open state lock: {}", lock_path.display()))?;
182 match file.try_lock() {
183 Ok(()) => Ok(Arc::new(file)),
184 Err(TryLockError::WouldBlock) => Err(anyhow!(
185 "another alembic run holds the state lock at {}; wait for it to finish",
186 lock_path.display()
187 )),
188 Err(TryLockError::Error(err)) => {
189 Err(err).with_context(|| format!("acquire state lock: {}", lock_path.display()))
190 }
191 }
192}
193
194impl From<&StateStore> for StateData {
195 fn from(store: &StateStore) -> Self {
196 Self {
197 mappings: store.all_mappings().clone(),
198 }
199 }
200}
201
202#[derive(Debug)]
203struct LocalBackend {
204 path: PathBuf,
205}
206
207#[async_trait::async_trait]
208impl StateBackend for LocalBackend {
209 async fn load(&mut self) -> Result<StateData> {
210 if self.path.exists() {
211 let raw = fs::read_to_string(&self.path)
212 .with_context(|| format!("read state: {}", self.path.display()))?;
213 let data = serde_json::from_str::<StateData>(&raw)
214 .with_context(|| format!("parse state: {}", self.path.display()))?;
215 Ok(data)
216 } else {
217 Ok(StateData::default())
218 }
219 }
220
221 async fn save(&mut self, data: &StateData) -> Result<()> {
222 if let Some(parent) = self.path.parent() {
223 fs::create_dir_all(parent)
224 .with_context(|| format!("create state dir: {}", parent.display()))?;
225 }
226 let raw = serde_json::to_string_pretty(data)?;
227 let tmp = self.path.with_extension("json.tmp");
228 fs::write(&tmp, &raw).with_context(|| format!("write state tmp: {}", tmp.display()))?;
229 fs::rename(&tmp, &self.path)
230 .with_context(|| format!("write state: {}", self.path.display()))?;
231 Ok(())
232 }
233}
234
235#[derive(Debug)]
236struct PostgresBackend {
237 url: String,
238 key: String,
239 tls_mode: PostgresTlsMode,
240 loaded_version: Option<i32>,
241 table_ensured: bool,
242}
243
244#[async_trait::async_trait]
245impl StateBackend for PostgresBackend {
246 async fn load(&mut self) -> Result<StateData> {
247 let client = self.connect().await?;
248
249 let row = client
250 .query_opt(
251 "SELECT payload::text, loaded_version FROM alembic_state WHERE state_key = $1",
252 &[&self.key],
253 )
254 .await
255 .with_context(|| "load postgres state payload")?;
256
257 let Some(row) = row else {
258 self.loaded_version = Some(0);
259 return Ok(StateData::default());
260 };
261
262 self.loaded_version = Some(row.try_get::<_, i32>("loaded_version")?);
263
264 let raw: String = row
265 .try_get(0)
266 .with_context(|| "decode postgres state payload")?;
267 serde_json::from_str::<StateData>(&raw).with_context(|| "parse postgres state payload")
268 }
269
270 async fn save(&mut self, data: &StateData) -> Result<()> {
271 let client = self.connect().await?;
272 let Some(loaded_version) = self.loaded_version else {
273 return Err(anyhow!("must load state before saving"));
274 };
275
276 let payload = serde_json::to_string(data)?;
277 let rows_modified = client
278 .execute(
279 "INSERT INTO alembic_state (state_key, payload, updated_at)
280 VALUES ($1, CAST($2 AS TEXT)::jsonb, NOW())
281 ON CONFLICT (state_key)
282 DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW(), loaded_version = $3+1
283 WHERE alembic_state.loaded_version = $3",
284 &[&self.key, &payload, &loaded_version],
285 )
286 .await
287 .with_context(|| "save postgres state payload")?;
288
289 if rows_modified == 0 {
290 return Err(anyhow!(
291 "failed to save postgres state for key '{}': optimistic lock failed (expected loaded_version={})",
292 self.key,
293 loaded_version
294 ));
295 }
296
297 self.loaded_version = Some(loaded_version + 1);
298
299 Ok(())
300 }
301}
302
303impl PostgresBackend {
304 async fn ensure_table(&mut self, client: &Client) -> Result<()> {
305 if self.table_ensured {
306 return Ok(());
307 }
308
309 client
310 .execute(
311 "CREATE TABLE IF NOT EXISTS alembic_state (\
312 state_key TEXT PRIMARY KEY, \
313 payload JSONB NOT NULL, \
314 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
315 loaded_version INTEGER NOT NULL DEFAULT 1\
316 )",
317 &[],
318 )
319 .await
320 .with_context(|| "ensure postgres alembic_state table exists")?;
321
322 client
324 .execute(
325 "ALTER TABLE alembic_state ADD COLUMN IF NOT EXISTS loaded_version INTEGER NOT NULL DEFAULT 1",
326 &[],
327 )
328 .await
329 .with_context(|| "ensure postgres alembic_state.loaded_version column exists")?;
330
331 self.table_ensured = true;
332 Ok(())
333 }
334
335 async fn connect(&mut self) -> Result<tokio_postgres::Client> {
336 match self.tls_mode {
337 PostgresTlsMode::Disable => {
338 let (client, connection) =
339 tokio_postgres::connect(&self.url, tokio_postgres::NoTls)
340 .await
341 .with_context(|| "connect postgres state backend")?;
342 tokio::spawn(async move {
343 if let Err(err) = connection.await {
344 tracing::warn!("postgres state backend connection error: {err}");
345 }
346 });
347 self.ensure_table(&client).await?;
348 Ok(client)
349 }
350 PostgresTlsMode::Require => {
351 let connector = native_tls::TlsConnector::builder()
352 .build()
353 .with_context(|| "build postgres TLS connector")?;
354 let connector = postgres_native_tls::MakeTlsConnector::new(connector);
355 let (client, connection) = tokio_postgres::connect(&self.url, connector)
356 .await
357 .with_context(|| "connect postgres state backend")?;
358 tokio::spawn(async move {
359 if let Err(err) = connection.await {
360 tracing::warn!("postgres state backend connection error: {err}");
361 }
362 });
363 self.ensure_table(&client).await?;
364 Ok(client)
365 }
366 }
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use tempfile::TempDir;
374
375 fn t(s: &str) -> TypeName {
376 TypeName::new(s)
377 }
378
379 fn uid(n: u128) -> Uid {
380 Uid::from_u128(n)
381 }
382
383 #[test]
384 fn backend_id_returns_none_for_missing_type() {
385 let store = StateStore::new(None, StateData::default());
386 assert_eq!(store.backend_id(t("site"), uid(1)), None);
387 }
388
389 #[test]
390 fn backend_id_returns_none_for_missing_uid() {
391 let mut data = StateData::default();
392 data.mappings
393 .entry(t("site"))
394 .or_default()
395 .insert(uid(1), BackendId::Int(42));
396 let store = StateStore::new(None, data);
397 assert_eq!(store.backend_id(t("site"), uid(2)), None);
398 }
399
400 #[test]
401 fn backend_id_returns_value_for_existing_mapping() {
402 let mut data = StateData::default();
403 data.mappings
404 .entry(t("site"))
405 .or_default()
406 .insert(uid(1), BackendId::Int(42));
407 let store = StateStore::new(None, data);
408 assert_eq!(
409 store.backend_id(t("site"), uid(1)),
410 Some(BackendId::Int(42))
411 );
412 }
413
414 #[test]
415 fn set_backend_id_creates_mapping() {
416 let mut store = StateStore::new(None, StateData::default());
417 store.set_backend_id(t("site"), uid(1), BackendId::Int(42));
418 assert_eq!(
419 store.backend_id(t("site"), uid(1)),
420 Some(BackendId::Int(42))
421 );
422 }
423
424 #[test]
425 fn set_backend_id_overwrites_existing() {
426 let mut data = StateData::default();
427 data.mappings
428 .entry(t("site"))
429 .or_default()
430 .insert(uid(1), BackendId::Int(42));
431 let mut store = StateStore::new(None, data);
432 store.set_backend_id(t("site"), uid(1), BackendId::Int(99));
433 assert_eq!(
434 store.backend_id(t("site"), uid(1)),
435 Some(BackendId::Int(99))
436 );
437 }
438
439 #[test]
440 fn remove_backend_id_removes_mapping() {
441 let mut data = StateData::default();
442 data.mappings
443 .entry(t("site"))
444 .or_default()
445 .insert(uid(1), BackendId::Int(42));
446 let mut store = StateStore::new(None, data);
447 store.remove_backend_id(t("site"), uid(1));
448 assert_eq!(store.backend_id(t("site"), uid(1)), None);
449 }
450
451 #[test]
452 fn remove_backend_id_noop_for_missing() {
453 let mut store = StateStore::new(None, StateData::default());
454 store.remove_backend_id(t("site"), uid(1));
455 }
457
458 #[test]
459 fn journal_dir_defaults_to_none_and_round_trips() {
460 let store = StateStore::new(None, StateData::default());
461 assert_eq!(store.journal_dir(), None);
462
463 let dir = PathBuf::from("/tmp/alembic-journal");
464 let store = store.with_journal_dir(dir.clone());
465 assert_eq!(store.journal_dir(), Some(dir.as_path()));
466 }
467
468 #[tokio::test]
469 async fn local_backend_load_missing_returns_empty() {
470 let dir = TempDir::new().unwrap();
471 let mut backend = LocalBackend {
472 path: dir.path().join("nope.json"),
473 };
474 let data = backend.load().await.unwrap();
475 assert!(data.mappings.is_empty());
476 }
477
478 #[tokio::test]
479 async fn local_backend_save_load_round_trip() {
480 let dir = TempDir::new().unwrap();
481 let path = dir.path().join("sub").join("state.json");
482 let mut backend = LocalBackend { path: path.clone() };
483
484 let mut data = StateData::default();
485 data.mappings
486 .entry(t("site"))
487 .or_default()
488 .insert(uid(10), BackendId::String("site-001".into()));
489
490 backend.save(&data).await.unwrap();
491 assert!(path.exists());
492
493 let loaded = backend.load().await.unwrap();
494 assert_eq!(
495 loaded.mappings[&t("site")][&uid(10)],
496 BackendId::String("site-001".into())
497 );
498 }
499
500 #[tokio::test]
501 async fn store_save_without_backend_is_noop() {
502 let store = StateStore::new(None, StateData::default());
503 store.save_async().await.unwrap();
504 }
505
506 #[tokio::test]
507 async fn store_load_async_without_backend_is_noop() {
508 let mut store = StateStore::new(None, StateData::default());
509 store.set_backend_id(t("x"), uid(1), BackendId::Int(1));
510 store.load_async().await.unwrap();
511 assert_eq!(store.backend_id(t("x"), uid(1)), Some(BackendId::Int(1)));
512 }
513}