1use std::sync::Arc;
29use std::time::Duration;
30
31use assay_domain::events::EngineEventBus;
32use tracing::info;
33
34use crate::config::{BackendConfig, EngineConfig};
35
36#[derive(Debug, Clone)]
41pub struct BuiltinModule {
42 pub name: &'static str,
43 pub version: &'static str,
44 pub default_enabled: bool,
45}
46
47pub fn builtin_modules() -> Vec<BuiltinModule> {
55 #[cfg_attr(not(feature = "vault"), allow(unused_mut))]
56 let mut mods = vec![
57 BuiltinModule {
58 name: "workflow",
59 version: env!("CARGO_PKG_VERSION"),
60 default_enabled: true,
61 },
62 BuiltinModule {
65 name: "auth",
66 version: env!("CARGO_PKG_VERSION"),
67 default_enabled: true,
68 },
69 ];
70 #[cfg(feature = "vault")]
74 mods.push(BuiltinModule {
75 name: "vault",
76 version: env!("CARGO_PKG_VERSION"),
77 default_enabled: true,
78 });
79 mods
80}
81
82const INSTANCE_HEARTBEAT_SECS: u64 = 3;
100#[cfg(feature = "backend-postgres")]
103const INSTANCE_STALE_SECS: f64 = 10.0;
104
105pub enum EngineBoot {
109 #[cfg(feature = "backend-postgres")]
110 Postgres(PgBoot),
111 #[cfg(feature = "backend-sqlite")]
112 Sqlite(SqliteBoot),
113}
114
115#[cfg(feature = "backend-postgres")]
116pub struct PgBoot {
117 pub pool: sqlx::PgPool,
118 pub bus: Arc<dyn EngineEventBus>,
119 pub instance_id: uuid::Uuid,
120 pub modules: Vec<String>,
121}
122
123#[cfg(feature = "backend-sqlite")]
124pub struct SqliteBoot {
125 pub pool: sqlx::SqlitePool,
126 pub bus: Arc<dyn EngineEventBus>,
127 pub instance_id: uuid::Uuid,
128 pub modules: Vec<String>,
129}
130
131impl EngineBoot {
132 pub async fn run(cfg: &EngineConfig) -> anyhow::Result<Self> {
134 match cfg.backend.clone() {
135 #[cfg(feature = "backend-postgres")]
136 BackendConfig::Postgres { url } => {
137 let boot = pg_boot(&url, &cfg.auto_enable_modules).await?;
138 Ok(EngineBoot::Postgres(boot))
139 }
140 #[cfg(feature = "backend-sqlite")]
141 BackendConfig::Sqlite { .. } => {
142 let data_dir = cfg
143 .backend
144 .sqlite_data_dir()
145 .expect("sqlite backend yields data_dir");
146 let boot = sqlite_boot(&data_dir, &cfg.auto_enable_modules).await?;
147 Ok(EngineBoot::Sqlite(boot))
148 }
149 #[allow(unreachable_patterns)]
150 _ => anyhow::bail!("backend not enabled at compile time"),
151 }
152 }
153
154 pub fn modules(&self) -> &[String] {
155 match self {
156 #[cfg(feature = "backend-postgres")]
157 EngineBoot::Postgres(b) => &b.modules,
158 #[cfg(feature = "backend-sqlite")]
159 EngineBoot::Sqlite(b) => &b.modules,
160 }
161 }
162
163 pub fn instance_id(&self) -> uuid::Uuid {
164 match self {
165 #[cfg(feature = "backend-postgres")]
166 EngineBoot::Postgres(b) => b.instance_id,
167 #[cfg(feature = "backend-sqlite")]
168 EngineBoot::Sqlite(b) => b.instance_id,
169 }
170 }
171}
172
173#[cfg(feature = "backend-postgres")]
174async fn pg_boot(url: &str, auto_enable: &[String]) -> anyhow::Result<PgBoot> {
175 use assay_domain::engine::PgEngineSchema;
176 use assay_domain::events::PgEngineEventBus;
177 use sqlx::PgPool;
178
179 info!(target: "assay-engine", "boot: connecting to postgres");
180 let pool = PgPool::connect(url)
181 .await
182 .map_err(|e| anyhow::anyhow!("connect postgres: {e}"))?;
183
184 let schema = PgEngineSchema::new(pool.clone());
185 schema
186 .migrate()
187 .await
188 .map_err(|e| anyhow::anyhow!("engine schema migrate (pg): {e}"))?;
189 record_engine_migration_pg(&pool, "engine", 1).await?;
190
191 let modules = read_or_seed_modules_pg(&schema, auto_enable).await?;
192
193 let mut tx = pool
199 .begin()
200 .await
201 .map_err(|e| anyhow::anyhow!("begin module schema tx: {e}"))?;
202 assay_domain::engine::acquire_schema_lock(&mut tx)
203 .await
204 .map_err(|e| anyhow::anyhow!("acquire schema migration advisory lock: {e}"))?;
205 for name in &modules {
206 let create = format!("CREATE SCHEMA IF NOT EXISTS {name}");
207 sqlx::query(&create)
208 .execute(&mut *tx)
209 .await
210 .map_err(|e| anyhow::anyhow!("create schema {name}: {e}"))?;
211 sqlx::query(
212 "INSERT INTO engine.migrations (module, version)
213 VALUES ($1, $2) ON CONFLICT DO NOTHING",
214 )
215 .bind(name)
216 .bind(1)
217 .execute(&mut *tx)
218 .await
219 .map_err(|e| anyhow::anyhow!("record engine.migrations row {name}/1: {e}"))?;
220 }
221 tx.commit()
222 .await
223 .map_err(|e| anyhow::anyhow!("commit module schema tx: {e}"))?;
224
225 if modules.iter().any(|m| m == "auth") {
229 assay_auth::schema::migrate_postgres(&pool)
230 .await
231 .map_err(|e| anyhow::anyhow!("auth schema migrate (pg): {e}"))?;
232 let _ = assay_auth::biscuit::load_or_init_postgres(&pool)
233 .await
234 .map_err(|e| anyhow::anyhow!("biscuit root key bootstrap (pg): {e}"))?;
235 sqlx::query("SELECT COUNT(*) FROM auth.oidc_clients")
236 .fetch_one(&pool)
237 .await
238 .map_err(|e| anyhow::anyhow!("oidc provider tables (pg): {e}"))?;
239 }
240
241 #[cfg(feature = "vault")]
244 if modules.iter().any(|m| m == "vault") {
245 assay_vault::schema::migrate_postgres(&pool)
246 .await
247 .map_err(|e| anyhow::anyhow!("vault schema migrate (pg): {e}"))?;
248 sqlx::query("SELECT COUNT(*) FROM vault.kv_meta")
249 .fetch_one(&pool)
250 .await
251 .map_err(|e| anyhow::anyhow!("vault tables (pg): {e}"))?;
252 }
253
254 let bus: Arc<dyn EngineEventBus> = Arc::new(
255 PgEngineEventBus::new(pool.clone(), url)
256 .await
257 .map_err(|e| anyhow::anyhow!("engine-events bus (pg): {e}"))?,
258 );
259
260 let instance_id = schema
261 .register_instance(&modules, Some(env!("CARGO_PKG_VERSION")))
262 .await
263 .map_err(|e| anyhow::anyhow!("register engine.instances row: {e}"))?;
264 spawn_pg_instance_lifecycle(pool.clone(), instance_id);
265
266 info!(target: "assay-engine", instance = %instance_id, modules = ?modules, "boot complete (pg)");
267 Ok(PgBoot {
268 pool,
269 bus,
270 instance_id,
271 modules,
272 })
273}
274
275#[cfg(feature = "backend-postgres")]
276async fn read_or_seed_modules_pg(
277 schema: &assay_domain::engine::PgEngineSchema,
278 auto_enable: &[String],
279) -> anyhow::Result<Vec<String>> {
280 let existing = schema
281 .list_modules()
282 .await
283 .map_err(|e| anyhow::anyhow!("list engine.modules (pg): {e}"))?;
284 let known: std::collections::HashSet<String> =
285 existing.iter().map(|m| m.name.clone()).collect();
286
287 for module in builtin_modules() {
293 if known.contains(module.name) {
294 continue;
295 }
296 let enabled = module.default_enabled || auto_enable.iter().any(|n| n == module.name);
297 schema
298 .upsert_module(module.name, Some(module.version), enabled)
299 .await
300 .map_err(|e| anyhow::anyhow!("seed engine.modules row {}: {e}", module.name))?;
301 }
302
303 let final_list = schema
304 .list_modules()
305 .await
306 .map_err(|e| anyhow::anyhow!("re-list engine.modules (pg): {e}"))?;
307 Ok(final_list
308 .into_iter()
309 .filter(|m| m.enabled)
310 .map(|m| m.name)
311 .collect())
312}
313
314#[cfg(feature = "backend-postgres")]
315async fn record_engine_migration_pg(
316 pool: &sqlx::PgPool,
317 module: &str,
318 version: i32,
319) -> anyhow::Result<()> {
320 sqlx::query(
321 "INSERT INTO engine.migrations (module, version)
322 VALUES ($1, $2) ON CONFLICT DO NOTHING",
323 )
324 .bind(module)
325 .bind(version)
326 .execute(pool)
327 .await
328 .map_err(|e| anyhow::anyhow!("record engine.migrations row {module}/{version}: {e}"))?;
329 Ok(())
330}
331
332#[cfg(feature = "backend-postgres")]
333fn spawn_pg_instance_lifecycle(pool: sqlx::PgPool, id: uuid::Uuid) {
334 use assay_domain::engine::PgEngineSchema;
335 let schema = PgEngineSchema::new(pool.clone());
336 tokio::spawn(async move {
337 let mut tick = tokio::time::interval(Duration::from_secs(INSTANCE_HEARTBEAT_SECS));
338 loop {
339 tick.tick().await;
340 if let Err(e) = schema.heartbeat_instance(id).await {
341 tracing::warn!(?e, %id, "engine.instances heartbeat failed");
342 }
343 let cutoff_sql = format!(
346 "DELETE FROM engine.instances
347 WHERE last_heartbeat < EXTRACT(EPOCH FROM NOW()) - {INSTANCE_STALE_SECS}"
348 );
349 if let Err(e) = sqlx::query(&cutoff_sql).execute(&pool).await {
350 tracing::debug!(?e, "engine.instances stale cleanup failed");
351 }
352 }
353 });
354}
355
356#[cfg(feature = "backend-sqlite")]
357async fn sqlite_boot(data_dir: &str, auto_enable: &[String]) -> anyhow::Result<SqliteBoot> {
358 use assay_domain::engine::SqliteEngineSchema;
359 use assay_domain::events::SqliteEngineEventBus;
360
361 let pool = sqlite_pool(data_dir).await?;
362
363 let schema = SqliteEngineSchema::new(pool.clone());
364 schema
365 .migrate()
366 .await
367 .map_err(|e| anyhow::anyhow!("engine schema migrate (sqlite): {e}"))?;
368 record_engine_migration_sqlite(&pool, "engine", 1).await?;
369
370 let modules = read_or_seed_modules_sqlite(&schema, auto_enable).await?;
371 for name in &modules {
372 record_engine_migration_sqlite(&pool, name, 1).await?;
373 }
374
375 if modules.iter().any(|m| m == "auth") {
377 assay_auth::schema::migrate_sqlite(&pool)
378 .await
379 .map_err(|e| anyhow::anyhow!("auth schema migrate (sqlite): {e}"))?;
380 let _ = assay_auth::biscuit::load_or_init_sqlite(&pool)
381 .await
382 .map_err(|e| anyhow::anyhow!("biscuit root key bootstrap (sqlite): {e}"))?;
383 sqlx::query("SELECT COUNT(*) FROM auth.oidc_clients")
384 .fetch_one(&pool)
385 .await
386 .map_err(|e| anyhow::anyhow!("oidc provider tables (sqlite): {e}"))?;
387 }
388
389 #[cfg(feature = "vault")]
391 if modules.iter().any(|m| m == "vault") {
392 assay_vault::schema::migrate_sqlite(&pool)
393 .await
394 .map_err(|e| anyhow::anyhow!("vault schema migrate (sqlite): {e}"))?;
395 sqlx::query("SELECT COUNT(*) FROM vault.kv_meta")
396 .fetch_one(&pool)
397 .await
398 .map_err(|e| anyhow::anyhow!("vault tables (sqlite): {e}"))?;
399 }
400
401 let bus: Arc<dyn EngineEventBus> = Arc::new(
402 SqliteEngineEventBus::new(pool.clone())
403 .await
404 .map_err(|e| anyhow::anyhow!("engine-events bus (sqlite): {e}"))?,
405 );
406
407 let instance_id = schema
408 .register_instance(&modules, Some(env!("CARGO_PKG_VERSION")))
409 .await
410 .map_err(|e| anyhow::anyhow!("register engine.instances row: {e}"))?;
411 spawn_sqlite_instance_lifecycle(pool.clone(), instance_id);
412
413 info!(target: "assay-engine", instance = %instance_id, modules = ?modules, "boot complete (sqlite)");
414 Ok(SqliteBoot {
415 pool,
416 bus,
417 instance_id,
418 modules,
419 })
420}
421
422#[cfg(feature = "backend-sqlite")]
426pub(crate) async fn sqlite_pool(data_dir: &str) -> anyhow::Result<sqlx::SqlitePool> {
427 use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
428 use std::str::FromStr;
429
430 let in_memory = data_dir == ":memory:";
431 if !in_memory {
432 std::fs::create_dir_all(data_dir)
433 .map_err(|e| anyhow::anyhow!("create data_dir {data_dir}: {e}"))?;
434 }
435
436 let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.create_if_missing(true);
437 let attachments: Vec<(&str, String)> = SQLITE_MODULE_DBS
438 .iter()
439 .map(|m| (*m, sqlite_attach_uri(data_dir, m, in_memory)))
440 .collect();
441
442 info!(
443 target: "assay-engine",
444 data_dir = %data_dir,
445 modules = ?SQLITE_MODULE_DBS,
446 "opening sqlite engine pool"
447 );
448
449 SqlitePoolOptions::new()
450 .max_connections(1)
451 .after_connect(move |conn, _meta| {
452 let attachments = attachments.clone();
453 Box::pin(async move {
454 use sqlx::Executor;
455 for (name, uri) in attachments {
456 conn.execute(format!("ATTACH DATABASE '{uri}' AS {name}").as_str())
457 .await?;
458 }
459 Ok(())
460 })
461 })
462 .connect_with(opts)
463 .await
464 .map_err(|e| anyhow::anyhow!("connect sqlite: {e}"))
465}
466
467#[cfg(feature = "backend-sqlite")]
469pub(crate) const SQLITE_MODULE_DBS: &[&str] = &[
470 "engine",
471 "workflow",
472 "auth",
473 #[cfg(feature = "vault")]
474 "vault",
475];
476
477#[cfg(feature = "backend-sqlite")]
478fn sqlite_attach_uri(data_dir: &str, module: &str, in_memory: bool) -> String {
479 if in_memory {
480 use std::sync::atomic::{AtomicU64, Ordering};
485 static SEQ: AtomicU64 = AtomicU64::new(0);
486 let suffix = format!(
487 "{}_{}",
488 std::process::id(),
489 SEQ.fetch_add(1, Ordering::Relaxed)
490 );
491 format!("file:assay_{module}_{suffix}?mode=memory&cache=shared")
492 } else {
493 format!("file:{data_dir}/{module}.db?mode=rwc")
494 }
495}
496
497#[cfg(feature = "backend-sqlite")]
498async fn read_or_seed_modules_sqlite(
499 schema: &assay_domain::engine::SqliteEngineSchema,
500 auto_enable: &[String],
501) -> anyhow::Result<Vec<String>> {
502 let existing = schema
503 .list_modules()
504 .await
505 .map_err(|e| anyhow::anyhow!("list engine.modules (sqlite): {e}"))?;
506 let known: std::collections::HashSet<String> =
507 existing.iter().map(|m| m.name.clone()).collect();
508
509 for module in builtin_modules() {
513 if known.contains(module.name) {
514 continue;
515 }
516 let enabled = module.default_enabled || auto_enable.iter().any(|n| n == module.name);
517 schema
518 .upsert_module(module.name, Some(module.version), enabled)
519 .await
520 .map_err(|e| anyhow::anyhow!("seed engine.modules row {}: {e}", module.name))?;
521 }
522
523 let final_list = schema
524 .list_modules()
525 .await
526 .map_err(|e| anyhow::anyhow!("re-list engine.modules (sqlite): {e}"))?;
527 Ok(final_list
528 .into_iter()
529 .filter(|m| m.enabled)
530 .map(|m| m.name)
531 .collect())
532}
533
534#[cfg(feature = "backend-sqlite")]
535async fn record_engine_migration_sqlite(
536 pool: &sqlx::SqlitePool,
537 module: &str,
538 version: i32,
539) -> anyhow::Result<()> {
540 sqlx::query(
541 "INSERT OR IGNORE INTO engine.migrations (module, version)
542 VALUES (?, ?)",
543 )
544 .bind(module)
545 .bind(version)
546 .execute(pool)
547 .await
548 .map_err(|e| anyhow::anyhow!("record engine.migrations row {module}/{version}: {e}"))?;
549 Ok(())
550}
551
552#[cfg(feature = "backend-sqlite")]
553fn spawn_sqlite_instance_lifecycle(pool: sqlx::SqlitePool, id: uuid::Uuid) {
554 use assay_domain::engine::SqliteEngineSchema;
555 let schema = SqliteEngineSchema::new(pool);
556 tokio::spawn(async move {
557 let mut tick = tokio::time::interval(Duration::from_secs(INSTANCE_HEARTBEAT_SECS));
558 loop {
559 tick.tick().await;
560 if let Err(e) = schema.heartbeat_instance(id).await {
561 tracing::warn!(?e, %id, "engine.instances heartbeat failed");
562 }
563 }
564 });
565}
566
567#[cfg(all(test, feature = "backend-sqlite"))]
568mod tests {
569 use super::*;
570
571 #[tokio::test(flavor = "multi_thread")]
576 async fn sqlite_boot_default_runs_auth_migration() {
577 let boot = sqlite_boot(":memory:", &[]).await.expect("boot");
578 assert!(
579 boot.modules.iter().any(|m| m == "auth"),
580 "auth must be in active modules by default; got {:?}",
581 boot.modules
582 );
583 let auth_row: Option<(String,)> =
585 sqlx::query_as("SELECT module FROM engine.migrations WHERE module = 'auth'")
586 .fetch_optional(&boot.pool)
587 .await
588 .expect("query engine.migrations");
589 assert!(
590 auth_row.is_some(),
591 "engine.migrations should have an auth row after auto-enabled boot"
592 );
593 let user_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM auth.users")
596 .fetch_one(&boot.pool)
597 .await
598 .expect("count auth.users");
599 assert_eq!(user_count.0, 0);
600 }
601}