Skip to main content

assay_workflow/store/
sqlite.rs

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