Skip to main content

assay_workflow/store/
postgres.rs

1use anyhow::Result;
2use sqlx::PgPool;
3
4use crate::store::{
5    NOT_A_SETTLEMENT, RetryEvent, WorkflowStore, payload_activity_id, retry_denial, settle_outcome,
6};
7use crate::types::*;
8
9const RETRY_ACTIVITY_SELECT: &str = "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 FROM workflow.activities WHERE workflow_id = $1 AND status = 'FAILED' ORDER BY seq DESC LIMIT 1 FOR UPDATE";
10/// Terminal activities whose workflow is still live and whose terminal
11/// history event never landed — the half-settled state the transactional
12/// settle path can no longer create, and older rows can still be in.
13const UNSETTLED_ACTIVITY_SELECT: &str = "SELECT a.id, a.workflow_id, a.seq, a.name, a.task_queue, a.input, a.status, a.result, a.error, a.attempt, a.max_attempts, a.initial_interval_secs, a.backoff_coefficient, a.start_to_close_secs, a.heartbeat_timeout_secs, a.claimed_by, a.scheduled_at, a.started_at, a.completed_at, a.last_heartbeat FROM workflow.activities a JOIN workflow.workflows w ON w.id = a.workflow_id WHERE a.status IN ('COMPLETED', 'FAILED') AND w.status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT') AND w.archived_at IS NULL AND NOT EXISTS (SELECT 1 FROM workflow.events e WHERE e.workflow_id = a.workflow_id AND e.activity_id = a.id AND e.event_type IN ('ActivityCompleted', 'ActivityFailed')) ORDER BY a.completed_at ASC LIMIT $1";
14const RETRY_ACTIVITY_UPDATE: &str = "UPDATE workflow.activities SET status = 'PENDING', result = NULL, error = NULL, attempt = 1, claimed_by = NULL, scheduled_at = $1, started_at = NULL, completed_at = NULL, last_heartbeat = NULL WHERE id = $2 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";
15
16/// v0.1.2 schema layout: workflow tables live in the `workflow` schema;
17/// the engine-events outbox lives in the `engine` schema (created
18/// alongside the engine-core tables by `assay_domain::engine`). The
19/// store creates the workflow schema first, then runs DDL against it
20/// schema-qualified.
21const SCHEMA: &str = r#"
22CREATE SCHEMA IF NOT EXISTS workflow;
23CREATE SCHEMA IF NOT EXISTS engine;
24
25CREATE TABLE IF NOT EXISTS workflow.namespaces (
26    name            TEXT PRIMARY KEY,
27    created_at      DOUBLE PRECISION NOT NULL
28);
29INSERT INTO workflow.namespaces (name, created_at)
30    VALUES ('main', EXTRACT(EPOCH FROM NOW()))
31    ON CONFLICT DO NOTHING;
32
33CREATE TABLE IF NOT EXISTS workflow.workflows (
34    id              TEXT PRIMARY KEY,
35    namespace       TEXT NOT NULL DEFAULT 'main',
36    run_id          TEXT NOT NULL,
37    workflow_type   TEXT NOT NULL,
38    task_queue      TEXT NOT NULL DEFAULT 'main',
39    status          TEXT NOT NULL DEFAULT 'PENDING',
40    input           TEXT,
41    result          TEXT,
42    error           TEXT,
43    parent_id       TEXT,
44    claimed_by      TEXT,
45    search_attributes TEXT,
46    archived_at     DOUBLE PRECISION,
47    archive_uri     TEXT,
48    -- Workflow-task dispatch (Phase 9): see sqlite.rs for the full comment.
49    needs_dispatch  BOOLEAN NOT NULL DEFAULT FALSE,
50    dispatch_claimed_by    TEXT,
51    dispatch_last_heartbeat DOUBLE PRECISION,
52    created_at      DOUBLE PRECISION NOT NULL,
53    updated_at      DOUBLE PRECISION NOT NULL,
54    completed_at    DOUBLE PRECISION
55);
56CREATE INDEX IF NOT EXISTS idx_wf_status_queue ON workflow.workflows(status, task_queue);
57CREATE INDEX IF NOT EXISTS idx_wf_namespace ON workflow.workflows(namespace);
58CREATE INDEX IF NOT EXISTS idx_wf_dispatch ON workflow.workflows(task_queue, needs_dispatch, dispatch_claimed_by);
59
60CREATE TABLE IF NOT EXISTS workflow.events (
61    id              BIGSERIAL PRIMARY KEY,
62    workflow_id     TEXT NOT NULL REFERENCES workflow.workflows(id),
63    seq             INTEGER NOT NULL,
64    event_type      TEXT NOT NULL,
65    payload         TEXT,
66    -- Set on ActivityCompleted / ActivityFailed only. Answers "did this
67    -- activity's terminal event land" without parsing payload JSON, which
68    -- is what the settle transaction and the reconciler both need.
69    activity_id     BIGINT,
70    timestamp       DOUBLE PRECISION NOT NULL
71);
72CREATE INDEX IF NOT EXISTS idx_wf_events_lookup ON workflow.events(workflow_id, seq);
73
74CREATE TABLE IF NOT EXISTS workflow.activities (
75    id              BIGSERIAL PRIMARY KEY,
76    workflow_id     TEXT NOT NULL REFERENCES workflow.workflows(id),
77    seq             INTEGER NOT NULL,
78    name            TEXT NOT NULL,
79    task_queue      TEXT NOT NULL DEFAULT 'main',
80    input           TEXT,
81    status          TEXT NOT NULL DEFAULT 'PENDING',
82    result          TEXT,
83    error           TEXT,
84    attempt         INTEGER NOT NULL DEFAULT 1,
85    max_attempts    INTEGER NOT NULL DEFAULT 3,
86    initial_interval_secs   DOUBLE PRECISION NOT NULL DEFAULT 1,
87    backoff_coefficient     DOUBLE PRECISION NOT NULL DEFAULT 2,
88    start_to_close_secs     DOUBLE PRECISION NOT NULL DEFAULT 300,
89    heartbeat_timeout_secs  DOUBLE PRECISION,
90    claimed_by      TEXT,
91    scheduled_at    DOUBLE PRECISION NOT NULL,
92    started_at      DOUBLE PRECISION,
93    completed_at    DOUBLE PRECISION,
94    last_heartbeat  DOUBLE PRECISION,
95    UNIQUE (workflow_id, seq)
96);
97CREATE INDEX IF NOT EXISTS idx_wf_act_pending ON workflow.activities(task_queue, status, scheduled_at);
98
99CREATE TABLE IF NOT EXISTS workflow.timers (
100    id              BIGSERIAL PRIMARY KEY,
101    workflow_id     TEXT NOT NULL REFERENCES workflow.workflows(id),
102    seq             INTEGER NOT NULL,
103    fire_at         DOUBLE PRECISION NOT NULL,
104    fired           BOOLEAN NOT NULL DEFAULT FALSE,
105    UNIQUE (workflow_id, seq)
106);
107CREATE INDEX IF NOT EXISTS idx_wf_timers_due ON workflow.timers(fire_at) WHERE fired = FALSE;
108
109CREATE TABLE IF NOT EXISTS workflow.signals (
110    id              BIGSERIAL PRIMARY KEY,
111    workflow_id     TEXT NOT NULL REFERENCES workflow.workflows(id),
112    name            TEXT NOT NULL,
113    payload         TEXT,
114    consumed        BOOLEAN NOT NULL DEFAULT FALSE,
115    received_at     DOUBLE PRECISION NOT NULL
116);
117CREATE INDEX IF NOT EXISTS idx_wf_signals_lookup ON workflow.signals(workflow_id, name, consumed);
118
119CREATE TABLE IF NOT EXISTS workflow.schedules (
120    namespace       TEXT NOT NULL DEFAULT 'main',
121    name            TEXT NOT NULL,
122    workflow_type   TEXT NOT NULL,
123    cron_expr       TEXT NOT NULL,
124    timezone        TEXT NOT NULL DEFAULT 'UTC',
125    input           TEXT,
126    task_queue      TEXT NOT NULL DEFAULT 'main',
127    overlap_policy  TEXT NOT NULL DEFAULT 'skip',
128    paused          BOOLEAN NOT NULL DEFAULT FALSE,
129    last_run_at     DOUBLE PRECISION,
130    next_run_at     DOUBLE PRECISION,
131    last_workflow_id TEXT,
132    created_at      DOUBLE PRECISION NOT NULL,
133    PRIMARY KEY (namespace, name)
134);
135
136CREATE TABLE IF NOT EXISTS workflow.workers (
137    id              TEXT PRIMARY KEY,
138    namespace       TEXT NOT NULL DEFAULT 'main',
139    identity        TEXT NOT NULL,
140    task_queue      TEXT NOT NULL,
141    workflows       TEXT,
142    activities      TEXT,
143    max_concurrent_workflows  INTEGER NOT NULL DEFAULT 10,
144    max_concurrent_activities INTEGER NOT NULL DEFAULT 10,
145    active_tasks    INTEGER NOT NULL DEFAULT 0,
146    last_heartbeat  DOUBLE PRECISION NOT NULL,
147    registered_at   DOUBLE PRECISION NOT NULL
148);
149
150CREATE TABLE IF NOT EXISTS workflow.snapshots (
151    workflow_id     TEXT NOT NULL REFERENCES workflow.workflows(id),
152    event_seq       INTEGER NOT NULL,
153    state_json      TEXT NOT NULL,
154    created_at      DOUBLE PRECISION NOT NULL,
155    PRIMARY KEY (workflow_id, event_seq)
156);
157
158-- Plan-15 slice 3: workflow.api_keys retired in favour of the auth
159-- module (sessions / JWT / Zanzibar tuples). Table is dropped on
160-- migration; nothing here re-creates it.
161DROP TABLE IF EXISTS workflow.api_keys CASCADE;
162
163CREATE TABLE IF NOT EXISTS engine.events (
164    id              BIGSERIAL PRIMARY KEY,
165    ts              DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
166    namespace       TEXT NOT NULL,
167    subsystem       TEXT NOT NULL,
168    kind            TEXT NOT NULL,
169    payload         JSONB NOT NULL DEFAULT '{}'::jsonb
170);
171CREATE INDEX IF NOT EXISTS idx_engine_events_ns_id ON engine.events(namespace, id);
172CREATE INDEX IF NOT EXISTS idx_engine_events_ts_prune ON engine.events(ts);
173
174"#;
175
176
177/// Split a Postgres DDL script into individual statements ready for `sqlx::query`.
178///
179/// Drops pure-comment lines (those starting with `--` after optional whitespace)
180/// *before* splitting on `;`. Without this step, a semicolon inside a line comment
181/// (e.g. `-- Idempotent across startups; fresh installs pick the column up`) would
182/// split the surrounding comment into fragments — one of which is naked prose that
183/// Postgres tries to parse as SQL and rejects with `syntax error at or near "<word>"`.
184///
185/// The filter only drops *pure-comment* lines (leading whitespace then `--`), leaving
186/// `--`-after-code untouched. That keeps string literals safe (could legally contain
187/// `--`) and is conservative enough to remain correct if the SCHEMA grows more prose.
188fn sanitise_schema(schema: &str) -> Vec<String> {
189    let without_comments: String = schema
190        .lines()
191        .filter(|line| !line.trim_start().starts_with("--"))
192        .collect::<Vec<_>>()
193        .join("\n");
194
195    without_comments
196        .split(';')
197        .map(|s| s.trim().to_string())
198        .filter(|s| !s.is_empty())
199        .collect()
200}
201
202/// `Clone` is derived because the underlying `PgPool` is itself `Clone`
203/// (it's `Arc<PoolInner>` internally) — cloning the store hands back a
204/// new wrapper around the same connection pool. Required so engine
205/// composition (`EngineState<S>`) can derive `Clone` and pass through
206/// axum `with_state`.
207#[derive(Clone)]
208pub struct PostgresStore {
209    pool: PgPool,
210}
211
212impl PostgresStore {
213    pub async fn new(url: &str) -> Result<Self> {
214        let pool = PgPool::connect(url).await?;
215        Self::from_pool(pool).await
216    }
217
218    /// Build a store from an existing pool. Runs migrations on the target
219    /// database. Useful when the engine owns the pool (shared with other
220    /// modules) and hands a clone to the workflow module, or for tests that
221    /// point many stores at different databases in the same Postgres server.
222    pub async fn from_pool(pool: PgPool) -> Result<Self> {
223        let store = Self { pool };
224        store.migrate().await?;
225        Ok(store)
226    }
227
228    /// Expose the underlying pool (used by the engine to build a
229    /// `PgEngineEventBus` that shares the same connection pool).
230    pub fn pool(&self) -> &PgPool {
231        &self.pool
232    }
233
234    async fn migrate(&self) -> Result<()> {
235        assay_domain::engine::retry_ddl(3, || self.migrate_once()).await
236    }
237
238    async fn migrate_once(&self) -> Result<()> {
239        // One advisory-locked transaction so concurrent first boots
240        // serialise instead of racing the catalog inserts.
241        let mut tx = self.pool.begin().await?;
242        assay_domain::engine::acquire_schema_lock(&mut tx).await?;
243        for statement in sanitise_schema(SCHEMA) {
244            sqlx::query(&statement).execute(&mut *tx).await?;
245        }
246        super::relocation::run(&mut tx).await?;
247        // Drop the v0.13.0 LISTEN/NOTIFY triggers if they still exist on
248        // the target database. The Rust-managed CDC outbox in
249        // assay_domain::events is the replacement; leaving stale
250        // triggers in place would double-publish NOTIFYs with channels
251        // no one listens to. Triggers reference the post-relocation
252        // table names (workflow.workflows, workflow.activities) so they
253        // execute regardless of which side of the migration we're on.
254        sqlx::raw_sql(
255            r#"
256            DROP TRIGGER IF EXISTS workflow_runnable_notify ON workflow.workflows;
257            DROP TRIGGER IF EXISTS workflow_task_notify ON workflow.activities;
258            DROP FUNCTION IF EXISTS assay_notify_runnable();
259            DROP FUNCTION IF EXISTS assay_notify_task();
260            "#,
261        )
262        .execute(&mut *tx)
263        .await?;
264        sqlx::query("ALTER TABLE workflow.events ADD COLUMN IF NOT EXISTS activity_id BIGINT")
265            .execute(&mut *tx)
266            .await?;
267        sqlx::query(
268            "CREATE INDEX IF NOT EXISTS idx_wf_events_activity ON workflow.events(activity_id)",
269        )
270        .execute(&mut *tx)
271        .await?;
272        tx.commit().await?;
273        self.backfill_event_activity_ids().await?;
274        Ok(())
275    }
276
277    /// Populate `events.activity_id` on terminal activity events written
278    /// before the column existed. Without it every pre-upgrade completion
279    /// reads as unsettled and the reconciler appends a duplicate event.
280    /// Payloads that carry no usable id are stamped `NOT_A_SETTLEMENT` so
281    /// the scan terminates instead of revisiting them.
282    async fn backfill_event_activity_ids(&self) -> Result<()> {
283        const BATCH: i64 = 500;
284        loop {
285            let rows: Vec<(i64, Option<String>)> = sqlx::query_as(
286                "SELECT id, payload FROM workflow.events
287                 WHERE activity_id IS NULL
288                   AND event_type IN ('ActivityCompleted', 'ActivityFailed')
289                 LIMIT $1",
290            )
291            .bind(BATCH)
292            .fetch_all(&self.pool)
293            .await?;
294            if rows.is_empty() {
295                return Ok(());
296            }
297            let batch_len = rows.len() as i64;
298            for (id, payload) in rows {
299                sqlx::query("UPDATE workflow.events SET activity_id = $1 WHERE id = $2")
300                    .bind(payload_activity_id(payload.as_deref()))
301                    .bind(id)
302                    .execute(&self.pool)
303                    .await?;
304            }
305            if batch_len < BATCH {
306                return Ok(());
307            }
308        }
309    }
310
311    /// Try to acquire pg_advisory_lock for leader election.
312    /// Returns true if this instance is the leader (scheduler should run).
313    pub async fn try_acquire_leader_lock(&self) -> Result<bool> {
314        let row: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock(1)")
315            .fetch_one(&self.pool)
316            .await?;
317        Ok(row.0)
318    }
319}
320
321impl WorkflowStore for PostgresStore {
322    // ── Namespaces ─────────────────────────────────────────
323
324    async fn create_namespace(&self, name: &str) -> Result<()> {
325        sqlx::query("INSERT INTO workflow.namespaces (name, created_at) VALUES ($1, EXTRACT(EPOCH FROM NOW()))")
326            .bind(name)
327            .execute(&self.pool)
328            .await?;
329        Ok(())
330    }
331
332    async fn list_namespaces(&self) -> Result<Vec<crate::store::NamespaceRecord>> {
333        let rows = sqlx::query_as::<_, (String, f64)>(
334            "SELECT name, created_at FROM workflow.namespaces ORDER BY name",
335        )
336        .fetch_all(&self.pool)
337        .await?;
338        Ok(rows
339            .into_iter()
340            .map(|(name, created_at)| crate::store::NamespaceRecord { name, created_at })
341            .collect())
342    }
343
344    async fn delete_namespace(&self, name: &str) -> Result<bool> {
345        let res = sqlx::query("DELETE FROM workflow.namespaces WHERE name = $1 AND name != 'main'")
346            .bind(name)
347            .execute(&self.pool)
348            .await?;
349        Ok(res.rows_affected() > 0)
350    }
351
352    async fn get_namespace_stats(&self, namespace: &str) -> Result<crate::store::NamespaceStats> {
353        let total: (i64,) =
354            sqlx::query_as("SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1")
355                .bind(namespace)
356                .fetch_one(&self.pool)
357                .await?;
358        let running: (i64,) = sqlx::query_as(
359            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'RUNNING'",
360        )
361        .bind(namespace)
362        .fetch_one(&self.pool)
363        .await?;
364        let pending: (i64,) = sqlx::query_as(
365            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'PENDING'",
366        )
367        .bind(namespace)
368        .fetch_one(&self.pool)
369        .await?;
370        let completed: (i64,) = sqlx::query_as(
371            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'COMPLETED'",
372        )
373        .bind(namespace)
374        .fetch_one(&self.pool)
375        .await?;
376        let failed: (i64,) = sqlx::query_as(
377            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'FAILED'",
378        )
379        .bind(namespace)
380        .fetch_one(&self.pool)
381        .await?;
382        let schedules: (i64,) =
383            sqlx::query_as("SELECT COUNT(*) FROM workflow.schedules WHERE namespace = $1")
384                .bind(namespace)
385                .fetch_one(&self.pool)
386                .await?;
387        let workers: (i64,) =
388            sqlx::query_as("SELECT COUNT(*) FROM workflow.workers WHERE namespace = $1")
389                .bind(namespace)
390                .fetch_one(&self.pool)
391                .await?;
392
393        Ok(crate::store::NamespaceStats {
394            namespace: namespace.to_string(),
395            total_workflows: total.0,
396            running: running.0,
397            pending: pending.0,
398            completed: completed.0,
399            failed: failed.0,
400            schedules: schedules.0,
401            workers: workers.0,
402        })
403    }
404
405    // ── Workflows ──────────────────────────────────────────
406
407    async fn create_workflow(&self, wf: &WorkflowRecord) -> Result<()> {
408        sqlx::query(
409            "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)
410             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
411        )
412        .bind(&wf.id)
413        .bind(&wf.namespace)
414        .bind(&wf.run_id)
415        .bind(&wf.workflow_type)
416        .bind(&wf.task_queue)
417        .bind(&wf.status)
418        .bind(&wf.input)
419        .bind(&wf.result)
420        .bind(&wf.error)
421        .bind(&wf.parent_id)
422        .bind(&wf.claimed_by)
423        .bind(&wf.search_attributes)
424        .bind(wf.archived_at)
425        .bind(&wf.archive_uri)
426        .bind(wf.created_at)
427        .bind(wf.updated_at)
428        .bind(wf.completed_at)
429        .execute(&self.pool)
430        .await?;
431        Ok(())
432    }
433
434    async fn get_workflow(&self, id: &str) -> Result<Option<WorkflowRecord>> {
435        let row = sqlx::query_as::<_, PgWorkflowRow>(
436            "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 = $1",
437        )
438        .bind(id)
439        .fetch_optional(&self.pool)
440        .await?;
441        Ok(row.map(Into::into))
442    }
443
444    async fn list_workflows(
445        &self,
446        namespace: &str,
447        status: Option<WorkflowStatus>,
448        workflow_type: Option<&str>,
449        search_attrs_filter: Option<&str>,
450        limit: i64,
451        offset: i64,
452    ) -> Result<Vec<WorkflowRecord>> {
453        let status_str = status.map(|s| s.to_string());
454
455        let filter_pairs: Vec<(String, serde_json::Value)> = search_attrs_filter
456            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
457            .and_then(|v| v.as_object().cloned())
458            .map(|m| m.into_iter().collect())
459            .unwrap_or_default();
460
461        let mut sql = String::from(
462            "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
463             FROM workflow.workflows
464             WHERE namespace = $1
465               AND ($2::TEXT IS NULL OR status = $2)
466               AND ($3::TEXT IS NULL OR workflow_type = $3)",
467        );
468        // Bind placeholders for the filter follow $3; next index is 4.
469        let mut idx = 4usize;
470        for _ in &filter_pairs {
471            sql.push_str(&format!(
472                " AND (search_attributes::jsonb)->>${} = ${}",
473                idx,
474                idx + 1
475            ));
476            idx += 2;
477        }
478        sql.push_str(&format!(
479            " ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
480            idx,
481            idx + 1
482        ));
483
484        let mut q = sqlx::query_as::<_, PgWorkflowRow>(&sql)
485            .bind(namespace)
486            .bind(&status_str)
487            .bind(workflow_type);
488        for (key, value) in &filter_pairs {
489            q = q.bind(key.clone());
490            // JSONB ->> always returns TEXT; compare by stringified value.
491            let as_text = match value {
492                serde_json::Value::String(s) => s.clone(),
493                other => other.to_string(),
494            };
495            q = q.bind(as_text);
496        }
497        let rows = q.bind(limit).bind(offset).fetch_all(&self.pool).await?;
498        Ok(rows.into_iter().map(Into::into).collect())
499    }
500
501    async fn update_workflow_status(
502        &self,
503        id: &str,
504        status: WorkflowStatus,
505        result: Option<&str>,
506        error: Option<&str>,
507    ) -> Result<()> {
508        let now = timestamp_now();
509        let completed_at = if status.is_terminal() {
510            Some(now)
511        } else {
512            None
513        };
514        sqlx::query(
515            "UPDATE workflow.workflows SET status = $1, result = COALESCE($2, result), error = COALESCE($3, error), updated_at = $4, completed_at = COALESCE($5, completed_at) WHERE id = $6",
516        )
517        .bind(status.to_string())
518        .bind(result)
519        .bind(error)
520        .bind(now)
521        .bind(completed_at)
522        .bind(id)
523        .execute(&self.pool)
524        .await?;
525        Ok(())
526    }
527
528    async fn claim_workflow(&self, id: &str, worker_id: &str) -> Result<bool> {
529        let res = sqlx::query(
530            "UPDATE workflow.workflows SET claimed_by = $1, status = 'RUNNING', updated_at = $2 WHERE id = $3 AND claimed_by IS NULL",
531        )
532        .bind(worker_id)
533        .bind(timestamp_now())
534        .bind(id)
535        .execute(&self.pool)
536        .await?;
537        Ok(res.rows_affected() > 0)
538    }
539
540    async fn mark_workflow_dispatchable(&self, workflow_id: &str) -> Result<()> {
541        sqlx::query("UPDATE workflow.workflows SET needs_dispatch = TRUE WHERE id = $1")
542            .bind(workflow_id)
543            .execute(&self.pool)
544            .await?;
545        Ok(())
546    }
547
548    async fn claim_workflow_task(
549        &self,
550        task_queue: &str,
551        worker_id: &str,
552    ) -> Result<Option<WorkflowRecord>> {
553        let now = timestamp_now();
554        // Atomic claim with FOR UPDATE SKIP LOCKED so multiple engine
555        // replicas don't fight over the same workflow task.
556        let row = sqlx::query_as::<_, PgWorkflowRow>(
557            "UPDATE workflow.workflows
558             SET dispatch_claimed_by = $1, dispatch_last_heartbeat = $2, needs_dispatch = FALSE
559             WHERE id = (
560                SELECT id FROM workflow.workflows
561                WHERE task_queue = $3
562                  AND needs_dispatch = TRUE
563                  AND dispatch_claimed_by IS NULL
564                  AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
565                ORDER BY updated_at ASC
566                FOR UPDATE SKIP LOCKED
567                LIMIT 1
568             )
569             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",
570        )
571        .bind(worker_id)
572        .bind(now)
573        .bind(task_queue)
574        .fetch_optional(&self.pool)
575        .await?;
576        Ok(row.map(Into::into))
577    }
578
579    async fn release_workflow_task(&self, workflow_id: &str, worker_id: &str) -> Result<()> {
580        sqlx::query(
581            "UPDATE workflow.workflows
582             SET dispatch_claimed_by = NULL, dispatch_last_heartbeat = NULL
583             WHERE id = $1 AND dispatch_claimed_by = $2",
584        )
585        .bind(workflow_id)
586        .bind(worker_id)
587        .execute(&self.pool)
588        .await?;
589        Ok(())
590    }
591
592    async fn release_stale_dispatch_leases(&self, now: f64, timeout_secs: f64) -> Result<u64> {
593        let res = sqlx::query(
594            "UPDATE workflow.workflows
595             SET dispatch_claimed_by = NULL,
596                 dispatch_last_heartbeat = NULL,
597                 needs_dispatch = TRUE
598             WHERE dispatch_claimed_by IS NOT NULL
599               AND ($1 - dispatch_last_heartbeat) > $2
600               AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')",
601        )
602        .bind(now)
603        .bind(timeout_secs)
604        .execute(&self.pool)
605        .await?;
606        Ok(res.rows_affected())
607    }
608
609    // ── Events ─────────────────────────────────────────────
610
611    async fn append_event(&self, ev: &WorkflowEvent) -> Result<i64> {
612        let row: (i64,) = sqlx::query_as(
613            "INSERT INTO workflow.events (workflow_id, seq, event_type, payload, timestamp) VALUES ($1, $2, $3, $4, $5) RETURNING id",
614        )
615        .bind(&ev.workflow_id)
616        .bind(ev.seq)
617        .bind(&ev.event_type)
618        .bind(&ev.payload)
619        .bind(ev.timestamp)
620        .fetch_one(&self.pool)
621        .await?;
622        Ok(row.0)
623    }
624
625    async fn list_events(&self, workflow_id: &str) -> Result<Vec<WorkflowEvent>> {
626        let rows = sqlx::query_as::<_, PgEventRow>(
627            "SELECT id, workflow_id, seq, event_type, payload, timestamp FROM workflow.events WHERE workflow_id = $1 ORDER BY seq ASC",
628        )
629        .bind(workflow_id)
630        .fetch_all(&self.pool)
631        .await?;
632        Ok(rows.into_iter().map(Into::into).collect())
633    }
634
635    async fn list_events_page(
636        &self,
637        workflow_id: &str,
638        cursor: Option<i32>,
639        limit: i64,
640        descending: bool,
641    ) -> Result<Vec<WorkflowEvent>> {
642        let limit = limit.clamp(0, 1_000);
643        if limit == 0 {
644            return Ok(Vec::new());
645        }
646        let rows = if descending {
647            sqlx::query_as::<_, PgEventRow>(
648                "SELECT id, workflow_id, seq, event_type, payload, timestamp
649                 FROM workflow.events
650                 WHERE workflow_id = $1 AND ($2::INTEGER IS NULL OR seq < $2)
651                 ORDER BY seq DESC LIMIT $3",
652            )
653            .bind(workflow_id)
654            .bind(cursor)
655            .bind(limit)
656            .fetch_all(&self.pool)
657            .await?
658        } else {
659            sqlx::query_as::<_, PgEventRow>(
660                "SELECT id, workflow_id, seq, event_type, payload, timestamp
661                 FROM workflow.events
662                 WHERE workflow_id = $1 AND ($2::INTEGER IS NULL OR seq > $2)
663                 ORDER BY seq ASC LIMIT $3",
664            )
665            .bind(workflow_id)
666            .bind(cursor)
667            .bind(limit)
668            .fetch_all(&self.pool)
669            .await?
670        };
671        Ok(rows.into_iter().map(Into::into).collect())
672    }
673
674    async fn get_event_count(&self, workflow_id: &str) -> Result<i64> {
675        let row: (i64,) =
676            sqlx::query_as("SELECT COUNT(*) FROM workflow.events WHERE workflow_id = $1")
677                .bind(workflow_id)
678                .fetch_one(&self.pool)
679                .await?;
680        Ok(row.0)
681    }
682
683    // ── Activities ──────────────────────────────────────────
684
685    async fn create_activity(&self, act: &WorkflowActivity) -> Result<i64> {
686        let row: (i64,) = sqlx::query_as(
687            "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)
688             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id",
689        )
690        .bind(&act.workflow_id)
691        .bind(act.seq)
692        .bind(&act.name)
693        .bind(&act.task_queue)
694        .bind(&act.input)
695        .bind(&act.status)
696        .bind(act.attempt)
697        .bind(act.max_attempts)
698        .bind(act.initial_interval_secs)
699        .bind(act.backoff_coefficient)
700        .bind(act.start_to_close_secs)
701        .bind(act.heartbeat_timeout_secs)
702        .bind(act.scheduled_at)
703        .fetch_one(&self.pool)
704        .await?;
705        Ok(row.0)
706    }
707
708    async fn get_activity(&self, id: i64) -> Result<Option<WorkflowActivity>> {
709        let row = sqlx::query_as::<_, PgActivityRow>(
710            "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
711             FROM workflow.activities WHERE id = $1",
712        )
713        .bind(id)
714        .fetch_optional(&self.pool)
715        .await?;
716        Ok(row.map(Into::into))
717    }
718
719    async fn get_activity_by_workflow_seq(
720        &self,
721        workflow_id: &str,
722        seq: i32,
723    ) -> Result<Option<WorkflowActivity>> {
724        let row = sqlx::query_as::<_, PgActivityRow>(
725            "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
726             FROM workflow.activities WHERE workflow_id = $1 AND seq = $2",
727        )
728        .bind(workflow_id)
729        .bind(seq)
730        .fetch_optional(&self.pool)
731        .await?;
732        Ok(row.map(Into::into))
733    }
734
735    async fn claim_activity(
736        &self,
737        task_queue: &str,
738        worker_id: &str,
739    ) -> Result<Option<WorkflowActivity>> {
740        let now = timestamp_now();
741        // Atomic claim using FOR UPDATE SKIP LOCKED — prevents contention
742        // between multiple assay serve instances claiming the same activity
743        let row = sqlx::query_as::<_, PgActivityRow>(
744            "UPDATE workflow.activities SET status = 'RUNNING', claimed_by = $1, started_at = $2
745             WHERE id = (
746                SELECT id FROM workflow.activities
747                WHERE task_queue = $3 AND status = 'PENDING'
748                ORDER BY scheduled_at ASC
749                FOR UPDATE SKIP LOCKED
750                LIMIT 1
751             )
752             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",
753        )
754        .bind(worker_id)
755        .bind(now)
756        .bind(task_queue)
757        .fetch_optional(&self.pool)
758        .await?;
759        Ok(row.map(Into::into))
760    }
761
762    async fn requeue_activity_for_retry(
763        &self,
764        id: i64,
765        next_attempt: i32,
766        next_scheduled_at: f64,
767    ) -> Result<()> {
768        sqlx::query(
769            "UPDATE workflow.activities
770             SET status = 'PENDING', attempt = $1, scheduled_at = $2,
771                 claimed_by = NULL, started_at = NULL, last_heartbeat = NULL,
772                 error = NULL
773             WHERE id = $3",
774        )
775        .bind(next_attempt)
776        .bind(next_scheduled_at)
777        .bind(id)
778        .execute(&self.pool)
779        .await?;
780        Ok(())
781    }
782
783    async fn retry_failed_activity(
784        &self,
785        workflow_id: &str,
786        requested_by: &str,
787        reason: &str,
788        requested_at: f64,
789    ) -> Result<RetryFailedActivityResult> {
790        let mut tx = self.pool.begin().await?;
791        let workflow: Option<(String, Option<String>, Option<f64>)> = sqlx::query_as(
792            "SELECT status, parent_id, archived_at FROM workflow.workflows WHERE id = $1 FOR UPDATE",
793        )
794        .bind(workflow_id)
795        .fetch_optional(&mut *tx)
796        .await?;
797        let Some((status, parent_id, archived_at)) = workflow else {
798            return Ok(RetryFailedActivityResult::NotFound);
799        };
800        if let Some(denial) = retry_denial(status, parent_id, archived_at) {
801            return Ok(denial);
802        }
803
804        let failed = sqlx::query_as::<_, PgActivityRow>(RETRY_ACTIVITY_SELECT)
805            .bind(workflow_id)
806            .fetch_optional(&mut *tx)
807            .await?;
808        let Some(failed) = failed else {
809            return Ok(RetryFailedActivityResult::NoFailedActivity);
810        };
811        let failed_event_seq: (i32,) = sqlx::query_as(
812            "SELECT seq FROM workflow.events
813             WHERE workflow_id = $1 AND event_type = 'ActivityFailed'
814             ORDER BY seq DESC LIMIT 1",
815        )
816        .bind(workflow_id)
817        .fetch_one(&mut *tx)
818        .await?;
819        let invalidated =
820            sqlx::query("DELETE FROM workflow.activities WHERE workflow_id = $1 AND seq > $2")
821                .bind(workflow_id)
822                .bind(failed.seq)
823                .execute(&mut *tx)
824                .await?
825                .rows_affected();
826        let activity = sqlx::query_as::<_, PgActivityRow>(RETRY_ACTIVITY_UPDATE)
827            .bind(requested_at)
828            .bind(failed.id)
829            .fetch_one(&mut *tx)
830            .await?;
831        // The ActivityFailed event stays in history, but it no longer
832        // records this activity's settlement — the row is open again.
833        sqlx::query("UPDATE workflow.events SET activity_id = $1 WHERE activity_id = $2")
834            .bind(NOT_A_SETTLEMENT)
835            .bind(failed.id)
836            .execute(&mut *tx)
837            .await?;
838        sqlx::query(
839            "UPDATE workflow.workflows
840             SET status = 'WAITING', result = NULL, error = NULL, completed_at = NULL,
841                 updated_at = $1, needs_dispatch = FALSE, dispatch_claimed_by = NULL,
842                 dispatch_last_heartbeat = NULL
843             WHERE id = $2",
844        )
845        .bind(requested_at)
846        .bind(workflow_id)
847        .execute(&mut *tx)
848        .await?;
849        let event_seq: (i32,) = sqlx::query_as(
850            "SELECT COALESCE(MAX(seq), 0) + 1 FROM workflow.events WHERE workflow_id = $1",
851        )
852        .bind(workflow_id)
853        .fetch_one(&mut *tx)
854        .await?;
855        let payload = RetryEvent {
856            activity_id: failed.id,
857            activity_seq: failed.seq,
858            activity_name: &failed.name,
859            failed_event_seq: failed_event_seq.0,
860            requested_by,
861            reason,
862            invalidated_activities: invalidated,
863        }
864        .payload();
865        sqlx::query(
866            "INSERT INTO workflow.events (workflow_id, seq, event_type, payload, timestamp)
867             VALUES ($1, $2, 'ActivityRetryRequested', $3, $4)",
868        )
869        .bind(workflow_id)
870        .bind(event_seq.0)
871        .bind(payload.to_string())
872        .bind(requested_at)
873        .execute(&mut *tx)
874        .await?;
875        tx.commit().await?;
876        Ok(RetryFailedActivityResult::Retried(Box::new(
877            RetriedActivity {
878                activity: activity.into(),
879                invalidated_activities: invalidated,
880            },
881        )))
882    }
883
884    async fn complete_activity(
885        &self,
886        id: i64,
887        result: Option<&str>,
888        error: Option<&str>,
889        failed: bool,
890    ) -> Result<()> {
891        let status = if failed { "FAILED" } else { "COMPLETED" };
892        sqlx::query(
893            "UPDATE workflow.activities SET status = $1, result = $2, error = $3, completed_at = $4 WHERE id = $5",
894        )
895        .bind(status)
896        .bind(result)
897        .bind(error)
898        .bind(timestamp_now())
899        .bind(id)
900        .execute(&self.pool)
901        .await?;
902        Ok(())
903    }
904
905    async fn settle_activity(&self, settlement: &ActivitySettlement<'_>) -> Result<SettleOutcome> {
906        let mut tx = self.pool.begin().await?;
907        let current: Option<(String,)> =
908            sqlx::query_as("SELECT status FROM workflow.activities WHERE id = $1 FOR UPDATE")
909                .bind(settlement.activity_id)
910                .fetch_optional(&mut *tx)
911                .await?;
912        let Some((status,)) = current else {
913            return Ok(SettleOutcome::Unknown);
914        };
915        let settled = matches!(status.as_str(), "COMPLETED" | "FAILED");
916        let event_id: Option<(i64,)> = sqlx::query_as(
917            "SELECT id FROM workflow.events
918             WHERE workflow_id = $1 AND activity_id = $2
919               AND event_type IN ('ActivityCompleted', 'ActivityFailed')
920             LIMIT 1",
921        )
922        .bind(settlement.workflow_id)
923        .bind(settlement.activity_id)
924        .fetch_optional(&mut *tx)
925        .await?;
926
927        if !settled {
928            sqlx::query(
929                "UPDATE workflow.activities
930                 SET status = $1, result = $2, error = $3, completed_at = $4
931                 WHERE id = $5",
932            )
933            .bind(if settlement.failed {
934                "FAILED"
935            } else {
936                "COMPLETED"
937            })
938            .bind(settlement.result)
939            .bind(settlement.error)
940            .bind(settlement.now)
941            .bind(settlement.activity_id)
942            .execute(&mut *tx)
943            .await?;
944        }
945        // An open activity always gets its event, even in the shape a
946        // superseded settlement event would otherwise mask: reaching a
947        // terminal status without the matching event is the defect.
948        if !settled || event_id.is_none() {
949            let seq: (i32,) = sqlx::query_as(
950                "SELECT COALESCE(MAX(seq), 0) + 1 FROM workflow.events WHERE workflow_id = $1",
951            )
952            .bind(settlement.workflow_id)
953            .fetch_one(&mut *tx)
954            .await?;
955            sqlx::query(
956                "INSERT INTO workflow.events (workflow_id, seq, event_type, payload, activity_id, timestamp)
957                 VALUES ($1, $2, $3, $4, $5, $6)",
958            )
959            .bind(settlement.workflow_id)
960            .bind(seq.0)
961            .bind(settlement.event_type)
962            .bind(settlement.payload)
963            .bind(settlement.activity_id)
964            .bind(settlement.now)
965            .execute(&mut *tx)
966            .await?;
967        }
968        sqlx::query("UPDATE workflow.workflows SET needs_dispatch = TRUE WHERE id = $1")
969            .bind(settlement.workflow_id)
970            .execute(&mut *tx)
971            .await?;
972        tx.commit().await?;
973        Ok(settle_outcome(settled, event_id.is_some()))
974    }
975
976    async fn list_unsettled_activities(&self, limit: i64) -> Result<Vec<WorkflowActivity>> {
977        let rows = sqlx::query_as::<_, PgActivityRow>(UNSETTLED_ACTIVITY_SELECT)
978            .bind(limit)
979            .fetch_all(&self.pool)
980            .await?;
981        Ok(rows.into_iter().map(Into::into).collect())
982    }
983
984    async fn heartbeat_activity(&self, id: i64, _details: Option<&str>) -> Result<()> {
985        sqlx::query("UPDATE workflow.activities SET last_heartbeat = $1 WHERE id = $2")
986            .bind(timestamp_now())
987            .bind(id)
988            .execute(&self.pool)
989            .await?;
990        Ok(())
991    }
992
993    async fn get_timed_out_activities(&self, now: f64) -> Result<Vec<WorkflowActivity>> {
994        let rows = sqlx::query_as::<_, PgActivityRow>(
995            "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
996             FROM workflow.activities
997             WHERE status = 'RUNNING'
998               AND heartbeat_timeout_secs IS NOT NULL
999               AND ($1 - COALESCE(last_heartbeat, started_at)) > heartbeat_timeout_secs",
1000        )
1001        .bind(now)
1002        .fetch_all(&self.pool)
1003        .await?;
1004        Ok(rows.into_iter().map(Into::into).collect())
1005    }
1006
1007    // ── Timers ──────────────────────────────────────────────
1008
1009    async fn create_timer(&self, timer: &WorkflowTimer) -> Result<i64> {
1010        // Idempotent: ON CONFLICT (workflow_id, seq) DO NOTHING.
1011        // If a row already exists, RETURNING produces no rows — fall back to SELECT.
1012        let inserted: Option<(i64,)> = sqlx::query_as(
1013            "INSERT INTO workflow.timers (workflow_id, seq, fire_at, fired)
1014             VALUES ($1, $2, $3, FALSE)
1015             ON CONFLICT (workflow_id, seq) DO NOTHING
1016             RETURNING id",
1017        )
1018        .bind(&timer.workflow_id)
1019        .bind(timer.seq)
1020        .bind(timer.fire_at)
1021        .fetch_optional(&self.pool)
1022        .await?;
1023
1024        if let Some((id,)) = inserted {
1025            return Ok(id);
1026        }
1027
1028        // Row already existed — return its id.
1029        let (id,): (i64,) =
1030            sqlx::query_as("SELECT id FROM workflow.timers WHERE workflow_id = $1 AND seq = $2")
1031                .bind(&timer.workflow_id)
1032                .bind(timer.seq)
1033                .fetch_one(&self.pool)
1034                .await?;
1035        Ok(id)
1036    }
1037
1038    async fn cancel_pending_activities(&self, workflow_id: &str) -> Result<u64> {
1039        let res = sqlx::query(
1040            "UPDATE workflow.activities SET status = 'CANCELLED', completed_at = $1
1041             WHERE workflow_id = $2 AND status = 'PENDING'",
1042        )
1043        .bind(timestamp_now())
1044        .bind(workflow_id)
1045        .execute(&self.pool)
1046        .await?;
1047        Ok(res.rows_affected())
1048    }
1049
1050    async fn cancel_pending_timers(&self, workflow_id: &str) -> Result<u64> {
1051        let res = sqlx::query(
1052            "UPDATE workflow.timers SET fired = TRUE
1053             WHERE workflow_id = $1 AND fired = FALSE",
1054        )
1055        .bind(workflow_id)
1056        .execute(&self.pool)
1057        .await?;
1058        Ok(res.rows_affected())
1059    }
1060
1061    async fn get_timer_by_workflow_seq(
1062        &self,
1063        workflow_id: &str,
1064        seq: i32,
1065    ) -> Result<Option<WorkflowTimer>> {
1066        let row = sqlx::query_as::<_, PgTimerRow>(
1067            "SELECT id, workflow_id, seq, fire_at, fired
1068             FROM workflow.timers WHERE workflow_id = $1 AND seq = $2",
1069        )
1070        .bind(workflow_id)
1071        .bind(seq)
1072        .fetch_optional(&self.pool)
1073        .await?;
1074        Ok(row.map(Into::into))
1075    }
1076
1077    async fn fire_due_timers(&self, now: f64) -> Result<Vec<WorkflowTimer>> {
1078        let rows = sqlx::query_as::<_, PgTimerRow>(
1079            "UPDATE workflow.timers SET fired = TRUE
1080             WHERE fired = FALSE AND fire_at <= $1
1081             RETURNING id, workflow_id, seq, fire_at, fired",
1082        )
1083        .bind(now)
1084        .fetch_all(&self.pool)
1085        .await?;
1086        Ok(rows.into_iter().map(Into::into).collect())
1087    }
1088
1089    // ── Signals ─────────────────────────────────────────────
1090
1091    async fn send_signal(&self, sig: &WorkflowSignal) -> Result<i64> {
1092        let row: (i64,) = sqlx::query_as(
1093            "INSERT INTO workflow.signals (workflow_id, name, payload, consumed, received_at) VALUES ($1, $2, $3, FALSE, $4) RETURNING id",
1094        )
1095        .bind(&sig.workflow_id)
1096        .bind(&sig.name)
1097        .bind(&sig.payload)
1098        .bind(sig.received_at)
1099        .fetch_one(&self.pool)
1100        .await?;
1101        Ok(row.0)
1102    }
1103
1104    async fn deliver_signal(&self, sig: &WorkflowSignal, payload_json: &str) -> Result<i64> {
1105        let mut tx = self.pool.begin().await?;
1106        let row: (i64,) = sqlx::query_as(
1107            "INSERT INTO workflow.signals (workflow_id, name, payload, consumed, received_at) VALUES ($1, $2, $3, FALSE, $4) RETURNING id",
1108        )
1109        .bind(&sig.workflow_id)
1110        .bind(&sig.name)
1111        .bind(&sig.payload)
1112        .bind(sig.received_at)
1113        .fetch_one(&mut *tx)
1114        .await?;
1115        let seq: (i32,) = sqlx::query_as(
1116            "SELECT COALESCE(MAX(seq), 0) + 1 FROM workflow.events WHERE workflow_id = $1",
1117        )
1118        .bind(&sig.workflow_id)
1119        .fetch_one(&mut *tx)
1120        .await?;
1121        sqlx::query(
1122            "INSERT INTO workflow.events (workflow_id, seq, event_type, payload, timestamp)
1123             VALUES ($1, $2, 'SignalReceived', $3, $4)",
1124        )
1125        .bind(&sig.workflow_id)
1126        .bind(seq.0)
1127        .bind(payload_json)
1128        .bind(sig.received_at)
1129        .execute(&mut *tx)
1130        .await?;
1131        sqlx::query("UPDATE workflow.workflows SET needs_dispatch = TRUE WHERE id = $1")
1132            .bind(&sig.workflow_id)
1133            .execute(&mut *tx)
1134            .await?;
1135        tx.commit().await?;
1136        Ok(row.0)
1137    }
1138
1139    async fn consume_signals(&self, workflow_id: &str, name: &str) -> Result<Vec<WorkflowSignal>> {
1140        let rows = sqlx::query_as::<_, PgSignalRow>(
1141            "UPDATE workflow.signals SET consumed = TRUE
1142             WHERE workflow_id = $1 AND name = $2 AND consumed = FALSE
1143             RETURNING id, workflow_id, name, payload, consumed, received_at",
1144        )
1145        .bind(workflow_id)
1146        .bind(name)
1147        .fetch_all(&self.pool)
1148        .await?;
1149        Ok(rows.into_iter().map(Into::into).collect())
1150    }
1151
1152    // ── Schedules ───────────────────────────────────────────
1153
1154    async fn create_schedule(&self, sched: &WorkflowSchedule) -> Result<()> {
1155        sqlx::query(
1156            "INSERT INTO workflow.schedules (namespace, name, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at)
1157             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
1158        )
1159        .bind(&sched.namespace)
1160        .bind(&sched.name)
1161        .bind(&sched.workflow_type)
1162        .bind(&sched.cron_expr)
1163        .bind(&sched.timezone)
1164        .bind(&sched.input)
1165        .bind(&sched.task_queue)
1166        .bind(&sched.overlap_policy)
1167        .bind(sched.paused)
1168        .bind(sched.last_run_at)
1169        .bind(crate::scheduler::seed_next_run(sched))
1170        .bind(&sched.last_workflow_id)
1171        .bind(sched.created_at)
1172        .execute(&self.pool)
1173        .await?;
1174        Ok(())
1175    }
1176
1177    async fn get_schedule(&self, namespace: &str, name: &str) -> Result<Option<WorkflowSchedule>> {
1178        let row = sqlx::query_as::<_, PgScheduleRow>(
1179            "SELECT namespace, name, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at FROM workflow.schedules WHERE namespace = $1 AND name = $2",
1180        )
1181        .bind(namespace)
1182        .bind(name)
1183        .fetch_optional(&self.pool)
1184        .await?;
1185        Ok(row.map(Into::into))
1186    }
1187
1188    async fn list_schedules(&self, namespace: &str) -> Result<Vec<WorkflowSchedule>> {
1189        let rows = sqlx::query_as::<_, PgScheduleRow>(
1190            "SELECT namespace, name, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at FROM workflow.schedules WHERE namespace = $1 ORDER BY name",
1191        )
1192        .bind(namespace)
1193        .fetch_all(&self.pool)
1194        .await?;
1195        Ok(rows.into_iter().map(Into::into).collect())
1196    }
1197
1198    async fn update_schedule_last_run(
1199        &self,
1200        namespace: &str,
1201        name: &str,
1202        last_run_at: f64,
1203        next_run_at: f64,
1204        workflow_id: &str,
1205    ) -> Result<()> {
1206        sqlx::query(
1207            "UPDATE workflow.schedules SET last_run_at = $1, next_run_at = $2, last_workflow_id = $3 WHERE namespace = $4 AND name = $5",
1208        )
1209        .bind(last_run_at)
1210        .bind(next_run_at)
1211        .bind(workflow_id)
1212        .bind(namespace)
1213        .bind(name)
1214        .execute(&self.pool)
1215        .await?;
1216        Ok(())
1217    }
1218
1219    async fn delete_schedule(&self, namespace: &str, name: &str) -> Result<bool> {
1220        let res = sqlx::query("DELETE FROM workflow.schedules WHERE namespace = $1 AND name = $2")
1221            .bind(namespace)
1222            .bind(name)
1223            .execute(&self.pool)
1224            .await?;
1225        Ok(res.rows_affected() > 0)
1226    }
1227
1228    async fn list_archivable_workflows(
1229        &self,
1230        cutoff: f64,
1231        limit: i64,
1232    ) -> Result<Vec<WorkflowRecord>> {
1233        let rows = sqlx::query_as::<_, PgWorkflowRow>(
1234            "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
1235             FROM workflow.workflows
1236             WHERE status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
1237               AND completed_at IS NOT NULL
1238               AND completed_at < $1
1239               AND archived_at IS NULL
1240             ORDER BY completed_at ASC
1241             LIMIT $2",
1242        )
1243        .bind(cutoff)
1244        .bind(limit)
1245        .fetch_all(&self.pool)
1246        .await?;
1247        Ok(rows.into_iter().map(Into::into).collect())
1248    }
1249
1250    async fn mark_archived_and_purge(
1251        &self,
1252        workflow_id: &str,
1253        archive_uri: &str,
1254        archived_at: f64,
1255    ) -> Result<()> {
1256        let mut tx = self.pool.begin().await?;
1257        sqlx::query("DELETE FROM workflow.events WHERE workflow_id = $1")
1258            .bind(workflow_id)
1259            .execute(&mut *tx)
1260            .await?;
1261        sqlx::query("DELETE FROM workflow.activities WHERE workflow_id = $1")
1262            .bind(workflow_id)
1263            .execute(&mut *tx)
1264            .await?;
1265        sqlx::query("DELETE FROM workflow.timers WHERE workflow_id = $1")
1266            .bind(workflow_id)
1267            .execute(&mut *tx)
1268            .await?;
1269        sqlx::query("DELETE FROM workflow.signals WHERE workflow_id = $1")
1270            .bind(workflow_id)
1271            .execute(&mut *tx)
1272            .await?;
1273        sqlx::query("DELETE FROM workflow.snapshots WHERE workflow_id = $1")
1274            .bind(workflow_id)
1275            .execute(&mut *tx)
1276            .await?;
1277        sqlx::query(
1278            "UPDATE workflow.workflows SET archived_at = $1, archive_uri = $2 WHERE id = $3",
1279        )
1280        .bind(archived_at)
1281        .bind(archive_uri)
1282        .bind(workflow_id)
1283        .execute(&mut *tx)
1284        .await?;
1285        tx.commit().await?;
1286        Ok(())
1287    }
1288
1289    async fn upsert_search_attributes(&self, workflow_id: &str, patch_json: &str) -> Result<()> {
1290        let current: Option<(Option<String>,)> =
1291            sqlx::query_as("SELECT search_attributes FROM workflow.workflows WHERE id = $1")
1292                .bind(workflow_id)
1293                .fetch_optional(&self.pool)
1294                .await?;
1295        let merged = crate::store::sqlite::merge_search_attrs(
1296            current.and_then(|(s,)| s).as_deref(),
1297            patch_json,
1298        )?;
1299        sqlx::query("UPDATE workflow.workflows SET search_attributes = $1 WHERE id = $2")
1300            .bind(merged)
1301            .bind(workflow_id)
1302            .execute(&self.pool)
1303            .await?;
1304        Ok(())
1305    }
1306
1307    async fn update_schedule(
1308        &self,
1309        namespace: &str,
1310        name: &str,
1311        patch: &SchedulePatch,
1312    ) -> Result<Option<WorkflowSchedule>> {
1313        let mut sets: Vec<String> = Vec::new();
1314        let mut idx = 1usize;
1315        if patch.cron_expr.is_some() {
1316            sets.push(format!("cron_expr = ${idx}"));
1317            idx += 1;
1318        }
1319        if patch.timezone.is_some() {
1320            sets.push(format!("timezone = ${idx}"));
1321            idx += 1;
1322        }
1323        if patch.input.is_some() {
1324            sets.push(format!("input = ${idx}"));
1325            idx += 1;
1326        }
1327        if patch.task_queue.is_some() {
1328            sets.push(format!("task_queue = ${idx}"));
1329            idx += 1;
1330        }
1331        if patch.overlap_policy.is_some() {
1332            sets.push(format!("overlap_policy = ${idx}"));
1333            idx += 1;
1334        }
1335        if sets.is_empty() {
1336            return self.get_schedule(namespace, name).await;
1337        }
1338        let sql = format!(
1339            "UPDATE workflow.schedules SET {} WHERE namespace = ${} AND name = ${}",
1340            sets.join(", "),
1341            idx,
1342            idx + 1
1343        );
1344        let mut q = sqlx::query(&sql);
1345        if let Some(ref v) = patch.cron_expr {
1346            q = q.bind(v);
1347        }
1348        if let Some(ref v) = patch.timezone {
1349            q = q.bind(v);
1350        }
1351        if let Some(ref v) = patch.input {
1352            q = q.bind(v.to_string());
1353        }
1354        if let Some(ref v) = patch.task_queue {
1355            q = q.bind(v);
1356        }
1357        if let Some(ref v) = patch.overlap_policy {
1358            q = q.bind(v);
1359        }
1360        let res = q.bind(namespace).bind(name).execute(&self.pool).await?;
1361        if res.rows_affected() == 0 {
1362            return Ok(None);
1363        }
1364        self.get_schedule(namespace, name).await
1365    }
1366
1367    async fn set_schedule_paused(
1368        &self,
1369        namespace: &str,
1370        name: &str,
1371        paused: bool,
1372    ) -> Result<Option<WorkflowSchedule>> {
1373        let res = sqlx::query(
1374            "UPDATE workflow.schedules SET paused = $1 WHERE namespace = $2 AND name = $3",
1375        )
1376        .bind(paused)
1377        .bind(namespace)
1378        .bind(name)
1379        .execute(&self.pool)
1380        .await?;
1381        if res.rows_affected() == 0 {
1382            return Ok(None);
1383        }
1384        self.get_schedule(namespace, name).await
1385    }
1386
1387    // ── Workers ─────────────────────────────────────────────
1388
1389    async fn register_worker(&self, w: &WorkflowWorker) -> Result<()> {
1390        sqlx::query(
1391            "INSERT INTO workflow.workers (id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at)
1392             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
1393             ON CONFLICT (id) DO UPDATE SET last_heartbeat = EXCLUDED.last_heartbeat, identity = EXCLUDED.identity",
1394        )
1395        .bind(&w.id)
1396        .bind(&w.namespace)
1397        .bind(&w.identity)
1398        .bind(&w.task_queue)
1399        .bind(&w.workflows)
1400        .bind(&w.activities)
1401        .bind(w.max_concurrent_workflows)
1402        .bind(w.max_concurrent_activities)
1403        .bind(w.active_tasks)
1404        .bind(w.last_heartbeat)
1405        .bind(w.registered_at)
1406        .execute(&self.pool)
1407        .await?;
1408        Ok(())
1409    }
1410
1411    async fn heartbeat_worker(&self, id: &str, now: f64) -> Result<bool> {
1412        let res = sqlx::query("UPDATE workflow.workers SET last_heartbeat = $1 WHERE id = $2")
1413            .bind(now)
1414            .bind(id)
1415            .execute(&self.pool)
1416            .await?;
1417        Ok(res.rows_affected() > 0)
1418    }
1419
1420    async fn list_workers(&self, namespace: &str) -> Result<Vec<WorkflowWorker>> {
1421        let rows = sqlx::query_as::<_, PgWorkerRow>(
1422            "SELECT id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at FROM workflow.workers WHERE namespace = $1 ORDER BY registered_at",
1423        )
1424        .bind(namespace)
1425        .fetch_all(&self.pool)
1426        .await?;
1427        Ok(rows.into_iter().map(Into::into).collect())
1428    }
1429
1430    async fn remove_dead_workers(&self, cutoff: f64) -> Result<Vec<String>> {
1431        let rows: Vec<(String,)> =
1432            sqlx::query_as("SELECT id FROM workflow.workers WHERE last_heartbeat < $1")
1433                .bind(cutoff)
1434                .fetch_all(&self.pool)
1435                .await?;
1436        let ids: Vec<String> = rows.into_iter().map(|r| r.0).collect();
1437        if !ids.is_empty() {
1438            sqlx::query("DELETE FROM workflow.workers WHERE last_heartbeat < $1")
1439                .bind(cutoff)
1440                .execute(&self.pool)
1441                .await?;
1442        }
1443        Ok(ids)
1444    }
1445
1446    // ── Child Workflows ─────────────────────────────────────
1447
1448    async fn list_child_workflows(&self, parent_id: &str) -> Result<Vec<WorkflowRecord>> {
1449        let rows = sqlx::query_as::<_, PgWorkflowRow>(
1450            "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
1451             FROM workflow.workflows WHERE parent_id = $1 ORDER BY created_at ASC",
1452        )
1453        .bind(parent_id)
1454        .fetch_all(&self.pool)
1455        .await?;
1456        Ok(rows.into_iter().map(Into::into).collect())
1457    }
1458
1459    // ── Snapshots ───────────────────────────────────────────
1460
1461    async fn create_snapshot(
1462        &self,
1463        workflow_id: &str,
1464        event_seq: i32,
1465        state_json: &str,
1466    ) -> Result<()> {
1467        sqlx::query(
1468            "INSERT INTO workflow.snapshots (workflow_id, event_seq, state_json, created_at)
1469             VALUES ($1, $2, $3, $4)
1470             ON CONFLICT (workflow_id, event_seq) DO UPDATE SET state_json = EXCLUDED.state_json, created_at = EXCLUDED.created_at",
1471        )
1472        .bind(workflow_id)
1473        .bind(event_seq)
1474        .bind(state_json)
1475        .bind(timestamp_now())
1476        .execute(&self.pool)
1477        .await?;
1478        Ok(())
1479    }
1480
1481    async fn get_latest_snapshot(&self, workflow_id: &str) -> Result<Option<WorkflowSnapshot>> {
1482        let row = sqlx::query_as::<_, (String, i32, String, f64)>(
1483            "SELECT workflow_id, event_seq, state_json, created_at
1484             FROM workflow.snapshots WHERE workflow_id = $1
1485             ORDER BY event_seq DESC LIMIT 1",
1486        )
1487        .bind(workflow_id)
1488        .fetch_optional(&self.pool)
1489        .await?;
1490
1491        Ok(row.map(
1492            |(workflow_id, event_seq, state_json, created_at)| WorkflowSnapshot {
1493                workflow_id,
1494                event_seq,
1495                state_json,
1496                created_at,
1497            },
1498        ))
1499    }
1500
1501    // ── Queue Stats ─────────────────────────────────────────
1502
1503    async fn get_queue_stats(&self, namespace: &str) -> Result<Vec<crate::store::QueueStats>> {
1504        let rows = sqlx::query_as::<_, (String, i64, i64, i64)>(
1505            "SELECT
1506                a.task_queue AS queue,
1507                SUM(CASE WHEN a.status = 'PENDING' THEN 1 ELSE 0 END) AS pending,
1508                SUM(CASE WHEN a.status = 'RUNNING' THEN 1 ELSE 0 END) AS running,
1509                (SELECT COUNT(*) FROM workflow.workers w WHERE w.task_queue = a.task_queue AND w.namespace = $1) AS workers
1510             FROM workflow.activities a
1511             JOIN workflow.workflows wf ON a.workflow_id = wf.id AND wf.namespace = $1
1512             GROUP BY a.task_queue",
1513        )
1514        .bind(namespace)
1515        .fetch_all(&self.pool)
1516        .await?;
1517
1518        Ok(rows
1519            .into_iter()
1520            .map(
1521                |(queue, pending, running, workers)| crate::store::QueueStats {
1522                    queue,
1523                    pending_activities: pending,
1524                    running_activities: running,
1525                    workers,
1526                },
1527            )
1528            .collect())
1529    }
1530
1531    // ── Leader Election ─────────────────────────────────────
1532
1533    async fn try_acquire_scheduler_lock(&self) -> Result<bool> {
1534        // pg_try_advisory_lock is session-scoped — only one connection
1535        // in the pool will hold the lock. In a multi-replica Kubernetes
1536        // deployment, only one pod's connection wins.
1537        let row: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock(42)")
1538            .fetch_one(&self.pool)
1539            .await?;
1540        Ok(row.0)
1541    }
1542}
1543
1544fn timestamp_now() -> f64 {
1545    std::time::SystemTime::now()
1546        .duration_since(std::time::UNIX_EPOCH)
1547        .unwrap()
1548        .as_secs_f64()
1549}
1550
1551// ── Postgres row types (sqlx::FromRow) ──────────────────────
1552
1553#[derive(sqlx::FromRow)]
1554struct PgWorkflowRow {
1555    id: String,
1556    namespace: String,
1557    run_id: String,
1558    workflow_type: String,
1559    task_queue: String,
1560    status: String,
1561    input: Option<String>,
1562    result: Option<String>,
1563    error: Option<String>,
1564    parent_id: Option<String>,
1565    claimed_by: Option<String>,
1566    search_attributes: Option<String>,
1567    archived_at: Option<f64>,
1568    archive_uri: Option<String>,
1569    created_at: f64,
1570    updated_at: f64,
1571    completed_at: Option<f64>,
1572}
1573
1574impl From<PgWorkflowRow> for WorkflowRecord {
1575    fn from(r: PgWorkflowRow) -> Self {
1576        Self {
1577            id: r.id,
1578            namespace: r.namespace,
1579            run_id: r.run_id,
1580            workflow_type: r.workflow_type,
1581            task_queue: r.task_queue,
1582            status: r.status,
1583            input: r.input,
1584            result: r.result,
1585            error: r.error,
1586            parent_id: r.parent_id,
1587            claimed_by: r.claimed_by,
1588            search_attributes: r.search_attributes,
1589            archived_at: r.archived_at,
1590            archive_uri: r.archive_uri,
1591            created_at: r.created_at,
1592            updated_at: r.updated_at,
1593            completed_at: r.completed_at,
1594        }
1595    }
1596}
1597
1598#[derive(sqlx::FromRow)]
1599struct PgEventRow {
1600    id: i64,
1601    workflow_id: String,
1602    seq: i32,
1603    event_type: String,
1604    payload: Option<String>,
1605    timestamp: f64,
1606}
1607
1608impl From<PgEventRow> for WorkflowEvent {
1609    fn from(r: PgEventRow) -> Self {
1610        Self {
1611            id: Some(r.id),
1612            workflow_id: r.workflow_id,
1613            seq: r.seq,
1614            event_type: r.event_type,
1615            payload: r.payload,
1616            timestamp: r.timestamp,
1617        }
1618    }
1619}
1620
1621#[derive(sqlx::FromRow)]
1622struct PgActivityRow {
1623    id: i64,
1624    workflow_id: String,
1625    seq: i32,
1626    name: String,
1627    task_queue: String,
1628    input: Option<String>,
1629    status: String,
1630    result: Option<String>,
1631    error: Option<String>,
1632    attempt: i32,
1633    max_attempts: i32,
1634    initial_interval_secs: f64,
1635    backoff_coefficient: f64,
1636    start_to_close_secs: f64,
1637    heartbeat_timeout_secs: Option<f64>,
1638    claimed_by: Option<String>,
1639    scheduled_at: f64,
1640    started_at: Option<f64>,
1641    completed_at: Option<f64>,
1642    last_heartbeat: Option<f64>,
1643}
1644
1645impl From<PgActivityRow> for WorkflowActivity {
1646    fn from(r: PgActivityRow) -> Self {
1647        Self {
1648            id: Some(r.id),
1649            workflow_id: r.workflow_id,
1650            seq: r.seq,
1651            name: r.name,
1652            task_queue: r.task_queue,
1653            input: r.input,
1654            status: r.status,
1655            result: r.result,
1656            error: r.error,
1657            attempt: r.attempt,
1658            max_attempts: r.max_attempts,
1659            initial_interval_secs: r.initial_interval_secs,
1660            backoff_coefficient: r.backoff_coefficient,
1661            start_to_close_secs: r.start_to_close_secs,
1662            heartbeat_timeout_secs: r.heartbeat_timeout_secs,
1663            claimed_by: r.claimed_by,
1664            scheduled_at: r.scheduled_at,
1665            started_at: r.started_at,
1666            completed_at: r.completed_at,
1667            last_heartbeat: r.last_heartbeat,
1668        }
1669    }
1670}
1671
1672#[derive(sqlx::FromRow)]
1673struct PgTimerRow {
1674    id: i64,
1675    workflow_id: String,
1676    seq: i32,
1677    fire_at: f64,
1678    fired: bool,
1679}
1680
1681impl From<PgTimerRow> for WorkflowTimer {
1682    fn from(r: PgTimerRow) -> Self {
1683        Self {
1684            id: Some(r.id),
1685            workflow_id: r.workflow_id,
1686            seq: r.seq,
1687            fire_at: r.fire_at,
1688            fired: r.fired,
1689        }
1690    }
1691}
1692
1693#[derive(sqlx::FromRow)]
1694struct PgSignalRow {
1695    id: i64,
1696    workflow_id: String,
1697    name: String,
1698    payload: Option<String>,
1699    consumed: bool,
1700    received_at: f64,
1701}
1702
1703impl From<PgSignalRow> for WorkflowSignal {
1704    fn from(r: PgSignalRow) -> Self {
1705        Self {
1706            id: Some(r.id),
1707            workflow_id: r.workflow_id,
1708            name: r.name,
1709            payload: r.payload,
1710            consumed: r.consumed,
1711            received_at: r.received_at,
1712        }
1713    }
1714}
1715
1716#[derive(sqlx::FromRow)]
1717struct PgScheduleRow {
1718    namespace: String,
1719    name: String,
1720    workflow_type: String,
1721    cron_expr: String,
1722    timezone: String,
1723    input: Option<String>,
1724    task_queue: String,
1725    overlap_policy: String,
1726    paused: bool,
1727    last_run_at: Option<f64>,
1728    next_run_at: Option<f64>,
1729    last_workflow_id: Option<String>,
1730    created_at: f64,
1731}
1732
1733impl From<PgScheduleRow> for WorkflowSchedule {
1734    fn from(r: PgScheduleRow) -> Self {
1735        Self {
1736            namespace: r.namespace,
1737            name: r.name,
1738            workflow_type: r.workflow_type,
1739            cron_expr: r.cron_expr,
1740            timezone: r.timezone,
1741            input: r.input,
1742            task_queue: r.task_queue,
1743            overlap_policy: r.overlap_policy,
1744            paused: r.paused,
1745            last_run_at: r.last_run_at,
1746            next_run_at: r.next_run_at,
1747            last_workflow_id: r.last_workflow_id,
1748            created_at: r.created_at,
1749        }
1750    }
1751}
1752
1753#[derive(sqlx::FromRow)]
1754struct PgWorkerRow {
1755    id: String,
1756    namespace: String,
1757    identity: String,
1758    task_queue: String,
1759    workflows: Option<String>,
1760    activities: Option<String>,
1761    max_concurrent_workflows: i32,
1762    max_concurrent_activities: i32,
1763    active_tasks: i32,
1764    last_heartbeat: f64,
1765    registered_at: f64,
1766}
1767
1768impl From<PgWorkerRow> for WorkflowWorker {
1769    fn from(r: PgWorkerRow) -> Self {
1770        Self {
1771            id: r.id,
1772            namespace: r.namespace,
1773            identity: r.identity,
1774            task_queue: r.task_queue,
1775            workflows: r.workflows,
1776            activities: r.activities,
1777            max_concurrent_workflows: r.max_concurrent_workflows,
1778            max_concurrent_activities: r.max_concurrent_activities,
1779            active_tasks: r.active_tasks,
1780            last_heartbeat: r.last_heartbeat,
1781            registered_at: r.registered_at,
1782        }
1783    }
1784}
1785
1786#[cfg(test)]
1787mod tests {
1788    use super::*;
1789
1790    #[test]
1791    fn sanitise_schema_keeps_statements_intact() {
1792        let input = "CREATE TABLE foo (x INT);\nCREATE INDEX idx_foo ON foo(x);\n";
1793        let out = sanitise_schema(input);
1794        assert_eq!(out.len(), 2);
1795        assert!(out[0].starts_with("CREATE TABLE foo"));
1796        assert!(out[1].starts_with("CREATE INDEX idx_foo"));
1797    }
1798
1799    #[test]
1800    fn sanitise_schema_drops_pure_comment_lines() {
1801        let input = "-- header comment\nCREATE TABLE foo (x INT);\n-- trailing comment\n";
1802        let out = sanitise_schema(input);
1803        assert_eq!(out.len(), 1);
1804        assert!(out[0].starts_with("CREATE TABLE foo"));
1805    }
1806
1807    #[test]
1808    fn sanitise_schema_ignores_semicolons_inside_comment_prose() {
1809        // Regression: the exact shape that broke v0.11.3–v0.11.5 in production.
1810        // `-- foo; bar` used to split into "foo" and " bar" fragments, the second
1811        // of which was executed as SQL and rejected with `syntax error at or near "bar"`.
1812        let input = "\
1813CREATE TABLE foo (x INT);
1814-- Idempotent across startups; fresh installs pick the column up from the
1815-- CREATE TABLE above so the ADD is a no-op.
1816";
1817        let out = sanitise_schema(input);
1818        assert_eq!(
1819            out.len(),
1820            1,
1821            "expected 1 real statement, got {}: {:?}",
1822            out.len(),
1823            out
1824        );
1825        assert!(out[0].starts_with("CREATE TABLE foo"));
1826    }
1827
1828    #[test]
1829    fn sanitise_schema_drops_indented_comment_lines() {
1830        let input = "  -- indented comment\n\tCREATE TABLE foo (x INT);\n";
1831        let out = sanitise_schema(input);
1832        assert_eq!(out.len(), 1);
1833        assert!(out[0].contains("CREATE TABLE foo"));
1834    }
1835
1836    #[test]
1837    fn sanitise_schema_real_constant_produces_only_ddl() {
1838        // The real SCHEMA constant must not produce any statement whose first
1839        // token isn't a recognised SQL keyword. A prose fragment leaking in
1840        // (e.g. "fresh installs...") means the filter regressed.
1841        for stmt in sanitise_schema(SCHEMA) {
1842            let first_word = stmt
1843                .split_whitespace()
1844                .next()
1845                .expect("non-empty statement")
1846                .to_uppercase();
1847            assert!(
1848                matches!(
1849                    first_word.as_str(),
1850                    "CREATE" | "INSERT" | "UPDATE" | "DROP" | "ALTER" | "WITH"
1851                ),
1852                "SCHEMA produced non-DDL statement starting with {first_word:?}: {stmt:?}"
1853            );
1854        }
1855    }
1856}