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