Skip to main content

runledger_postgres/
lib.rs

1//! PostgreSQL persistence layer for Runledger durable execution.
2//!
3//! This crate owns the SQLx-backed storage and query helpers used by the
4//! runtime and application crates. The main entrypoint is the [`jobs`] module,
5//! which exposes APIs for:
6//! - queueing, resource-aware claiming, heartbeating, and completing jobs
7//! - typed direct-job recovery, successful-job replay, events, metrics, and
8//!   admin reads
9//! - enqueueing, actively coordinating, querying, and immutably recovering
10//!   workflow runs and steps
11//! - applying or validating the bundled Runledger schema migrations
12//!
13//! Typical consumers share a [`DbPool`] with `runledger-runtime`, then call the
14//! exported [`jobs`] functions from application setup, admin APIs, or tests.
15//!
16//! # Security Boundary
17//!
18//! This crate is a persistence layer, not an authentication or authorization
19//! layer. Public job and workflow APIs that accept `organization_id`,
20//! idempotency keys, workflow IDs, job IDs, or metadata expect those values to
21//! come from a trusted service boundary. HTTP or RPC handlers should derive
22//! organization scope from authenticated claims or server-side policy, not from
23//! untrusted request parameters alone.
24//!
25//! [`QueryError::client_message`] and [`QueryError::code`] are the stable values
26//! intended for public error responses and application logs. Detailed internal
27//! context remains available through [`QueryError::internal_message`] and the
28//! standard error source chain for targeted debugging, but may contain payload
29//! values, idempotency keys, or database policy details. Do not emit it at
30//! general logging boundaries without explicit redaction. Public formatting
31//! keeps raw SQLx details sanitized.
32//! Runtime lifecycle, workflow mutation, and idempotent enqueue APIs are designed
33//! for PostgreSQL's default `READ COMMITTED` transaction isolation so they can
34//! observe rows committed after lock waits or uniqueness conflicts.
35//! Release-sensitive, workflow-append, and keyed-enqueue paths validate this
36//! before running because their correctness depends on second reads after waits
37//! or conflicts.
38//!
39//! For simple embedding, call [`migrate_after_idempotency_cutover`] during
40//! startup:
41//!
42//! ```rust,no_run
43//! # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
44//! let pool = sqlx::PgPool::connect("postgres://localhost/runledger").await?;
45//! runledger_postgres::migrate_after_idempotency_cutover(&pool).await?;
46//! # Ok(())
47//! # }
48//! ```
49//!
50//! For deployments that manage DDL elsewhere, call
51//! [`ensure_schema_compatible_after_idempotency_cutover`] instead to fail fast
52//! if the schema is missing, drifted, or still has keyed legacy rows without
53//! idempotency request snapshots. That check is read-only, but it expects the
54//! database to retain SQLx migration history in `_sqlx_migrations` and, when
55//! available, Runledger-owned migration state in `runledger_migration_history`.
56//!
57//! # Copy-Paste Examples
58//!
59//! - [Enqueue one job](https://github.com/bpcakes/runledger/blob/master/runledger-postgres/examples/enqueue_job.rs)
60//! - [Enqueue a workflow DAG](https://github.com/bpcakes/runledger/blob/master/runledger-postgres/examples/workflow_dag.rs)
61//! - [Use an external workflow gate](https://github.com/bpcakes/runledger/blob/master/runledger-postgres/examples/external_gate.rs)
62//! - [Create a scheduled job entrypoint](https://github.com/bpcakes/runledger/blob/master/runledger-postgres/examples/schedule_job.rs)
63//! - [Adopt continuation, coordination, replay, and recovery](https://github.com/bpcakes/runledger/blob/master/docs/downstream-agent-guide.md)
64//!
65//! Import `runledger_runtime::prelude::*` and use
66//! [the worker binary example](https://github.com/bpcakes/runledger/blob/master/runledger-runtime/examples/worker_binary.rs)
67//! when adding a worker process for the jobs and workflows enqueued through this
68//! crate.
69//!
70//! # Prelude
71//!
72//! ```rust
73//! use runledger_core::prelude::*;
74//! use runledger_postgres::prelude::*;
75//! ```
76//!
77//! The PostgreSQL prelude exports persistence functions and record/input types.
78//! It intentionally does not re-export core contract types, so import
79//! `runledger_core::prelude::*` beside it when building job or workflow inputs.
80//!
81//! # Enqueue One Job
82//!
83//! Use direct job enqueue for one independent retried unit of work.
84//!
85//! ```rust,no_run
86//! # async fn demo(pool: runledger_postgres::DbPool) -> Result<(), Box<dyn std::error::Error>> {
87//! use runledger_core::prelude::*;
88//! use runledger_postgres::prelude::*;
89//!
90//! let payload = serde_json::json!({"email_id": "email_123"});
91//! let job = JobEnqueue {
92//!     job_type: JobType::new("jobs.email.send"),
93//!     organization_id: None,
94//!     payload: &payload,
95//!     priority: None,
96//!     max_attempts: None,
97//!     timeout_seconds: None,
98//!     next_run_at: None,
99//!     idempotency_key: Some("email:email_123:send"),
100//!     stage: None,
101//! };
102//!
103//! let _job_id = enqueue_job(&pool, &job).await?;
104//! # Ok(())
105//! # }
106//! ```
107//!
108//! # Record A Durable Transactional Handoff
109//!
110//! Use an enqueue intent when application state and a future job request must
111//! commit atomically before the job definition is available. Recording does not
112//! read `job_definitions` or create a queue row. A standard worker later promotes
113//! pending intents for its registered job types through the ordinary enqueue
114//! path.
115//!
116//! ```rust,no_run
117//! # async fn demo(pool: runledger_postgres::DbPool) -> Result<(), Box<dyn std::error::Error>> {
118//! use runledger_core::prelude::*;
119//! use runledger_postgres::prelude::*;
120//!
121//! let payload = serde_json::json!({"invoice_id": "invoice_123"});
122//! let intent = JobEnqueueIntent::new(
123//!     JobType::new("billing.invoice.capture"),
124//!     &payload,
125//!     "invoice:invoice_123:capture",
126//! );
127//!
128//! let mut tx = pool.begin().await?;
129//! // Persist the application's business/audit mutation with this same `tx`.
130//! let outcome = record_job_enqueue_intent_tx(&mut tx, &intent).await?;
131//! if outcome.status() == JobEnqueueIntentStatus::Conflicted {
132//!     return Err("the existing durable handoff is conflicted".into());
133//! }
134//! tx.commit().await?;
135//! # Ok(())
136//! # }
137//! ```
138//!
139//! The returned status is a point-in-time observation, not a promotion
140//! guarantee. An existing intent can be promoted or become conflicted
141//! concurrently, including while a caller-owned record transaction remains
142//! open. Continue monitoring pending age and conflicts after accepting a
143//! pending handoff.
144//!
145//! Concurrent calls that record the same `(job_type, organization_id,
146//! idempotency_key)` may wait for the transaction that first claimed that unique
147//! key. Include that wait in the caller-owned transaction's lock ordering and
148//! timeout budget.
149//! Call [`jobs::record_job_enqueue_intent_tx`] before any operation in the same
150//! transaction that can lock a `job_queue` row. A job-first recorder can create
151//! an inverse lock cycle with retention's canonical intent-before-job order.
152//!
153//! Intent payloads and idempotency keys cross the same trusted persistence
154//! boundary as ordinary queue inputs. Do not place secrets in them or emit them
155//! in logs. Monitor [`jobs::get_job_enqueue_intent_metrics`] for pending age,
156//! retrying count, maximum promotion attempts, and conflicts during the preceding
157//! 24 hours; Runledger never automatically deletes conflicted intent evidence.
158//! Database-level promotion failures leave the intent pending with bounded
159//! jittered exponential backoff, and read APIs expose the attempt/error metadata. They
160//! retry indefinitely so a prolonged outage cannot silently discard work;
161//! operators must alert on pending age and maximum attempts.
162//! Promoted intents retain their linked jobs. A queue-retention transaction
163//! must call [`jobs::delete_promoted_job_enqueue_intents_for_jobs_tx`] with its
164//! exact selected job IDs before deleting those jobs. Select candidate IDs
165//! without row locks, then call the helper as the transaction's first
166//! lock-taking operation. The transaction must use `READ COMMITTED`; stronger
167//! isolation levels return `job.intent_retention_unsupported_isolation` before
168//! fence acquisition. The helper waits for active promotions, fences new
169//! promotions, deletes promoted-intent links, then locks those jobs for the rest
170//! of the transaction. This intent-before-job order composes with duplicate
171//! recorders without an inverse lock cycle. Keep the transaction short and
172//! commit promptly. Time cutoffs alone are insufficient because a newly
173//! promoted intent may link to an older existing job.
174//!
175//! # Create A Scheduled Job Entrypoint
176//!
177//! Use [`jobs::JobScheduleUpsert`] to create or update the cron row consumed by
178//! the runtime scheduler. Schedules are UTC-only. Updating an existing schedule
179//! refreshes its definition while preserving `is_active` and `organization_id`;
180//! `next_fire_at` is refreshed when `cron_expr` changes. Cron expressions are
181//! validated with the same parser used by `runledger-runtime`. Use
182//! [`jobs::set_job_schedule_active`] to pause or resume a schedule, and
183//! [`jobs::set_job_schedule_next_fire_at`] to manually retime its cursor.
184//!
185//! ```rust,no_run
186//! # async fn demo(pool: runledger_postgres::DbPool) -> Result<(), Box<dyn std::error::Error>> {
187//! use chrono::Utc;
188//! use runledger_core::prelude::*;
189//! use runledger_postgres::prelude::*;
190//!
191//! let payload_template = serde_json::json!({"source": "api"});
192//! let schedule = JobScheduleUpsert {
193//!     name: "profile-refresh-hourly",
194//!     job_type: JobType::new("profiles.refresh"),
195//!     organization_id: None,
196//!     payload_template: &payload_template,
197//!     cron_expr: "0 0 * * * *",
198//!     is_active: true,
199//!     next_fire_at: Utc::now(),
200//!     max_jitter_seconds: 0,
201//! };
202//!
203//! let _schedule = upsert_job_schedule(&pool, &schedule).await?;
204//! # Ok(())
205//! # }
206//! ```
207//!
208//! # Enqueue A Workflow DAG
209//!
210//! Use workflows when the work has step dependencies, fan-out/fan-in, external
211//! gates, cancellation as one logical run, or workflow-level idempotency.
212//!
213//! ```rust,no_run
214//! # async fn demo(pool: runledger_postgres::DbPool) -> Result<(), Box<dyn std::error::Error>> {
215//! use runledger_core::prelude::*;
216//! use runledger_postgres::prelude::*;
217//!
218//! let crawl_payload = serde_json::json!({"profile_id": "p_123"});
219//! let classify_payload = serde_json::json!({"profile_id": "p_123"});
220//! let metadata = serde_json::json!({"source": "api"});
221//!
222//! let run = WorkflowDagBuilder::new("profiles.research", &metadata)
223//!     .idempotency_key("profile:p_123:research")
224//!     .job("crawl", "profiles.crawl", &crawl_payload)?
225//!     .job("classify", "profiles.classify", &classify_payload)?
226//!     .after_success("classify", ["crawl"])?
227//!     .build()?;
228//!
229//! let _workflow_run = enqueue_workflow_run(&pool, &run).await?;
230//! # Ok(())
231//! # }
232//! ```
233//!
234//! # Use An External Workflow Gate
235//!
236//! Create the gate with `WorkflowStepEnqueueBuilder::new_external`, then
237//! complete it from a trusted service boundary when the external condition is
238//! known.
239//!
240//! ```rust,no_run
241//! # async fn demo(
242//! #     pool: runledger_postgres::DbPool,
243//! #     workflow_run_id: sqlx::types::Uuid,
244//! # ) -> Result<(), Box<dyn std::error::Error>> {
245//! use runledger_core::prelude::*;
246//! use runledger_postgres::prelude::*;
247//!
248//! let input = CompleteExternalWorkflowStepInput {
249//!     workflow_run_id,
250//!     organization_id: None,
251//!     step_key: StepKey::new("approval"),
252//!     outcome: ExternalWorkflowStepTerminalOutcome::Succeeded { output: None },
253//!     status_reason: Some("approved"),
254//!     last_error_code: None,
255//!     last_error_message: None,
256//! };
257//!
258//! let _step = complete_external_workflow_step(&pool, &input).await?;
259//! # Ok(())
260//! # }
261//! ```
262//!
263//! # Inspect Workflow State
264//!
265//! ```rust,no_run
266//! # async fn demo(
267//! #     pool: runledger_postgres::DbPool,
268//! #     workflow_run_id: sqlx::types::Uuid,
269//! # ) -> Result<(), Box<dyn std::error::Error>> {
270//! use runledger_postgres::prelude::*;
271//!
272//! let scope = WorkflowRunReadScope::Admin;
273//! let _run = get_workflow_run_by_id_with_scope(&pool, scope, workflow_run_id).await?;
274//! let _steps = list_workflow_steps_with_scope(&pool, scope, workflow_run_id).await?;
275//! let _dependencies =
276//!     list_workflow_step_dependencies_with_scope(&pool, scope, workflow_run_id).await?;
277//! # Ok(())
278//! # }
279//! ```
280//!
281//! # Coordinate And Recover Durable Work
282//!
283//! - Use [`jobs::enqueue_job_with_execution_resource`] or
284//!   `runledger_core::jobs::WorkflowStepEnqueueBuilder::execution_resource`
285//!   for lease-scoped single-permit resources.
286//! - Use [`jobs::enqueue_or_get_active_workflow`] with a workflow
287//!   `runledger_core::jobs::WorkflowRunEnqueueBuilder::active_key` and inspect every
288//!   [`jobs::EnqueueActiveWorkflowOutcome`].
289//! - Use [`jobs::compare_and_requeue_job`] for a failed/canceled/dead-lettered
290//!   direct job and [`jobs::compare_and_replay_succeeded_job`] for intentional
291//!   successful replay.
292//! - Use [`jobs::recover_workflow_run`] to reconstruct a terminal workflow as a
293//!   new lineage-linked run rather than rewriting source history.
294//!
295//! The caller-transaction variants of recovery and replay require PostgreSQL
296//! `READ COMMITTED`. See the
297//! [downstream guide](https://github.com/bpcakes/runledger/blob/master/docs/downstream-agent-guide.md)
298//! for rollout fences, request idempotency, and retention behavior.
299//!
300//! # Handle Errors Safely
301//!
302//! `QueryError::client_message` and `QueryError::code` are safe for public
303//! responses. `QueryError::internal_message` is for trusted diagnostics.
304//!
305//! ```rust,no_run
306//! # async fn demo(
307//! #     pool: runledger_postgres::DbPool,
308//! #     job: runledger_postgres::jobs::JobEnqueue<'_>,
309//! # ) -> Result<(), runledger_postgres::Error> {
310//! match runledger_postgres::jobs::enqueue_job(&pool, &job).await {
311//!     Ok(_job_id) => {}
312//!     Err(runledger_postgres::Error::QueryError(query_error)) => {
313//!         let _public_code = query_error.code();
314//!         let _public_message = query_error.client_message();
315//!         let _private_diagnostic = query_error.internal_message();
316//!     }
317//!     Err(error) => return Err(error),
318//! }
319//! # Ok(())
320//! # }
321//! ```
322
323use std::fmt;
324
325mod error;
326pub mod jobs;
327mod migrations;
328
329pub use error::{
330    FrameworkConstraintSpec, QueryError, QueryErrorCategory, QueryErrorKind,
331    classify_framework_constraint, classify_query_error,
332    classify_query_error_with_constraint_classifier, has_framework_constraint_classifier,
333};
334pub use migrations::{
335    MIGRATOR, SchemaCompatibilityError, WorkflowJobLinkTriggerDiagnostic,
336    WorkflowJobLinkTriggerProblem, ensure_schema_compatible_after_idempotency_cutover,
337    migrate_after_idempotency_cutover,
338};
339#[allow(
340    deprecated,
341    reason = "deprecated migration entrypoints remain re-exported for semver compatibility"
342)]
343pub use migrations::{ensure_schema_compatible, migrate};
344
345/// Common `runledger-postgres` imports for integration crates.
346///
347/// This prelude contains persistence APIs, DB types, and database record/input
348/// structs. It avoids generic `Result` or `Error` aliases and does not re-export
349/// `runledger-core` contracts, so it can be glob-imported alongside
350/// `runledger_core::prelude::*` and `runledger_runtime::prelude::*`.
351pub mod prelude {
352    #[allow(
353        deprecated,
354        reason = "the prelude retains deprecated exports for semver compatibility"
355    )]
356    pub use crate::jobs::{
357        AppendWorkflowStepsInput, AppendWorkflowStepsOutcome, AppendWorkflowStepsResult,
358        CompareAndReplaySucceededJob, CompareAndReplaySucceededJobOutcome, CompareAndRequeueJob,
359        CompareAndRequeueJobOutcome, CompleteExternalWorkflowStepInput,
360        DEFAULT_WORKFLOW_RUN_WAIT_TIMEOUT, DecodedJobEventPayload, DecodedRequeuedEventPayload,
361        EnqueueActiveWorkflowOutcome, ExternalWorkflowStepTerminalOutcome,
362        JOB_SCHEDULE_MAX_JITTER_SECONDS, JobCancellationScope, JobCompletionUpdate,
363        JobContinuationMetricsRecord, JobContinuationOutcome, JobContinuationUpdate,
364        JobDefinitionListFilter, JobDefinitionRecord, JobDefinitionUpdate, JobDefinitionUpsert,
365        JobEnqueue, JobEnqueueDisposition, JobEnqueueIntent, JobEnqueueIntentDisposition,
366        JobEnqueueIntentListFilter, JobEnqueueIntentMetricsFilter, JobEnqueueIntentMetricsRecord,
367        JobEnqueueIntentOutcome, JobEnqueueIntentOutcomeState, JobEnqueueIntentPromotionError,
368        JobEnqueueIntentPromotionReport, JobEnqueueIntentRecord, JobEnqueueIntentState,
369        JobEnqueueIntentStatus, JobEnqueueOutcome, JobEventRecord, JobFailureCompletionDisposition,
370        JobFailureCompletionOutcome, JobFailureUpdate, JobLeaseIdentity, JobListFilter,
371        JobLogRecord, JobLogRecordInput, JobMetricsRecord, JobOrdinaryProgressUpdate,
372        JobPayloadUuidArrayFieldUpdate, JobPayloadUuidArrayFieldUpdateRejection, JobProgressUpdate,
373        JobQueueRecord, JobRequeueStatePolicy, JobRunningUpdate, JobRuntimeConfigListFilter,
374        JobRuntimeConfigRecord, JobRuntimeConfigUpsert, JobScheduleRecord, JobScheduleUpsert,
375        JobScope, JobSuccessCompletionOutcome, NonRequeueableJobStatusError,
376        ReapExpiredLeaseCleanupError, ReapExpiredLeaseCleanupOperation,
377        ReapExpiredLeaseDeferredError, ReapExpiredLeasesDetailedResult, ReapExpiredLeasesResult,
378        ReapedLeaseDisposition, ReapedLeaseRecord, ReapedTerminalLeaseRecord, RequeueableJobStatus,
379        SuccessfulReplayEnqueuedEventPayload, WorkflowRecoveryDisposition, WorkflowRecoveryMode,
380        WorkflowRecoveryOutcome, WorkflowRecoveryRequest, WorkflowRunDbRecord, WorkflowRunHandle,
381        WorkflowRunHandleError, WorkflowRunHandleScope, WorkflowRunListFilter,
382        WorkflowRunReadCountFilter, WorkflowRunReadListFilter, WorkflowRunReadScope,
383        WorkflowRunResultRecord, WorkflowRunWaitOptions, WorkflowStepDbRecord,
384        WorkflowStepDependencyDbRecord, append_workflow_steps, append_workflow_steps_tx,
385        cancel_job, cancel_job_with_scope, cancel_workflow_run_tx,
386        compare_and_replay_succeeded_job, compare_and_replay_succeeded_job_tx,
387        compare_and_requeue_job, compare_and_requeue_job_tx, complete_external_workflow_step,
388        complete_external_workflow_step_tx, complete_job_continuation,
389        complete_job_continuation_for_lease, complete_job_continuation_with_outcome,
390        complete_job_continuation_with_outcome_for_lease, complete_job_failure,
391        complete_job_failure_for_lease, complete_job_failure_with_outcome,
392        complete_job_failure_with_outcome_for_lease, complete_job_success,
393        complete_job_success_for_lease, complete_job_success_with_outcome,
394        complete_job_success_with_outcome_for_lease, count_workflow_runs_with_scope,
395        count_workflow_step_dependencies, count_workflow_step_dependencies_with_scope,
396        count_workflow_steps, count_workflow_steps_with_scope,
397        delete_promoted_job_enqueue_intents_before,
398        delete_promoted_job_enqueue_intents_for_jobs_tx, enqueue_job, enqueue_job_tx,
399        enqueue_job_with_execution_resource, enqueue_job_with_execution_resource_tx,
400        enqueue_job_with_outcome_tx, enqueue_or_get_active_workflow,
401        enqueue_or_get_active_workflow_tx, enqueue_workflow_run, enqueue_workflow_run_handle,
402        enqueue_workflow_run_tx, get_job_by_id, get_job_continuation_metrics,
403        get_job_definition_by_type, get_job_enqueue_intent_by_id, get_job_enqueue_intent_metrics,
404        get_job_metrics, get_job_payload_by_idempotency_key, get_job_runtime_config_by_type,
405        get_job_schedule_by_name, get_latest_job_payload_for_run, get_latest_workflow_run_by_type,
406        get_latest_workflow_run_by_type_with_scope, get_required_job_runtime_config_by_type,
407        get_workflow_run_by_id, get_workflow_run_by_id_with_scope,
408        get_workflow_run_by_type_and_idempotency_key, get_workflow_run_id_for_job,
409        heartbeat_job_for_lease, insert_job_definition_if_missing_tx, insert_job_log,
410        insert_job_runtime_config_if_missing, list_job_definitions, list_job_enqueue_intents,
411        list_job_events, list_job_logs, list_job_runtime_configs, list_jobs, list_workflow_runs,
412        list_workflow_runs_with_scope, list_workflow_step_dependencies,
413        list_workflow_step_dependencies_page, list_workflow_step_dependencies_page_with_scope,
414        list_workflow_step_dependencies_with_scope, list_workflow_steps, list_workflow_steps_page,
415        list_workflow_steps_page_with_scope, list_workflow_steps_with_scope,
416        mark_job_running_for_lease, prepare_schedule_exact_sync_critical_section_tx,
417        promote_job_enqueue_intents_for_types, reap_expired_leases_with_diagnostics,
418        record_job_enqueue_intent, record_job_enqueue_intent_tx, recover_workflow_run,
419        recover_workflow_run_tx, retrieve_workflow_run_handle, set_job_schedule_active,
420        set_job_schedule_active_tx, set_job_schedule_next_fire_at,
421        set_job_schedule_next_fire_at_tx, sync_catalog_job_schedules_tx, update_job_definition,
422        update_job_ordinary_progress_for_lease, update_job_payload_uuid_array_field,
423        update_job_progress_for_lease, update_workflow_step_and_pending_job_payload_tx,
424        upsert_job_definition_tx, upsert_job_runtime_config, upsert_job_runtime_config_tx,
425        upsert_job_schedule, upsert_job_schedule_tx, workflow_run_handle,
426    };
427    pub use crate::jobs::{
428        JobScheduleCatalogSyncEntry, JobScheduleCatalogSyncReport,
429        deactivate_schedules_absent_from_names_tx,
430    };
431    pub use crate::{
432        DbPool, DbTx, FrameworkConstraintSpec, MIGRATOR, QueryError, QueryErrorCategory,
433        QueryErrorKind, SchemaCompatibilityError, WorkflowJobLinkTriggerDiagnostic,
434        WorkflowJobLinkTriggerProblem, ensure_schema_compatible_after_idempotency_cutover,
435        migrate_after_idempotency_cutover,
436    };
437}
438
439pub type DbPool = sqlx::PgPool;
440pub type DbTx<'a> = sqlx::Transaction<'a, sqlx::Postgres>;
441pub type Result<T> = std::result::Result<T, Error>;
442
443#[derive(Debug)]
444pub enum Error {
445    ConfigError(String),
446    ConnectionError(String),
447    MigrationError(String),
448    QueryError(QueryError),
449}
450
451impl fmt::Display for Error {
452    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
453        match self {
454            Self::ConfigError(message) => write!(f, "{message}"),
455            Self::ConnectionError(message) => write!(f, "{message}"),
456            Self::MigrationError(message) => write!(f, "{message}"),
457            Self::QueryError(query_error) => write!(f, "{query_error}"),
458        }
459    }
460}
461
462impl std::error::Error for Error {
463    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
464        match self {
465            Self::QueryError(query_error) => Some(query_error),
466            Self::ConfigError(_) | Self::ConnectionError(_) | Self::MigrationError(_) => None,
467        }
468    }
469}
470
471impl Error {
472    #[must_use]
473    pub fn from_query_sqlx(error: sqlx::Error) -> Self {
474        Self::QueryError(QueryError::from_sqlx(error, None))
475    }
476
477    #[must_use]
478    pub fn from_query_sqlx_with_context(context: &str, error: sqlx::Error) -> Self {
479        Self::QueryError(QueryError::from_sqlx(error, Some(context)))
480    }
481}