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