mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
Add multi-database data layer
Introduce aether-data-schema and driver-specific schema generation for Postgres, MySQL, and SQLite. Split data backends, lifecycle, repositories, and gateway runtime integration across database drivers. Verified with cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace.
This commit is contained in:
1340
crates/aether-data/src/lifecycle/backfill.rs
Normal file
1340
crates/aether-data/src/lifecycle/backfill.rs
Normal file
File diff suppressed because it is too large
Load Diff
7
crates/aether-data/src/lifecycle/bootstrap/mod.rs
Normal file
7
crates/aether-data/src/lifecycle/bootstrap/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Empty-database bootstrap workflows.
|
||||
//!
|
||||
//! Bootstrap is separate from migration execution: it prepares a fresh database
|
||||
//! from a squashed snapshot, then stamps the migrations covered by that
|
||||
//! snapshot so normal migration runners only apply later changes.
|
||||
|
||||
pub(crate) mod postgres;
|
||||
128
crates/aether-data/src/lifecycle/bootstrap/postgres.rs
Normal file
128
crates/aether-data/src/lifecycle/bootstrap/postgres.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use sqlx::{
|
||||
migrate::{Migrate, MigrateError, Migration, Migrator},
|
||||
query, query_scalar, Connection, PgConnection,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
// Generated by build.rs from schema/bootstrap/postgres.
|
||||
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
|
||||
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
|
||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260502000000;
|
||||
|
||||
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
||||
SELECT COUNT(*)::BIGINT
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
AND table_name <> '_sqlx_migrations'
|
||||
"#;
|
||||
const AETHER_SCHEMA_FOOTPRINT_TABLE_COUNT_SQL: &str = r#"
|
||||
SELECT COUNT(*)::BIGINT
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
AND table_name IN (
|
||||
'api_key_provider_mappings',
|
||||
'auth_modules',
|
||||
'gemini_file_mappings',
|
||||
'global_models',
|
||||
'oauth_providers',
|
||||
'provider_api_keys',
|
||||
'proxy_nodes',
|
||||
'usage_routing_snapshots',
|
||||
'usage_settlement_snapshots'
|
||||
)
|
||||
"#;
|
||||
const INSERT_APPLIED_MIGRATION_SQL: &str = r#"
|
||||
INSERT INTO _sqlx_migrations (
|
||||
version,
|
||||
description,
|
||||
success,
|
||||
checksum,
|
||||
execution_time
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
TRUE,
|
||||
$3,
|
||||
0
|
||||
)
|
||||
ON CONFLICT (version) DO NOTHING
|
||||
"#;
|
||||
|
||||
pub(crate) async fn apply_snapshot_if_empty(
|
||||
conn: &mut PgConnection,
|
||||
migrator: &'static Migrator,
|
||||
) -> Result<(), MigrateError> {
|
||||
if !should_apply_snapshot(conn).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let migrations = snapshot_migrations(migrator)?;
|
||||
info!(
|
||||
cutoff_version = EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION,
|
||||
stamped_migrations = migrations.len(),
|
||||
"bootstrapping empty database from empty_database_snapshot"
|
||||
);
|
||||
|
||||
let mut tx = conn.begin().await?;
|
||||
sqlx::raw_sql(EMPTY_DATABASE_SNAPSHOT_SQL)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for migration in migrations {
|
||||
query(INSERT_APPLIED_MIGRATION_SQL)
|
||||
.bind(migration.version)
|
||||
.bind(migration.description.as_ref())
|
||||
.bind(migration.checksum.as_ref())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn should_apply_snapshot(conn: &mut PgConnection) -> Result<bool, MigrateError> {
|
||||
let applied_migrations = conn.list_applied_migrations().await?;
|
||||
if !applied_migrations.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let public_table_count: i64 = query_scalar(PUBLIC_BASE_TABLE_COUNT_SQL)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
if public_table_count == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let aether_footprint_table_count: i64 = query_scalar(AETHER_SCHEMA_FOOTPRINT_TABLE_COUNT_SQL)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
if aether_footprint_table_count == 0 {
|
||||
info!(
|
||||
public_table_count,
|
||||
"no Aether schema footprint detected; allowing empty database snapshot bootstrap despite pre-existing public tables"
|
||||
);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot_migrations(
|
||||
migrator: &'static Migrator,
|
||||
) -> Result<Vec<&'static Migration>, MigrateError> {
|
||||
let migrations = migrator
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.filter(|migration| migration.version <= EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if migrations.is_empty() {
|
||||
return Err(MigrateError::Source(Box::new(std::io::Error::other(
|
||||
"empty database snapshot cutoff does not match any embedded migrations",
|
||||
))));
|
||||
}
|
||||
|
||||
Ok(migrations)
|
||||
}
|
||||
2129
crates/aether-data/src/lifecycle/export.rs
Normal file
2129
crates/aether-data/src/lifecycle/export.rs
Normal file
File diff suppressed because it is too large
Load Diff
329
crates/aether-data/src/lifecycle/migrate.rs
Normal file
329
crates/aether-data/src/lifecycle/migrate.rs
Normal file
@@ -0,0 +1,329 @@
|
||||
//! Runtime database migration entry points.
|
||||
//!
|
||||
//! Postgres uses the empty-database snapshot bootstrap before checking normal
|
||||
//! migrations. The snapshot logic lives under `lifecycle::bootstrap` so this
|
||||
//! module remains focused on migration execution and pending-migration
|
||||
//! reporting.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use sqlx::{
|
||||
migrate::{Migrate, MigrateError, Migrator},
|
||||
query_scalar, PgConnection, PgPool,
|
||||
};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
mod mysql;
|
||||
mod sqlite;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
static POSTGRES_MIGRATOR: Migrator = sqlx::migrate!("./migrations/postgres");
|
||||
const MIGRATIONS_TABLE_EXISTS_SQL: &str =
|
||||
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingMigrationInfo {
|
||||
pub version: i64,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Run all pending Postgres migrations embedded at compile time from `migrations/postgres/`.
|
||||
pub async fn run_migrations(pool: &PgPool) -> Result<(), MigrateError> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
conn.lock().await?;
|
||||
}
|
||||
|
||||
let result = run_migrations_locked(&mut conn).await;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
match conn.unlock().await {
|
||||
Ok(()) => {}
|
||||
Err(unlock_error) if result.is_ok() => return Err(unlock_error),
|
||||
Err(unlock_error) => {
|
||||
warn!(
|
||||
error = %unlock_error,
|
||||
"database migration lock release failed after migration error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn pending_migrations(pool: &PgPool) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
pending_migrations_locked(&mut conn).await
|
||||
}
|
||||
|
||||
pub async fn run_mysql_migrations(pool: &sqlx::MySqlPool) -> Result<(), MigrateError> {
|
||||
mysql::run_migrations(pool).await
|
||||
}
|
||||
|
||||
pub async fn pending_mysql_migrations(
|
||||
pool: &sqlx::MySqlPool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
mysql::pending_migrations(pool).await
|
||||
}
|
||||
|
||||
pub async fn prepare_mysql_database_for_startup(
|
||||
pool: &sqlx::MySqlPool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
mysql::prepare_database_for_startup(pool).await
|
||||
}
|
||||
|
||||
pub async fn run_sqlite_migrations(pool: &sqlx::SqlitePool) -> Result<(), MigrateError> {
|
||||
sqlite::run_migrations(pool).await
|
||||
}
|
||||
|
||||
pub async fn pending_sqlite_migrations(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
sqlite::pending_migrations(pool).await
|
||||
}
|
||||
|
||||
pub async fn prepare_sqlite_database_for_startup(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
sqlite::prepare_database_for_startup(pool).await
|
||||
}
|
||||
|
||||
pub async fn prepare_database_for_startup(
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
conn.lock().await?;
|
||||
}
|
||||
|
||||
let result = prepare_database_for_startup_locked(&mut conn).await;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
match conn.unlock().await {
|
||||
Ok(()) => {}
|
||||
Err(unlock_error) if result.is_ok() => return Err(unlock_error),
|
||||
Err(unlock_error) => {
|
||||
warn!(
|
||||
error = %unlock_error,
|
||||
"database migration lock release failed after startup preparation error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn is_missing_sqlx_migrations_table_error(err: &MigrateError) -> bool {
|
||||
let message = err.to_string().to_ascii_lowercase();
|
||||
message.contains("_sqlx_migrations")
|
||||
&& (message.contains("no such table")
|
||||
|| message.contains("doesn't exist")
|
||||
|| message.contains("does not exist")
|
||||
|| message.contains("unknown table"))
|
||||
}
|
||||
|
||||
async fn run_migrations_locked(conn: &mut PgConnection) -> Result<(), MigrateError> {
|
||||
conn.ensure_migrations_table().await?;
|
||||
crate::lifecycle::bootstrap::postgres::apply_snapshot_if_empty(conn, &POSTGRES_MIGRATOR)
|
||||
.await?;
|
||||
|
||||
if let Some(version) = conn.dirty_version().await? {
|
||||
error!(version, "database migration state is dirty");
|
||||
return Err(MigrateError::Dirty(version));
|
||||
}
|
||||
|
||||
let applied_migrations = conn.list_applied_migrations().await?;
|
||||
validate_applied_migrations(&applied_migrations)?;
|
||||
|
||||
let known_versions: HashSet<_> = POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.map(|migration| migration.version)
|
||||
.collect();
|
||||
let applied_migrations_by_version: HashMap<_, _> = applied_migrations
|
||||
.into_iter()
|
||||
.map(|migration| (migration.version, migration))
|
||||
.collect();
|
||||
|
||||
let pending_migrations: Vec<_> = POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.filter(|migration| !applied_migrations_by_version.contains_key(&migration.version))
|
||||
.collect();
|
||||
|
||||
let total_migrations = known_versions.len();
|
||||
let applied_count = total_migrations.saturating_sub(pending_migrations.len());
|
||||
|
||||
if pending_migrations.is_empty() {
|
||||
info!(
|
||||
total_migrations,
|
||||
applied_migrations = applied_count,
|
||||
pending_migrations = 0,
|
||||
"database migrations already up to date"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
total_migrations,
|
||||
applied_migrations = applied_count,
|
||||
pending_migrations = pending_migrations.len(),
|
||||
"database migrations pending"
|
||||
);
|
||||
|
||||
for (index, migration) in pending_migrations.iter().enumerate() {
|
||||
let current = index + 1;
|
||||
let total = pending_migrations.len();
|
||||
|
||||
info!(
|
||||
current,
|
||||
total,
|
||||
version = migration.version,
|
||||
description = %migration.description,
|
||||
"applying database migration"
|
||||
);
|
||||
|
||||
let elapsed = conn.apply(migration).await?;
|
||||
|
||||
info!(
|
||||
current,
|
||||
total,
|
||||
version = migration.version,
|
||||
description = %migration.description,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
"applied database migration"
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
total_migrations,
|
||||
applied_migrations = total_migrations,
|
||||
pending_migrations = 0,
|
||||
"database migrations complete"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prepare_database_for_startup_locked(
|
||||
conn: &mut PgConnection,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
conn.ensure_migrations_table().await?;
|
||||
crate::lifecycle::bootstrap::postgres::apply_snapshot_if_empty(conn, &POSTGRES_MIGRATOR)
|
||||
.await?;
|
||||
pending_migrations_locked(conn).await
|
||||
}
|
||||
|
||||
async fn pending_migrations_locked(
|
||||
conn: &mut PgConnection,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
if !migrations_table_exists(conn).await? {
|
||||
return Ok(all_up_migrations());
|
||||
}
|
||||
|
||||
if let Some(version) = conn.dirty_version().await? {
|
||||
error!(version, "database migration state is dirty");
|
||||
return Err(MigrateError::Dirty(version));
|
||||
}
|
||||
|
||||
let applied_migrations = conn.list_applied_migrations().await?;
|
||||
validate_applied_migrations(&applied_migrations)?;
|
||||
Ok(pending_migrations_from_applied(&applied_migrations))
|
||||
}
|
||||
|
||||
async fn migrations_table_exists(conn: &mut PgConnection) -> Result<bool, MigrateError> {
|
||||
let exists: bool = query_scalar(MIGRATIONS_TABLE_EXISTS_SQL)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
fn all_up_migrations() -> Vec<PendingMigrationInfo> {
|
||||
POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.map(|migration| PendingMigrationInfo {
|
||||
version: migration.version,
|
||||
description: migration.description.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pending_migrations_from_applied(
|
||||
applied_migrations: &[sqlx::migrate::AppliedMigration],
|
||||
) -> Vec<PendingMigrationInfo> {
|
||||
pending_migrations_from_applied_for(&POSTGRES_MIGRATOR, applied_migrations)
|
||||
}
|
||||
|
||||
fn pending_migrations_from_applied_for(
|
||||
migrator: &'static Migrator,
|
||||
applied_migrations: &[sqlx::migrate::AppliedMigration],
|
||||
) -> Vec<PendingMigrationInfo> {
|
||||
let applied_versions: HashSet<_> = applied_migrations
|
||||
.iter()
|
||||
.map(|migration| migration.version)
|
||||
.collect();
|
||||
|
||||
migrator
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.filter(|migration| !applied_versions.contains(&migration.version))
|
||||
.map(|migration| PendingMigrationInfo {
|
||||
version: migration.version,
|
||||
description: migration.description.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_applied_migrations(
|
||||
applied_migrations: &[sqlx::migrate::AppliedMigration],
|
||||
) -> Result<(), MigrateError> {
|
||||
if POSTGRES_MIGRATOR.ignore_missing {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let known_versions: HashSet<_> = POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.map(|migration| migration.version)
|
||||
.collect();
|
||||
|
||||
for applied_migration in applied_migrations {
|
||||
if !known_versions.contains(&applied_migration.version) {
|
||||
error!(
|
||||
version = applied_migration.version,
|
||||
"applied database migration is missing from embedded migrations"
|
||||
);
|
||||
return Err(MigrateError::VersionMissing(applied_migration.version));
|
||||
}
|
||||
}
|
||||
|
||||
// Checksum drift is reported as a warning only. Strict enforcement makes
|
||||
// harmless edits (comment tweaks, whitespace, metadata fixes) impossible
|
||||
// without also touching every environment's migration history table. We
|
||||
// match by version alone — sqlx still skips already-applied migrations so
|
||||
// edited files will not re-run, they are merely allowed to exist.
|
||||
for migration in POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
{
|
||||
if let Some(applied_migration) = applied_migrations
|
||||
.iter()
|
||||
.find(|applied_migration| applied_migration.version == migration.version)
|
||||
{
|
||||
if migration.checksum != applied_migration.checksum {
|
||||
warn!(
|
||||
version = migration.version,
|
||||
description = %migration.description,
|
||||
"database migration checksum mismatch (ignored: version-only validation)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
36
crates/aether-data/src/lifecycle/migrate/mysql.rs
Normal file
36
crates/aether-data/src/lifecycle/migrate/mysql.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sqlx::{
|
||||
migrate::{Migrate, MigrateError, Migrator},
|
||||
MySqlPool,
|
||||
};
|
||||
|
||||
use super::{
|
||||
is_missing_sqlx_migrations_table_error, pending_migrations_from_applied_for,
|
||||
PendingMigrationInfo,
|
||||
};
|
||||
|
||||
pub(super) static MIGRATOR: Migrator = sqlx::migrate!("./migrations/mysql");
|
||||
|
||||
pub async fn run_migrations(pool: &MySqlPool) -> Result<(), MigrateError> {
|
||||
MIGRATOR.run(pool).await
|
||||
}
|
||||
|
||||
pub async fn pending_migrations(
|
||||
pool: &MySqlPool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
let applied_migrations = match conn.list_applied_migrations().await {
|
||||
Ok(applied_migrations) => applied_migrations,
|
||||
Err(err) if is_missing_sqlx_migrations_table_error(&err) => Vec::new(),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
Ok(pending_migrations_from_applied_for(
|
||||
&MIGRATOR,
|
||||
&applied_migrations,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn prepare_database_for_startup(
|
||||
pool: &MySqlPool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
pending_migrations(pool).await
|
||||
}
|
||||
36
crates/aether-data/src/lifecycle/migrate/sqlite.rs
Normal file
36
crates/aether-data/src/lifecycle/migrate/sqlite.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sqlx::{
|
||||
migrate::{Migrate, MigrateError, Migrator},
|
||||
SqlitePool,
|
||||
};
|
||||
|
||||
use super::{
|
||||
is_missing_sqlx_migrations_table_error, pending_migrations_from_applied_for,
|
||||
PendingMigrationInfo,
|
||||
};
|
||||
|
||||
pub(super) static MIGRATOR: Migrator = sqlx::migrate!("./migrations/sqlite");
|
||||
|
||||
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), MigrateError> {
|
||||
MIGRATOR.run(pool).await
|
||||
}
|
||||
|
||||
pub async fn pending_migrations(
|
||||
pool: &SqlitePool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
let applied_migrations = match conn.list_applied_migrations().await {
|
||||
Ok(applied_migrations) => applied_migrations,
|
||||
Err(err) if is_missing_sqlx_migrations_table_error(&err) => Vec::new(),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
Ok(pending_migrations_from_applied_for(
|
||||
&MIGRATOR,
|
||||
&applied_migrations,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn prepare_database_for_startup(
|
||||
pool: &SqlitePool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
pending_migrations(pool).await
|
||||
}
|
||||
1332
crates/aether-data/src/lifecycle/migrate/tests.rs
Normal file
1332
crates/aether-data/src/lifecycle/migrate/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
9
crates/aether-data/src/lifecycle/mod.rs
Normal file
9
crates/aether-data/src/lifecycle/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! Database lifecycle workflows.
|
||||
//!
|
||||
//! Runtime request paths should not depend on this module directly except at
|
||||
//! process startup or explicit maintenance/export commands.
|
||||
|
||||
pub mod backfill;
|
||||
pub(crate) mod bootstrap;
|
||||
pub mod export;
|
||||
pub mod migrate;
|
||||
Reference in New Issue
Block a user