Skip to main content

assay_workflow/store/
sqlite.rs

1use anyhow::Result;
2use sqlx::SqlitePool;
3use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
4
5use crate::store::{NamespaceRecord, NamespaceStats, QueueStats, WorkflowStore};
6use crate::types::*;
7
8/// Workflow-module DDL. v0.1.2 schema-qualifies every table to the
9/// `workflow` schema, which on SQLite is an attached database (one
10/// `workflow.db` file per data dir, attached on connect). On PG the
11/// same DDL targets the `workflow` schema.
12///
13/// `engine.events` and `engine.lock` (engine-core infrastructure) live
14/// in the `engine` attachment; engine-core DDL is owned by
15/// `assay_domain::engine::SqliteEngineSchema`. We still bootstrap them
16/// here for v0.1.2 because the workflow store is the embedder for the
17/// `engine.events` notification outbox and for the SQLite single-instance
18/// lock — both pre-date the engine-core schema and stay co-located on
19/// SQLite to keep `SqliteStore::new(url)` self-sufficient for tests.
20const SCHEMA: &str = r#"
21CREATE TABLE IF NOT EXISTS workflow.namespaces (
22    name            TEXT PRIMARY KEY,
23    created_at      REAL NOT NULL
24);
25
26INSERT OR IGNORE INTO workflow.namespaces (name, created_at)
27    VALUES ('main', strftime('%s', 'now'));
28
29CREATE TABLE IF NOT EXISTS workflow.workflows (
30    id              TEXT PRIMARY KEY,
31    namespace       TEXT NOT NULL DEFAULT 'main',
32    run_id          TEXT NOT NULL,
33    workflow_type   TEXT NOT NULL,
34    task_queue      TEXT NOT NULL DEFAULT 'main',
35    status          TEXT NOT NULL DEFAULT 'PENDING',
36    input           TEXT,
37    result          TEXT,
38    error           TEXT,
39    parent_id       TEXT,
40    claimed_by      TEXT,
41    search_attributes TEXT,
42    archived_at     REAL,
43    archive_uri     TEXT,
44    -- Workflow-task dispatch (Phase 9): a workflow is "dispatchable" when
45    -- it has new events a worker needs to replay against. Set true on
46    -- start, on activity completion, on timer fire, on signal arrival.
47    -- Cleared when a worker claims the dispatch lease.
48    needs_dispatch  INTEGER NOT NULL DEFAULT 0,
49    dispatch_claimed_by    TEXT,
50    dispatch_last_heartbeat REAL,
51    created_at      REAL NOT NULL,
52    updated_at      REAL NOT NULL,
53    completed_at    REAL
54);
55CREATE INDEX IF NOT EXISTS workflow.idx_wf_status_queue ON workflows(status, task_queue);
56CREATE INDEX IF NOT EXISTS workflow.idx_wf_namespace ON workflows(namespace);
57CREATE INDEX IF NOT EXISTS workflow.idx_wf_dispatch ON workflows(task_queue, needs_dispatch, dispatch_claimed_by);
58
59CREATE TABLE IF NOT EXISTS workflow.events (
60    id              INTEGER PRIMARY KEY AUTOINCREMENT,
61    workflow_id     TEXT NOT NULL REFERENCES workflows(id),
62    seq             INTEGER NOT NULL,
63    event_type      TEXT NOT NULL,
64    payload         TEXT,
65    timestamp       REAL NOT NULL
66);
67CREATE INDEX IF NOT EXISTS workflow.idx_wf_events_lookup ON events(workflow_id, seq);
68
69CREATE TABLE IF NOT EXISTS workflow.activities (
70    id              INTEGER PRIMARY KEY AUTOINCREMENT,
71    workflow_id     TEXT NOT NULL REFERENCES workflows(id),
72    seq             INTEGER NOT NULL,
73    name            TEXT NOT NULL,
74    task_queue      TEXT NOT NULL DEFAULT 'main',
75    input           TEXT,
76    status          TEXT NOT NULL DEFAULT 'PENDING',
77    result          TEXT,
78    error           TEXT,
79    attempt         INTEGER NOT NULL DEFAULT 1,
80    max_attempts    INTEGER NOT NULL DEFAULT 3,
81    initial_interval_secs   REAL NOT NULL DEFAULT 1,
82    backoff_coefficient     REAL NOT NULL DEFAULT 2,
83    start_to_close_secs     REAL NOT NULL DEFAULT 300,
84    heartbeat_timeout_secs  REAL,
85    claimed_by      TEXT,
86    scheduled_at    REAL NOT NULL,
87    started_at      REAL,
88    completed_at    REAL,
89    last_heartbeat  REAL,
90    UNIQUE (workflow_id, seq)
91);
92CREATE INDEX IF NOT EXISTS workflow.idx_wf_act_pending ON activities(task_queue, status, scheduled_at);
93
94CREATE TABLE IF NOT EXISTS workflow.timers (
95    id              INTEGER PRIMARY KEY AUTOINCREMENT,
96    workflow_id     TEXT NOT NULL REFERENCES workflows(id),
97    seq             INTEGER NOT NULL,
98    fire_at         REAL NOT NULL,
99    fired           INTEGER NOT NULL DEFAULT 0,
100    UNIQUE (workflow_id, seq)
101);
102CREATE INDEX IF NOT EXISTS workflow.idx_wf_timers_due ON timers(fire_at);
103
104CREATE TABLE IF NOT EXISTS workflow.signals (
105    id              INTEGER PRIMARY KEY AUTOINCREMENT,
106    workflow_id     TEXT NOT NULL REFERENCES workflows(id),
107    name            TEXT NOT NULL,
108    payload         TEXT,
109    consumed        INTEGER NOT NULL DEFAULT 0,
110    received_at     REAL NOT NULL
111);
112CREATE INDEX IF NOT EXISTS workflow.idx_wf_signals_lookup ON signals(workflow_id, name, consumed);
113
114CREATE TABLE IF NOT EXISTS workflow.schedules (
115    name            TEXT NOT NULL,
116    namespace       TEXT NOT NULL DEFAULT 'main',
117    workflow_type   TEXT NOT NULL,
118    cron_expr       TEXT NOT NULL,
119    timezone        TEXT NOT NULL DEFAULT 'UTC',
120    input           TEXT,
121    task_queue      TEXT NOT NULL DEFAULT 'main',
122    overlap_policy  TEXT NOT NULL DEFAULT 'skip',
123    paused          INTEGER NOT NULL DEFAULT 0,
124    last_run_at     REAL,
125    next_run_at     REAL,
126    last_workflow_id TEXT,
127    created_at      REAL NOT NULL,
128    PRIMARY KEY (namespace, name)
129);
130
131CREATE TABLE IF NOT EXISTS workflow.workers (
132    id              TEXT PRIMARY KEY,
133    namespace       TEXT NOT NULL DEFAULT 'main',
134    identity        TEXT NOT NULL,
135    task_queue      TEXT NOT NULL,
136    workflows       TEXT,
137    activities      TEXT,
138    max_concurrent_workflows  INTEGER NOT NULL DEFAULT 10,
139    max_concurrent_activities INTEGER NOT NULL DEFAULT 10,
140    active_tasks    INTEGER NOT NULL DEFAULT 0,
141    last_heartbeat  REAL NOT NULL,
142    registered_at   REAL NOT NULL
143);
144
145CREATE TABLE IF NOT EXISTS workflow.snapshots (
146    workflow_id     TEXT NOT NULL REFERENCES workflows(id),
147    event_seq       INTEGER NOT NULL,
148    state_json      TEXT NOT NULL,
149    created_at      REAL NOT NULL,
150    PRIMARY KEY (workflow_id, event_seq)
151);
152
153-- workflow.api_keys retired in plan-15 slice 3 (auth tokens come from
154-- the auth module).
155DROP TABLE IF EXISTS workflow.api_keys;
156
157CREATE TABLE IF NOT EXISTS engine.lock (
158    id              INTEGER PRIMARY KEY CHECK (id = 1),
159    instance_id     TEXT NOT NULL,
160    started_at      REAL NOT NULL,
161    last_heartbeat  REAL NOT NULL
162);
163
164CREATE TABLE IF NOT EXISTS engine.events (
165    id              INTEGER PRIMARY KEY AUTOINCREMENT,
166    ts              REAL NOT NULL DEFAULT (CAST(strftime('%s','now') AS REAL)),
167    namespace       TEXT NOT NULL,
168    subsystem       TEXT NOT NULL,
169    kind            TEXT NOT NULL,
170    payload         TEXT NOT NULL DEFAULT '{}'
171);
172CREATE INDEX IF NOT EXISTS engine.idx_engine_events_ns_id ON events(namespace, id);
173CREATE INDEX IF NOT EXISTS engine.idx_engine_events_ts_prune ON events(ts);
174"#;
175
176/// Stale lock timeout — if the lock holder hasn't heartbeated in this
177/// many seconds, assume it's dead and allow takeover.
178const LOCK_STALE_SECS: f64 = 60.0;
179/// How often to refresh the lock heartbeat.
180const LOCK_HEARTBEAT_SECS: u64 = 15;
181
182/// `Clone` is derived because the underlying `SqlitePool` is itself
183/// `Clone` (it's `Arc<PoolInner>` internally) — cloning the store hands
184/// back a new wrapper around the same connection pool. The
185/// `instance_id` is per-store identity (heartbeat row tag), shared
186/// across clones so all clones look like the same instance to
187/// `engine.lock`.
188#[derive(Clone)]
189pub struct SqliteStore {
190    pool: SqlitePool,
191    instance_id: String,
192}
193
194/// Build a fresh [`SqlitePool`] with `engine` + `workflow` ATTACHed to
195/// in-memory shared-cache databases (one alias per pool). Each connection
196/// in the pool inherits the same ATTACHed databases via `after_connect`.
197///
198/// This is the test-friendly path for `SqliteStore::new(url)` callers that
199/// pass `sqlite::memory:` or any path-based URL — every connection sees
200/// the same `engine.*` / `workflow.*` data because the shared-cache URI
201/// pins the in-memory DB to a process-global name.
202///
203/// Production embedders (the engine binary) build their own pool with
204/// file-backed ATTACHes (`<data_dir>/engine.db`, `<data_dir>/workflow.db`)
205/// and call [`SqliteStore::from_attached_pool`] instead.
206async fn build_default_pool(url: &str) -> Result<SqlitePool> {
207    use std::str::FromStr;
208    use std::sync::atomic::{AtomicU64, Ordering};
209
210    static SEQ: AtomicU64 = AtomicU64::new(0);
211    let suffix = format!(
212        "{}_{}",
213        std::process::id(),
214        SEQ.fetch_add(1, Ordering::Relaxed)
215    );
216    let engine_alias = format!(
217        "file:assay_engine_{suffix}?mode=memory&cache=shared"
218    );
219    let workflow_alias = format!(
220        "file:assay_workflow_{suffix}?mode=memory&cache=shared"
221    );
222
223    let opts = SqliteConnectOptions::from_str(url)?
224        .create_if_missing(true);
225
226    let pool = SqlitePoolOptions::new()
227        .max_connections(1)
228        .after_connect(move |conn, _meta| {
229            let engine_alias = engine_alias.clone();
230            let workflow_alias = workflow_alias.clone();
231            Box::pin(async move {
232                use sqlx::Executor;
233                conn.execute(
234                    format!("ATTACH DATABASE '{engine_alias}' AS engine").as_str(),
235                )
236                .await?;
237                conn.execute(
238                    format!("ATTACH DATABASE '{workflow_alias}' AS workflow").as_str(),
239                )
240                .await?;
241                Ok(())
242            })
243        })
244        .connect_with(opts)
245        .await?;
246    Ok(pool)
247}
248
249impl SqliteStore {
250    /// Open a SqliteStore at `url`. Provisions an in-memory `engine` +
251    /// `workflow` ATTACH automatically — convenient for tests and
252    /// embedders that don't need persistent module isolation. Production
253    /// deployments use [`SqliteStore::from_attached_pool`] with the
254    /// engine-controlled pool that ATTACHes to `<data_dir>/*.db` files.
255    pub async fn new(url: &str) -> Result<Self> {
256        let pool = build_default_pool(url).await?;
257        Self::from_attached_pool(pool).await
258    }
259
260    /// Construct from an externally-managed pool that already has the
261    /// `engine` and `workflow` databases ATTACHed. The engine binary
262    /// uses this — its pool's `after_connect` hook ATTACHes the
263    /// per-module file paths from `[backend].data_dir`.
264    pub async fn from_attached_pool(pool: SqlitePool) -> Result<Self> {
265        let instance_id = format!("assay-{:016x}", {
266            use std::collections::hash_map::DefaultHasher;
267            use std::hash::{Hash, Hasher};
268            let mut h = DefaultHasher::new();
269            std::time::SystemTime::now().hash(&mut h);
270            std::process::id().hash(&mut h);
271            h.finish()
272        });
273        let store = Self { pool, instance_id };
274        store.migrate().await?;
275        Ok(store)
276    }
277
278    /// Backward-compat alias for [`SqliteStore::from_attached_pool`].
279    /// Older call sites passed a bare pool from `SqlitePool::connect()`;
280    /// after v0.1.2 the pool must already have the engine + workflow
281    /// databases attached. The implementation is identical, kept under
282    /// the legacy name so external embedders don't break on upgrade.
283    pub async fn from_pool(pool: SqlitePool) -> Result<Self> {
284        Self::from_attached_pool(pool).await
285    }
286
287    /// Expose the underlying pool (used by the engine to build an
288    /// `SqliteEngineEventBus` that shares the same connection).
289    pub fn pool(&self) -> &SqlitePool {
290        &self.pool
291    }
292
293    /// Acquire the single-instance engine lock.
294    /// Returns an error if another instance is already running.
295    pub async fn acquire_engine_lock(&self) -> Result<()> {
296        let now = timestamp_now();
297
298        // Try to insert the lock
299        let result = sqlx::query(
300            "INSERT INTO engine.lock (id, instance_id, started_at, last_heartbeat) VALUES (1, ?, ?, ?)",
301        )
302        .bind(&self.instance_id)
303        .bind(now)
304        .bind(now)
305        .execute(&self.pool)
306        .await;
307
308        match result {
309            Ok(_) => Ok(()),
310            Err(_) => {
311                // Lock exists — check if it's stale
312                let row: Option<(String, f64)> = sqlx::query_as(
313                    "SELECT instance_id, last_heartbeat FROM engine.lock WHERE id = 1",
314                )
315                .fetch_optional(&self.pool)
316                .await?;
317
318                if let Some((existing_id, last_hb)) = row {
319                    if now - last_hb > LOCK_STALE_SECS {
320                        // Stale lock — take over
321                        sqlx::query(
322                            "UPDATE engine.lock SET instance_id = ?, started_at = ?, last_heartbeat = ? WHERE id = 1",
323                        )
324                        .bind(&self.instance_id)
325                        .bind(now)
326                        .bind(now)
327                        .execute(&self.pool)
328                        .await?;
329                        tracing::warn!(
330                            "Took over stale engine lock from {existing_id} (last heartbeat {:.0}s ago)",
331                            now - last_hb
332                        );
333                        Ok(())
334                    } else {
335                        let age = now - last_hb;
336                        anyhow::bail!(
337                            "Another assay engine instance is already running (id: {existing_id}, \
338                             last heartbeat {age:.0}s ago).\n\n\
339                             SQLite only supports a single engine instance. For multi-instance \
340                             deployment (Kubernetes, Docker Swarm), use PostgreSQL:\n\n\
341                             \x20 assay serve --backend postgres://user:pass@host:5432/dbname"
342                        );
343                    }
344                } else {
345                    anyhow::bail!("Unexpected engine lock state");
346                }
347            }
348        }
349    }
350
351    /// Refresh the engine lock heartbeat. Called periodically by the engine.
352    pub async fn refresh_engine_lock(&self) -> Result<()> {
353        sqlx::query("UPDATE engine.lock SET last_heartbeat = ? WHERE id = 1 AND instance_id = ?")
354            .bind(timestamp_now())
355            .bind(&self.instance_id)
356            .execute(&self.pool)
357            .await?;
358        Ok(())
359    }
360
361    /// Release the engine lock on shutdown.
362    pub async fn release_engine_lock(&self) -> Result<()> {
363        sqlx::query("DELETE FROM engine.lock WHERE id = 1 AND instance_id = ?")
364            .bind(&self.instance_id)
365            .execute(&self.pool)
366            .await?;
367        Ok(())
368    }
369
370    /// Start background task to keep the lock alive.
371    pub fn spawn_lock_heartbeat(self: &std::sync::Arc<Self>) {
372        let store = std::sync::Arc::clone(self);
373        tokio::spawn(async move {
374            let mut tick = tokio::time::interval(std::time::Duration::from_secs(LOCK_HEARTBEAT_SECS));
375            loop {
376                tick.tick().await;
377                if let Err(e) = store.refresh_engine_lock().await {
378                    tracing::error!("Engine lock heartbeat failed: {e}");
379                }
380            }
381        });
382    }
383
384    /// Apply the baseline schema.
385    ///
386    /// Fresh installs get the current `CREATE TABLE IF NOT EXISTS` statements
387    /// in one pass. The engine is pre-1.0 and no v0.11.x release has been
388    /// deployed against a real workload yet, so we don't carry
389    /// `ALTER TABLE ADD COLUMN` statements for historical columns — the
390    /// baseline is the source of truth.
391    ///
392    /// For **future** additive migrations (post-v0.11.3), call
393    /// `Self::add_column_if_missing(&self.pool, "<table>", "<column>",
394    /// "<type_def>").await?` here before returning. Kept around so adding a
395    /// column later is a one-liner.
396    async fn migrate(&self) -> Result<()> {
397        for statement in SCHEMA.split(';') {
398            let trimmed = statement.trim();
399            if !trimmed.is_empty() {
400                sqlx::query(trimmed).execute(&self.pool).await?;
401            }
402        }
403        // Future additive migrations go here; see doc-comment above.
404        Ok(())
405    }
406
407    /// Add a column to an existing table if it's not already there.
408    ///
409    /// SQLite (unlike Postgres) doesn't support `ADD COLUMN IF NOT EXISTS`,
410    /// so we check via `pragma_table_info` before issuing the ALTER. Each
411    /// call is idempotent across startups.
412    ///
413    /// Currently unused — kept as the documented pattern for the first
414    /// additive migration after v0.11.3. Remove `#[allow(dead_code)]` when
415    /// a caller is added.
416    #[allow(dead_code)]
417    async fn add_column_if_missing(
418        pool: &SqlitePool,
419        table: &str,
420        column: &str,
421        type_def: &str,
422    ) -> Result<()> {
423        let exists: Option<(String,)> =
424            sqlx::query_as("SELECT name FROM pragma_table_info(?) WHERE name = ?")
425                .bind(table)
426                .bind(column)
427                .fetch_optional(pool)
428                .await?;
429        if exists.is_none() {
430            let sql = format!("ALTER TABLE {table} ADD COLUMN {column} {type_def}");
431            sqlx::query(&sql).execute(pool).await?;
432        }
433        Ok(())
434    }
435}
436
437impl WorkflowStore for SqliteStore {
438    // ── Namespaces ─────────────────────────────────────────
439
440    async fn create_namespace(&self, name: &str) -> Result<()> {
441        sqlx::query("INSERT INTO workflow.namespaces (name, created_at) VALUES (?, ?)")
442            .bind(name)
443            .bind(timestamp_now())
444            .execute(&self.pool)
445            .await?;
446        Ok(())
447    }
448
449    async fn list_namespaces(&self) -> Result<Vec<NamespaceRecord>> {
450        let rows = sqlx::query_as::<_, (String, f64)>(
451            "SELECT name, created_at FROM workflow.namespaces ORDER BY name",
452        )
453        .fetch_all(&self.pool)
454        .await?;
455        Ok(rows
456            .into_iter()
457            .map(|(name, created_at)| NamespaceRecord { name, created_at })
458            .collect())
459    }
460
461    async fn delete_namespace(&self, name: &str) -> Result<bool> {
462        // Mirror PG: 'main' is always available, can't be deleted.
463        let res = sqlx::query("DELETE FROM workflow.namespaces WHERE name = ? AND name != 'main'")
464            .bind(name)
465            .execute(&self.pool)
466            .await?;
467        Ok(res.rows_affected() > 0)
468    }
469
470    async fn get_namespace_stats(&self, namespace: &str) -> Result<NamespaceStats> {
471        let total: (i64,) =
472            sqlx::query_as("SELECT COUNT(*) FROM workflow.workflows WHERE namespace = ?")
473                .bind(namespace)
474                .fetch_one(&self.pool)
475                .await?;
476        let running: (i64,) = sqlx::query_as(
477            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = ? AND status = 'RUNNING'",
478        )
479        .bind(namespace)
480        .fetch_one(&self.pool)
481        .await?;
482        let pending: (i64,) = sqlx::query_as(
483            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = ? AND status = 'PENDING'",
484        )
485        .bind(namespace)
486        .fetch_one(&self.pool)
487        .await?;
488        let completed: (i64,) = sqlx::query_as(
489            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = ? AND status = 'COMPLETED'",
490        )
491        .bind(namespace)
492        .fetch_one(&self.pool)
493        .await?;
494        let failed: (i64,) = sqlx::query_as(
495            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = ? AND status = 'FAILED'",
496        )
497        .bind(namespace)
498        .fetch_one(&self.pool)
499        .await?;
500        let schedules: (i64,) =
501            sqlx::query_as("SELECT COUNT(*) FROM workflow.schedules WHERE namespace = ?")
502                .bind(namespace)
503                .fetch_one(&self.pool)
504                .await?;
505        let workers: (i64,) =
506            sqlx::query_as("SELECT COUNT(*) FROM workflow.workers WHERE namespace = ?")
507                .bind(namespace)
508                .fetch_one(&self.pool)
509                .await?;
510
511        Ok(NamespaceStats {
512            namespace: namespace.to_string(),
513            total_workflows: total.0,
514            running: running.0,
515            pending: pending.0,
516            completed: completed.0,
517            failed: failed.0,
518            schedules: schedules.0,
519            workers: workers.0,
520        })
521    }
522
523    // ── Workflows ──────────────────────────────────────────
524
525    async fn create_workflow(&self, wf: &WorkflowRecord) -> Result<()> {
526        sqlx::query(
527            "INSERT INTO workflow.workflows (id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at)
528             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
529        )
530        .bind(&wf.id)
531        .bind(&wf.namespace)
532        .bind(&wf.run_id)
533        .bind(&wf.workflow_type)
534        .bind(&wf.task_queue)
535        .bind(&wf.status)
536        .bind(&wf.input)
537        .bind(&wf.result)
538        .bind(&wf.error)
539        .bind(&wf.parent_id)
540        .bind(&wf.claimed_by)
541        .bind(&wf.search_attributes)
542        .bind(wf.archived_at)
543        .bind(&wf.archive_uri)
544        .bind(wf.created_at)
545        .bind(wf.updated_at)
546        .bind(wf.completed_at)
547        .execute(&self.pool)
548        .await?;
549        Ok(())
550    }
551
552    async fn get_workflow(&self, id: &str) -> Result<Option<WorkflowRecord>> {
553        let row = sqlx::query_as::<_, SqliteWorkflowRow>(
554            "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at FROM workflow.workflows WHERE id = ?",
555        )
556        .bind(id)
557        .fetch_optional(&self.pool)
558        .await?;
559        Ok(row.map(Into::into))
560    }
561
562    async fn list_workflows(
563        &self,
564        namespace: &str,
565        status: Option<WorkflowStatus>,
566        workflow_type: Option<&str>,
567        search_attrs_filter: Option<&str>,
568        limit: i64,
569        offset: i64,
570    ) -> Result<Vec<WorkflowRecord>> {
571        let status_str = status.map(|s| s.to_string());
572
573        // Parse search filter into (key, value) pairs. Each pair adds a
574        // `json_extract(search_attributes, '$.key') = value` predicate so
575        // matches require every filter key to be present in the stored
576        // attributes. Invalid/empty JSON → no filter (all pass).
577        let filter_pairs: Vec<(String, serde_json::Value)> = search_attrs_filter
578            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
579            .and_then(|v| v.as_object().cloned())
580            .map(|m| m.into_iter().collect())
581            .unwrap_or_default();
582
583        let mut sql = String::from(
584            "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at
585             FROM workflow.workflows
586             WHERE namespace = ?
587               AND (? IS NULL OR status = ?)
588               AND (? IS NULL OR workflow_type = ?)",
589        );
590        for _ in &filter_pairs {
591            sql.push_str(" AND json_extract(search_attributes, '$.' || ?) = ?");
592        }
593        sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
594
595        let mut q = sqlx::query_as::<_, SqliteWorkflowRow>(&sql)
596            .bind(namespace)
597            .bind(&status_str)
598            .bind(&status_str)
599            .bind(workflow_type)
600            .bind(workflow_type);
601        for (key, value) in &filter_pairs {
602            q = q.bind(key.clone());
603            // Bind the JSON value as its string/number representation.
604            // json_extract on a stored JSON string returns its "natural"
605            // SQLite type (text for strings, numeric for numbers), so we
606            // match by the same type.
607            match value {
608                serde_json::Value::String(s) => q = q.bind(s.clone()),
609                serde_json::Value::Number(n) => {
610                    if let Some(i) = n.as_i64() {
611                        q = q.bind(i);
612                    } else if let Some(f) = n.as_f64() {
613                        q = q.bind(f);
614                    } else {
615                        q = q.bind(n.to_string());
616                    }
617                }
618                serde_json::Value::Bool(b) => q = q.bind(*b as i64),
619                _ => q = q.bind(value.to_string()),
620            }
621        }
622        let rows = q
623            .bind(limit)
624            .bind(offset)
625            .fetch_all(&self.pool)
626        .await?;
627        Ok(rows.into_iter().map(Into::into).collect())
628    }
629
630    async fn update_workflow_status(
631        &self,
632        id: &str,
633        status: WorkflowStatus,
634        result: Option<&str>,
635        error: Option<&str>,
636    ) -> Result<()> {
637        let now = timestamp_now();
638        let completed_at = if status.is_terminal() { Some(now) } else { None };
639        sqlx::query(
640            "UPDATE workflow.workflows SET status = ?, result = COALESCE(?, result), error = COALESCE(?, error), updated_at = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?",
641        )
642        .bind(status.to_string())
643        .bind(result)
644        .bind(error)
645        .bind(now)
646        .bind(completed_at)
647        .bind(id)
648        .execute(&self.pool)
649        .await?;
650        Ok(())
651    }
652
653    async fn claim_workflow(&self, id: &str, worker_id: &str) -> Result<bool> {
654        let res = sqlx::query(
655            "UPDATE workflow.workflows SET claimed_by = ?, status = 'RUNNING', updated_at = ? WHERE id = ? AND claimed_by IS NULL",
656        )
657        .bind(worker_id)
658        .bind(timestamp_now())
659        .bind(id)
660        .execute(&self.pool)
661        .await?;
662        Ok(res.rows_affected() > 0)
663    }
664
665    async fn mark_workflow_dispatchable(&self, workflow_id: &str) -> Result<()> {
666        sqlx::query("UPDATE workflow.workflows SET needs_dispatch = 1 WHERE id = ?")
667            .bind(workflow_id)
668            .execute(&self.pool)
669            .await?;
670        Ok(())
671    }
672
673    async fn claim_workflow_task(
674        &self,
675        task_queue: &str,
676        worker_id: &str,
677    ) -> Result<Option<WorkflowRecord>> {
678        let now = timestamp_now();
679        // Atomic: pick the oldest dispatchable + unclaimed workflow on the queue
680        let row = sqlx::query_as::<_, SqliteWorkflowRow>(
681            "UPDATE workflow.workflows
682             SET dispatch_claimed_by = ?, dispatch_last_heartbeat = ?, needs_dispatch = 0
683             WHERE id = (
684                SELECT id FROM workflow.workflows
685                WHERE task_queue = ?
686                  AND needs_dispatch = 1
687                  AND dispatch_claimed_by IS NULL
688                  AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
689                ORDER BY updated_at ASC
690                LIMIT 1
691             )
692             RETURNING id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at",
693        )
694        .bind(worker_id)
695        .bind(now)
696        .bind(task_queue)
697        .fetch_optional(&self.pool)
698        .await?;
699        Ok(row.map(Into::into))
700    }
701
702    async fn release_workflow_task(&self, workflow_id: &str, worker_id: &str) -> Result<()> {
703        sqlx::query(
704            "UPDATE workflow.workflows
705             SET dispatch_claimed_by = NULL, dispatch_last_heartbeat = NULL
706             WHERE id = ? AND dispatch_claimed_by = ?",
707        )
708        .bind(workflow_id)
709        .bind(worker_id)
710        .execute(&self.pool)
711        .await?;
712        Ok(())
713    }
714
715    async fn release_stale_dispatch_leases(
716        &self,
717        now: f64,
718        timeout_secs: f64,
719    ) -> Result<u64> {
720        // Re-arm needs_dispatch so the work goes back into the pool. Don't
721        // touch workflows that have reached a terminal state — those should
722        // never be re-dispatched.
723        let res = sqlx::query(
724            "UPDATE workflow.workflows
725             SET dispatch_claimed_by = NULL,
726                 dispatch_last_heartbeat = NULL,
727                 needs_dispatch = 1
728             WHERE dispatch_claimed_by IS NOT NULL
729               AND (? - dispatch_last_heartbeat) > ?
730               AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')",
731        )
732        .bind(now)
733        .bind(timeout_secs)
734        .execute(&self.pool)
735        .await?;
736        Ok(res.rows_affected())
737    }
738
739    // ── Events ─────────────────────────────────────────────
740
741    async fn append_event(&self, ev: &WorkflowEvent) -> Result<i64> {
742        let res = sqlx::query(
743            "INSERT INTO workflow.events (workflow_id, seq, event_type, payload, timestamp) VALUES (?, ?, ?, ?, ?)",
744        )
745        .bind(&ev.workflow_id)
746        .bind(ev.seq)
747        .bind(&ev.event_type)
748        .bind(&ev.payload)
749        .bind(ev.timestamp)
750        .execute(&self.pool)
751        .await?;
752        Ok(res.last_insert_rowid())
753    }
754
755    async fn list_events(&self, workflow_id: &str) -> Result<Vec<WorkflowEvent>> {
756        let rows = sqlx::query_as::<_, SqliteEventRow>(
757            "SELECT id, workflow_id, seq, event_type, payload, timestamp FROM workflow.events WHERE workflow_id = ? ORDER BY seq ASC",
758        )
759        .bind(workflow_id)
760        .fetch_all(&self.pool)
761        .await?;
762        Ok(rows.into_iter().map(Into::into).collect())
763    }
764
765    async fn get_event_count(&self, workflow_id: &str) -> Result<i64> {
766        let row: (i64,) =
767            sqlx::query_as("SELECT COUNT(*) FROM workflow.events WHERE workflow_id = ?")
768                .bind(workflow_id)
769                .fetch_one(&self.pool)
770                .await?;
771        Ok(row.0)
772    }
773
774    // ── Activities ──────────────────────────────────────────
775
776    async fn create_activity(&self, act: &WorkflowActivity) -> Result<i64> {
777        let res = sqlx::query(
778            "INSERT INTO workflow.activities (workflow_id, seq, name, task_queue, input, status, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, scheduled_at)
779             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
780        )
781        .bind(&act.workflow_id)
782        .bind(act.seq)
783        .bind(&act.name)
784        .bind(&act.task_queue)
785        .bind(&act.input)
786        .bind(&act.status)
787        .bind(act.attempt)
788        .bind(act.max_attempts)
789        .bind(act.initial_interval_secs)
790        .bind(act.backoff_coefficient)
791        .bind(act.start_to_close_secs)
792        .bind(act.heartbeat_timeout_secs)
793        .bind(act.scheduled_at)
794        .execute(&self.pool)
795        .await?;
796        Ok(res.last_insert_rowid())
797    }
798
799    async fn get_activity(&self, id: i64) -> Result<Option<WorkflowActivity>> {
800        let row = sqlx::query_as::<_, SqliteActivityRow>(
801            "SELECT id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat
802             FROM workflow.activities WHERE id = ?",
803        )
804        .bind(id)
805        .fetch_optional(&self.pool)
806        .await?;
807        Ok(row.map(Into::into))
808    }
809
810    async fn get_activity_by_workflow_seq(
811        &self,
812        workflow_id: &str,
813        seq: i32,
814    ) -> Result<Option<WorkflowActivity>> {
815        let row = sqlx::query_as::<_, SqliteActivityRow>(
816            "SELECT id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat
817             FROM workflow.activities WHERE workflow_id = ? AND seq = ?",
818        )
819        .bind(workflow_id)
820        .bind(seq)
821        .fetch_optional(&self.pool)
822        .await?;
823        Ok(row.map(Into::into))
824    }
825
826    async fn claim_activity(
827        &self,
828        task_queue: &str,
829        worker_id: &str,
830    ) -> Result<Option<WorkflowActivity>> {
831        let now = timestamp_now();
832        let row = sqlx::query_as::<_, SqliteActivityRow>(
833            "UPDATE workflow.activities SET status = 'RUNNING', claimed_by = ?, started_at = ?
834             WHERE id = (
835                SELECT id FROM workflow.activities
836                WHERE task_queue = ? AND status = 'PENDING'
837                ORDER BY scheduled_at ASC
838                LIMIT 1
839             )
840             RETURNING id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat",
841        )
842        .bind(worker_id)
843        .bind(now)
844        .bind(task_queue)
845        .fetch_optional(&self.pool)
846        .await?;
847        Ok(row.map(Into::into))
848    }
849
850    async fn requeue_activity_for_retry(
851        &self,
852        id: i64,
853        next_attempt: i32,
854        next_scheduled_at: f64,
855    ) -> Result<()> {
856        sqlx::query(
857            "UPDATE workflow.activities
858             SET status = 'PENDING', attempt = ?, scheduled_at = ?,
859                 claimed_by = NULL, started_at = NULL, last_heartbeat = NULL,
860                 error = NULL
861             WHERE id = ?",
862        )
863        .bind(next_attempt)
864        .bind(next_scheduled_at)
865        .bind(id)
866        .execute(&self.pool)
867        .await?;
868        Ok(())
869    }
870
871    async fn complete_activity(
872        &self,
873        id: i64,
874        result: Option<&str>,
875        error: Option<&str>,
876        failed: bool,
877    ) -> Result<()> {
878        let status = if failed { "FAILED" } else { "COMPLETED" };
879        sqlx::query(
880            "UPDATE workflow.activities SET status = ?, result = ?, error = ?, completed_at = ? WHERE id = ?",
881        )
882        .bind(status)
883        .bind(result)
884        .bind(error)
885        .bind(timestamp_now())
886        .bind(id)
887        .execute(&self.pool)
888        .await?;
889        Ok(())
890    }
891
892    async fn heartbeat_activity(&self, id: i64, _details: Option<&str>) -> Result<()> {
893        sqlx::query("UPDATE workflow.activities SET last_heartbeat = ? WHERE id = ?")
894            .bind(timestamp_now())
895            .bind(id)
896            .execute(&self.pool)
897            .await?;
898        Ok(())
899    }
900
901    async fn get_timed_out_activities(&self, now: f64) -> Result<Vec<WorkflowActivity>> {
902        let rows = sqlx::query_as::<_, SqliteActivityRow>(
903            "SELECT id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat
904             FROM workflow.activities
905             WHERE status = 'RUNNING'
906               AND heartbeat_timeout_secs IS NOT NULL
907               AND last_heartbeat IS NOT NULL
908               AND (? - last_heartbeat) > heartbeat_timeout_secs",
909        )
910        .bind(now)
911        .fetch_all(&self.pool)
912        .await?;
913        Ok(rows.into_iter().map(Into::into).collect())
914    }
915
916    // ── Timers ──────────────────────────────────────────────
917
918    async fn create_timer(&self, timer: &WorkflowTimer) -> Result<i64> {
919        // Idempotent: INSERT OR IGNORE on UNIQUE (workflow_id, seq).
920        // If the row already existed, last_insert_rowid() is 0 — fall back to SELECT.
921        let res = sqlx::query(
922            "INSERT OR IGNORE INTO workflow.timers (workflow_id, seq, fire_at, fired) VALUES (?, ?, ?, 0)",
923        )
924        .bind(&timer.workflow_id)
925        .bind(timer.seq)
926        .bind(timer.fire_at)
927        .execute(&self.pool)
928        .await?;
929
930        let id = res.last_insert_rowid();
931        if id != 0 {
932            return Ok(id);
933        }
934
935        // Row already existed — return its id.
936        let (existing_id,): (i64,) = sqlx::query_as(
937            "SELECT id FROM workflow.timers WHERE workflow_id = ? AND seq = ?",
938        )
939        .bind(&timer.workflow_id)
940        .bind(timer.seq)
941        .fetch_one(&self.pool)
942        .await?;
943        Ok(existing_id)
944    }
945
946    async fn cancel_pending_activities(&self, workflow_id: &str) -> Result<u64> {
947        let res = sqlx::query(
948            "UPDATE workflow.activities SET status = 'CANCELLED', completed_at = ?
949             WHERE workflow_id = ? AND status = 'PENDING'",
950        )
951        .bind(timestamp_now())
952        .bind(workflow_id)
953        .execute(&self.pool)
954        .await?;
955        Ok(res.rows_affected())
956    }
957
958    async fn cancel_pending_timers(&self, workflow_id: &str) -> Result<u64> {
959        let res = sqlx::query(
960            "UPDATE workflow.timers SET fired = 1
961             WHERE workflow_id = ? AND fired = 0",
962        )
963        .bind(workflow_id)
964        .execute(&self.pool)
965        .await?;
966        Ok(res.rows_affected())
967    }
968
969    async fn get_timer_by_workflow_seq(
970        &self,
971        workflow_id: &str,
972        seq: i32,
973    ) -> Result<Option<WorkflowTimer>> {
974        let row = sqlx::query_as::<_, SqliteTimerRow>(
975            "SELECT id, workflow_id, seq, fire_at, fired
976             FROM workflow.timers WHERE workflow_id = ? AND seq = ?",
977        )
978        .bind(workflow_id)
979        .bind(seq)
980        .fetch_optional(&self.pool)
981        .await?;
982        Ok(row.map(Into::into))
983    }
984
985    async fn fire_due_timers(&self, now: f64) -> Result<Vec<WorkflowTimer>> {
986        let rows = sqlx::query_as::<_, SqliteTimerRow>(
987            "UPDATE workflow.timers SET fired = 1
988             WHERE fired = 0 AND fire_at <= ?
989             RETURNING id, workflow_id, seq, fire_at, fired",
990        )
991        .bind(now)
992        .fetch_all(&self.pool)
993        .await?;
994        Ok(rows.into_iter().map(Into::into).collect())
995    }
996
997    // ── Signals ─────────────────────────────────────────────
998
999    async fn send_signal(&self, sig: &WorkflowSignal) -> Result<i64> {
1000        let res = sqlx::query(
1001            "INSERT INTO workflow.signals (workflow_id, name, payload, consumed, received_at) VALUES (?, ?, ?, 0, ?)",
1002        )
1003        .bind(&sig.workflow_id)
1004        .bind(&sig.name)
1005        .bind(&sig.payload)
1006        .bind(sig.received_at)
1007        .execute(&self.pool)
1008        .await?;
1009        Ok(res.last_insert_rowid())
1010    }
1011
1012    async fn consume_signals(
1013        &self,
1014        workflow_id: &str,
1015        name: &str,
1016    ) -> Result<Vec<WorkflowSignal>> {
1017        let rows = sqlx::query_as::<_, SqliteSignalRow>(
1018            "UPDATE workflow.signals SET consumed = 1
1019             WHERE workflow_id = ? AND name = ? AND consumed = 0
1020             RETURNING id, workflow_id, name, payload, consumed, received_at",
1021        )
1022        .bind(workflow_id)
1023        .bind(name)
1024        .fetch_all(&self.pool)
1025        .await?;
1026        Ok(rows.into_iter().map(Into::into).collect())
1027    }
1028
1029    // ── Schedules ───────────────────────────────────────────
1030
1031    async fn create_schedule(&self, sched: &WorkflowSchedule) -> Result<()> {
1032        sqlx::query(
1033            "INSERT INTO workflow.schedules (name, namespace, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at)
1034             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1035        )
1036        .bind(&sched.name)
1037        .bind(&sched.namespace)
1038        .bind(&sched.workflow_type)
1039        .bind(&sched.cron_expr)
1040        .bind(&sched.timezone)
1041        .bind(&sched.input)
1042        .bind(&sched.task_queue)
1043        .bind(&sched.overlap_policy)
1044        .bind(sched.paused)
1045        .bind(sched.last_run_at)
1046        .bind(sched.next_run_at)
1047        .bind(&sched.last_workflow_id)
1048        .bind(sched.created_at)
1049        .execute(&self.pool)
1050        .await?;
1051        Ok(())
1052    }
1053
1054    async fn get_schedule(&self, namespace: &str, name: &str) -> Result<Option<WorkflowSchedule>> {
1055        let row = sqlx::query_as::<_, SqliteScheduleRow>(
1056            "SELECT name, namespace, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at
1057             FROM workflow.schedules WHERE namespace = ? AND name = ?",
1058        )
1059        .bind(namespace)
1060        .bind(name)
1061        .fetch_optional(&self.pool)
1062        .await?;
1063        Ok(row.map(Into::into))
1064    }
1065
1066    async fn list_schedules(&self, namespace: &str) -> Result<Vec<WorkflowSchedule>> {
1067        let rows = sqlx::query_as::<_, SqliteScheduleRow>(
1068            "SELECT name, namespace, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at
1069             FROM workflow.schedules WHERE namespace = ? ORDER BY name",
1070        )
1071        .bind(namespace)
1072        .fetch_all(&self.pool)
1073        .await?;
1074        Ok(rows.into_iter().map(Into::into).collect())
1075    }
1076
1077    async fn update_schedule_last_run(
1078        &self,
1079        namespace: &str,
1080        name: &str,
1081        last_run_at: f64,
1082        next_run_at: f64,
1083        workflow_id: &str,
1084    ) -> Result<()> {
1085        sqlx::query(
1086            "UPDATE workflow.schedules SET last_run_at = ?, next_run_at = ?, last_workflow_id = ? WHERE namespace = ? AND name = ?",
1087        )
1088        .bind(last_run_at)
1089        .bind(next_run_at)
1090        .bind(workflow_id)
1091        .bind(namespace)
1092        .bind(name)
1093        .execute(&self.pool)
1094        .await?;
1095        Ok(())
1096    }
1097
1098    async fn delete_schedule(&self, namespace: &str, name: &str) -> Result<bool> {
1099        let res =
1100            sqlx::query("DELETE FROM workflow.schedules WHERE namespace = ? AND name = ?")
1101                .bind(namespace)
1102                .bind(name)
1103                .execute(&self.pool)
1104                .await?;
1105        Ok(res.rows_affected() > 0)
1106    }
1107
1108    async fn list_archivable_workflows(
1109        &self,
1110        cutoff: f64,
1111        limit: i64,
1112    ) -> Result<Vec<WorkflowRecord>> {
1113        let rows = sqlx::query_as::<_, SqliteWorkflowRow>(
1114            "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at
1115             FROM workflow.workflows
1116             WHERE status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
1117               AND completed_at IS NOT NULL
1118               AND completed_at < ?
1119               AND archived_at IS NULL
1120             ORDER BY completed_at ASC
1121             LIMIT ?",
1122        )
1123        .bind(cutoff)
1124        .bind(limit)
1125        .fetch_all(&self.pool)
1126        .await?;
1127        Ok(rows.into_iter().map(Into::into).collect())
1128    }
1129
1130    async fn mark_archived_and_purge(
1131        &self,
1132        workflow_id: &str,
1133        archive_uri: &str,
1134        archived_at: f64,
1135    ) -> Result<()> {
1136        let mut tx = self.pool.begin().await?;
1137        sqlx::query("DELETE FROM workflow.events WHERE workflow_id = ?")
1138            .bind(workflow_id)
1139            .execute(&mut *tx)
1140            .await?;
1141        sqlx::query("DELETE FROM workflow.activities WHERE workflow_id = ?")
1142            .bind(workflow_id)
1143            .execute(&mut *tx)
1144            .await?;
1145        sqlx::query("DELETE FROM workflow.timers WHERE workflow_id = ?")
1146            .bind(workflow_id)
1147            .execute(&mut *tx)
1148            .await?;
1149        sqlx::query("DELETE FROM workflow.signals WHERE workflow_id = ?")
1150            .bind(workflow_id)
1151            .execute(&mut *tx)
1152            .await?;
1153        sqlx::query("DELETE FROM workflow.snapshots WHERE workflow_id = ?")
1154            .bind(workflow_id)
1155            .execute(&mut *tx)
1156            .await?;
1157        sqlx::query(
1158            "UPDATE workflow.workflows SET archived_at = ?, archive_uri = ? WHERE id = ?",
1159        )
1160        .bind(archived_at)
1161        .bind(archive_uri)
1162        .bind(workflow_id)
1163        .execute(&mut *tx)
1164        .await?;
1165        tx.commit().await?;
1166        Ok(())
1167    }
1168
1169    async fn upsert_search_attributes(
1170        &self,
1171        workflow_id: &str,
1172        patch_json: &str,
1173    ) -> Result<()> {
1174        // Merge at the application layer so we don't depend on SQLite's
1175        // `json_patch`, which is only available with the json1 extension.
1176        let current: Option<(Option<String>,)> =
1177            sqlx::query_as("SELECT search_attributes FROM workflow.workflows WHERE id = ?")
1178                .bind(workflow_id)
1179                .fetch_optional(&self.pool)
1180                .await?;
1181        let merged = merge_search_attrs(
1182            current.and_then(|(s,)| s).as_deref(),
1183            patch_json,
1184        )?;
1185        sqlx::query("UPDATE workflow.workflows SET search_attributes = ? WHERE id = ?")
1186            .bind(merged)
1187            .bind(workflow_id)
1188            .execute(&self.pool)
1189            .await?;
1190        Ok(())
1191    }
1192
1193    async fn update_schedule(
1194        &self,
1195        namespace: &str,
1196        name: &str,
1197        patch: &SchedulePatch,
1198    ) -> Result<Option<WorkflowSchedule>> {
1199        // Build the UPDATE dynamically so unchanged fields aren't touched
1200        // and NULL from `serde_json::Value::Null` round-trips cleanly.
1201        let mut sets: Vec<&'static str> = Vec::new();
1202        if patch.cron_expr.is_some() {
1203            sets.push("cron_expr = ?");
1204        }
1205        if patch.timezone.is_some() {
1206            sets.push("timezone = ?");
1207        }
1208        if patch.input.is_some() {
1209            sets.push("input = ?");
1210        }
1211        if patch.task_queue.is_some() {
1212            sets.push("task_queue = ?");
1213        }
1214        if patch.overlap_policy.is_some() {
1215            sets.push("overlap_policy = ?");
1216        }
1217        // Updating last_run_at/next_run_at is internal only (update_schedule_last_run).
1218        if sets.is_empty() {
1219            return self.get_schedule(namespace, name).await;
1220        }
1221
1222        let sql = format!(
1223            "UPDATE workflow.schedules SET {} WHERE namespace = ? AND name = ?",
1224            sets.join(", ")
1225        );
1226        let mut q = sqlx::query(&sql);
1227        if let Some(ref v) = patch.cron_expr {
1228            q = q.bind(v);
1229        }
1230        if let Some(ref v) = patch.timezone {
1231            q = q.bind(v);
1232        }
1233        if let Some(ref v) = patch.input {
1234            q = q.bind(v.to_string());
1235        }
1236        if let Some(ref v) = patch.task_queue {
1237            q = q.bind(v);
1238        }
1239        if let Some(ref v) = patch.overlap_policy {
1240            q = q.bind(v);
1241        }
1242        let res = q
1243            .bind(namespace)
1244            .bind(name)
1245            .execute(&self.pool)
1246            .await?;
1247        if res.rows_affected() == 0 {
1248            return Ok(None);
1249        }
1250        self.get_schedule(namespace, name).await
1251    }
1252
1253    async fn set_schedule_paused(
1254        &self,
1255        namespace: &str,
1256        name: &str,
1257        paused: bool,
1258    ) -> Result<Option<WorkflowSchedule>> {
1259        let res = sqlx::query(
1260            "UPDATE workflow.schedules SET paused = ? WHERE namespace = ? AND name = ?",
1261        )
1262        .bind(paused)
1263        .bind(namespace)
1264        .bind(name)
1265        .execute(&self.pool)
1266        .await?;
1267        if res.rows_affected() == 0 {
1268            return Ok(None);
1269        }
1270        self.get_schedule(namespace, name).await
1271    }
1272
1273    // ── Workers ─────────────────────────────────────────────
1274
1275    async fn register_worker(&self, w: &WorkflowWorker) -> Result<()> {
1276        sqlx::query(
1277            "INSERT OR REPLACE INTO workflow.workers (id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at)
1278             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1279        )
1280        .bind(&w.id)
1281        .bind(&w.namespace)
1282        .bind(&w.identity)
1283        .bind(&w.task_queue)
1284        .bind(&w.workflows)
1285        .bind(&w.activities)
1286        .bind(w.max_concurrent_workflows)
1287        .bind(w.max_concurrent_activities)
1288        .bind(w.active_tasks)
1289        .bind(w.last_heartbeat)
1290        .bind(w.registered_at)
1291        .execute(&self.pool)
1292        .await?;
1293        Ok(())
1294    }
1295
1296    async fn heartbeat_worker(&self, id: &str, now: f64) -> Result<()> {
1297        sqlx::query("UPDATE workflow.workers SET last_heartbeat = ? WHERE id = ?")
1298            .bind(now)
1299            .bind(id)
1300            .execute(&self.pool)
1301            .await?;
1302        Ok(())
1303    }
1304
1305    async fn list_workers(&self, namespace: &str) -> Result<Vec<WorkflowWorker>> {
1306        let rows = sqlx::query_as::<_, SqliteWorkerRow>(
1307            "SELECT id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at
1308             FROM workflow.workers WHERE namespace = ? ORDER BY registered_at",
1309        )
1310        .bind(namespace)
1311        .fetch_all(&self.pool)
1312        .await?;
1313        Ok(rows.into_iter().map(Into::into).collect())
1314    }
1315
1316    async fn remove_dead_workers(&self, cutoff: f64) -> Result<Vec<String>> {
1317        let rows: Vec<(String,)> =
1318            sqlx::query_as("SELECT id FROM workflow.workers WHERE last_heartbeat < ?")
1319                .bind(cutoff)
1320                .fetch_all(&self.pool)
1321                .await?;
1322        let ids: Vec<String> = rows.into_iter().map(|r| r.0).collect();
1323        if !ids.is_empty() {
1324            sqlx::query("DELETE FROM workflow.workers WHERE last_heartbeat < ?")
1325                .bind(cutoff)
1326                .execute(&self.pool)
1327                .await?;
1328        }
1329        Ok(ids)
1330    }
1331
1332    // ── Child Workflows ─────────────────────────────────────
1333
1334    async fn list_child_workflows(&self, parent_id: &str) -> Result<Vec<WorkflowRecord>> {
1335        let rows = sqlx::query_as::<_, SqliteWorkflowRow>(
1336            "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at
1337             FROM workflow.workflows WHERE parent_id = ? ORDER BY created_at ASC",
1338        )
1339        .bind(parent_id)
1340        .fetch_all(&self.pool)
1341        .await?;
1342        Ok(rows.into_iter().map(Into::into).collect())
1343    }
1344
1345    // ── Snapshots ───────────────────────────────────────────
1346
1347    async fn create_snapshot(
1348        &self,
1349        workflow_id: &str,
1350        event_seq: i32,
1351        state_json: &str,
1352    ) -> Result<()> {
1353        sqlx::query(
1354            "INSERT OR REPLACE INTO workflow.snapshots (workflow_id, event_seq, state_json, created_at)
1355             VALUES (?, ?, ?, ?)",
1356        )
1357        .bind(workflow_id)
1358        .bind(event_seq)
1359        .bind(state_json)
1360        .bind(timestamp_now())
1361        .execute(&self.pool)
1362        .await?;
1363        Ok(())
1364    }
1365
1366    async fn get_latest_snapshot(
1367        &self,
1368        workflow_id: &str,
1369    ) -> Result<Option<WorkflowSnapshot>> {
1370        let row = sqlx::query_as::<_, (String, i32, String, f64)>(
1371            "SELECT workflow_id, event_seq, state_json, created_at
1372             FROM workflow.snapshots WHERE workflow_id = ?
1373             ORDER BY event_seq DESC LIMIT 1",
1374        )
1375        .bind(workflow_id)
1376        .fetch_optional(&self.pool)
1377        .await?;
1378
1379        Ok(row.map(|(workflow_id, event_seq, state_json, created_at)| WorkflowSnapshot {
1380            workflow_id,
1381            event_seq,
1382            state_json,
1383            created_at,
1384        }))
1385    }
1386
1387    // ── Queue Stats ─────────────────────────────────────────
1388
1389    async fn get_queue_stats(&self, namespace: &str) -> Result<Vec<QueueStats>> {
1390        // Gather activity stats per queue for workflows in this namespace
1391        let rows = sqlx::query_as::<_, (String, i64, i64)>(
1392            "SELECT a.task_queue,
1393                    SUM(CASE WHEN a.status = 'PENDING' THEN 1 ELSE 0 END),
1394                    SUM(CASE WHEN a.status = 'RUNNING' THEN 1 ELSE 0 END)
1395             FROM workflow.activities a
1396             INNER JOIN workflow.workflows w ON w.id = a.workflow_id
1397             WHERE w.namespace = ?
1398             GROUP BY a.task_queue",
1399        )
1400        .bind(namespace)
1401        .fetch_all(&self.pool)
1402        .await?;
1403
1404        let mut stats: Vec<QueueStats> = rows
1405            .into_iter()
1406            .map(|(queue, pending, running)| QueueStats {
1407                queue,
1408                pending_activities: pending,
1409                running_activities: running,
1410                workers: 0,
1411            })
1412            .collect();
1413
1414        // Gather worker counts per queue in this namespace
1415        let worker_rows = sqlx::query_as::<_, (String, i64)>(
1416            "SELECT task_queue, COUNT(*) FROM workflow.workers WHERE namespace = ? GROUP BY task_queue",
1417        )
1418        .bind(namespace)
1419        .fetch_all(&self.pool)
1420        .await?;
1421
1422        for (queue, count) in worker_rows {
1423            if let Some(s) = stats.iter_mut().find(|s| s.queue == queue) {
1424                s.workers = count;
1425            } else {
1426                stats.push(QueueStats {
1427                    queue,
1428                    pending_activities: 0,
1429                    running_activities: 0,
1430                    workers: count,
1431                });
1432            }
1433        }
1434
1435        stats.sort_by(|a, b| a.queue.cmp(&b.queue));
1436        Ok(stats)
1437    }
1438
1439    // ── Leader Election ─────────────────────────────────────
1440
1441    async fn try_acquire_scheduler_lock(&self) -> Result<bool> {
1442        // SQLite is single-instance — always the leader.
1443        // Also refresh the engine lock heartbeat on each scheduler tick.
1444        self.refresh_engine_lock().await.ok();
1445        Ok(true)
1446    }
1447
1448}
1449
1450fn timestamp_now() -> f64 {
1451    std::time::SystemTime::now()
1452        .duration_since(std::time::UNIX_EPOCH)
1453        .unwrap()
1454        .as_secs_f64()
1455}
1456
1457/// Merge a JSON-object patch into a (possibly-null) current JSON object,
1458/// returning the serialised result. Shared by SQLite and Postgres stores.
1459pub(crate) fn merge_search_attrs(current: Option<&str>, patch_json: &str) -> Result<String> {
1460    let mut current_map: serde_json::Map<String, serde_json::Value> = current
1461        .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
1462        .and_then(|v| v.as_object().cloned())
1463        .unwrap_or_default();
1464    let patch: serde_json::Value = serde_json::from_str(patch_json)
1465        .map_err(|e| anyhow::anyhow!("invalid search_attributes patch: {e}"))?;
1466    let patch_obj = patch
1467        .as_object()
1468        .ok_or_else(|| anyhow::anyhow!("search_attributes patch must be a JSON object"))?;
1469    for (k, v) in patch_obj {
1470        current_map.insert(k.clone(), v.clone());
1471    }
1472    Ok(serde_json::Value::Object(current_map).to_string())
1473}
1474
1475// ── SQLite row types (sqlx::FromRow) ────────────────────────
1476
1477#[derive(sqlx::FromRow)]
1478struct SqliteWorkflowRow {
1479    id: String,
1480    namespace: String,
1481    run_id: String,
1482    workflow_type: String,
1483    task_queue: String,
1484    status: String,
1485    input: Option<String>,
1486    result: Option<String>,
1487    error: Option<String>,
1488    parent_id: Option<String>,
1489    claimed_by: Option<String>,
1490    search_attributes: Option<String>,
1491    archived_at: Option<f64>,
1492    archive_uri: Option<String>,
1493    created_at: f64,
1494    updated_at: f64,
1495    completed_at: Option<f64>,
1496}
1497
1498impl From<SqliteWorkflowRow> for WorkflowRecord {
1499    fn from(r: SqliteWorkflowRow) -> Self {
1500        Self {
1501            id: r.id,
1502            namespace: r.namespace,
1503            run_id: r.run_id,
1504            workflow_type: r.workflow_type,
1505            task_queue: r.task_queue,
1506            status: r.status,
1507            input: r.input,
1508            result: r.result,
1509            error: r.error,
1510            parent_id: r.parent_id,
1511            claimed_by: r.claimed_by,
1512            search_attributes: r.search_attributes,
1513            archived_at: r.archived_at,
1514            archive_uri: r.archive_uri,
1515            created_at: r.created_at,
1516            updated_at: r.updated_at,
1517            completed_at: r.completed_at,
1518        }
1519    }
1520}
1521
1522#[derive(sqlx::FromRow)]
1523struct SqliteEventRow {
1524    id: i64,
1525    workflow_id: String,
1526    seq: i32,
1527    event_type: String,
1528    payload: Option<String>,
1529    timestamp: f64,
1530}
1531
1532impl From<SqliteEventRow> for WorkflowEvent {
1533    fn from(r: SqliteEventRow) -> Self {
1534        Self {
1535            id: Some(r.id),
1536            workflow_id: r.workflow_id,
1537            seq: r.seq,
1538            event_type: r.event_type,
1539            payload: r.payload,
1540            timestamp: r.timestamp,
1541        }
1542    }
1543}
1544
1545#[derive(sqlx::FromRow)]
1546struct SqliteActivityRow {
1547    id: i64,
1548    workflow_id: String,
1549    seq: i32,
1550    name: String,
1551    task_queue: String,
1552    input: Option<String>,
1553    status: String,
1554    result: Option<String>,
1555    error: Option<String>,
1556    attempt: i32,
1557    max_attempts: i32,
1558    initial_interval_secs: f64,
1559    backoff_coefficient: f64,
1560    start_to_close_secs: f64,
1561    heartbeat_timeout_secs: Option<f64>,
1562    claimed_by: Option<String>,
1563    scheduled_at: f64,
1564    started_at: Option<f64>,
1565    completed_at: Option<f64>,
1566    last_heartbeat: Option<f64>,
1567}
1568
1569impl From<SqliteActivityRow> for WorkflowActivity {
1570    fn from(r: SqliteActivityRow) -> Self {
1571        Self {
1572            id: Some(r.id),
1573            workflow_id: r.workflow_id,
1574            seq: r.seq,
1575            name: r.name,
1576            task_queue: r.task_queue,
1577            input: r.input,
1578            status: r.status,
1579            result: r.result,
1580            error: r.error,
1581            attempt: r.attempt,
1582            max_attempts: r.max_attempts,
1583            initial_interval_secs: r.initial_interval_secs,
1584            backoff_coefficient: r.backoff_coefficient,
1585            start_to_close_secs: r.start_to_close_secs,
1586            heartbeat_timeout_secs: r.heartbeat_timeout_secs,
1587            claimed_by: r.claimed_by,
1588            scheduled_at: r.scheduled_at,
1589            started_at: r.started_at,
1590            completed_at: r.completed_at,
1591            last_heartbeat: r.last_heartbeat,
1592        }
1593    }
1594}
1595
1596#[derive(sqlx::FromRow)]
1597struct SqliteTimerRow {
1598    id: i64,
1599    workflow_id: String,
1600    seq: i32,
1601    fire_at: f64,
1602    fired: bool,
1603}
1604
1605impl From<SqliteTimerRow> for WorkflowTimer {
1606    fn from(r: SqliteTimerRow) -> Self {
1607        Self {
1608            id: Some(r.id),
1609            workflow_id: r.workflow_id,
1610            seq: r.seq,
1611            fire_at: r.fire_at,
1612            fired: r.fired,
1613        }
1614    }
1615}
1616
1617#[derive(sqlx::FromRow)]
1618struct SqliteSignalRow {
1619    id: i64,
1620    workflow_id: String,
1621    name: String,
1622    payload: Option<String>,
1623    consumed: bool,
1624    received_at: f64,
1625}
1626
1627impl From<SqliteSignalRow> for WorkflowSignal {
1628    fn from(r: SqliteSignalRow) -> Self {
1629        Self {
1630            id: Some(r.id),
1631            workflow_id: r.workflow_id,
1632            name: r.name,
1633            payload: r.payload,
1634            consumed: r.consumed,
1635            received_at: r.received_at,
1636        }
1637    }
1638}
1639
1640#[derive(sqlx::FromRow)]
1641struct SqliteScheduleRow {
1642    name: String,
1643    namespace: String,
1644    workflow_type: String,
1645    cron_expr: String,
1646    timezone: String,
1647    input: Option<String>,
1648    task_queue: String,
1649    overlap_policy: String,
1650    paused: bool,
1651    last_run_at: Option<f64>,
1652    next_run_at: Option<f64>,
1653    last_workflow_id: Option<String>,
1654    created_at: f64,
1655}
1656
1657impl From<SqliteScheduleRow> for WorkflowSchedule {
1658    fn from(r: SqliteScheduleRow) -> Self {
1659        Self {
1660            name: r.name,
1661            namespace: r.namespace,
1662            workflow_type: r.workflow_type,
1663            cron_expr: r.cron_expr,
1664            timezone: r.timezone,
1665            input: r.input,
1666            task_queue: r.task_queue,
1667            overlap_policy: r.overlap_policy,
1668            paused: r.paused,
1669            last_run_at: r.last_run_at,
1670            next_run_at: r.next_run_at,
1671            last_workflow_id: r.last_workflow_id,
1672            created_at: r.created_at,
1673        }
1674    }
1675}
1676
1677#[derive(sqlx::FromRow)]
1678struct SqliteWorkerRow {
1679    id: String,
1680    namespace: String,
1681    identity: String,
1682    task_queue: String,
1683    workflows: Option<String>,
1684    activities: Option<String>,
1685    max_concurrent_workflows: i32,
1686    max_concurrent_activities: i32,
1687    active_tasks: i32,
1688    last_heartbeat: f64,
1689    registered_at: f64,
1690}
1691
1692impl From<SqliteWorkerRow> for WorkflowWorker {
1693    fn from(r: SqliteWorkerRow) -> Self {
1694        Self {
1695            id: r.id,
1696            namespace: r.namespace,
1697            identity: r.identity,
1698            task_queue: r.task_queue,
1699            workflows: r.workflows,
1700            activities: r.activities,
1701            max_concurrent_workflows: r.max_concurrent_workflows,
1702            max_concurrent_activities: r.max_concurrent_activities,
1703            active_tasks: r.active_tasks,
1704            last_heartbeat: r.last_heartbeat,
1705            registered_at: r.registered_at,
1706        }
1707    }
1708}