Skip to main content

hexeract_outbox_sql/
dialect.rs

1use hexeract_outbox::OutboxError;
2
3use crate::validate::validate_table_name;
4
5/// Canonical PostgreSQL schema for an outbox table.
6///
7/// `{{table}}` is substituted
8/// by [`Dialect::schema_ddl`].
9const POSTGRES_SCHEMA_SQL: &str = r"
10CREATE TABLE IF NOT EXISTS {{table}} (
11    id            BIGSERIAL    PRIMARY KEY,
12    event_id      UUID         NOT NULL UNIQUE,
13    event_type    VARCHAR(64)  NOT NULL,
14    payload       JSONB        NOT NULL,
15    subject_id    UUID         NULL,
16    created_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
17    attempts      INTEGER      NOT NULL DEFAULT 0,
18    last_error    TEXT         NULL,
19    next_retry_at TIMESTAMPTZ  NULL,
20    delivered_at  TIMESTAMPTZ  NULL
21);
22CREATE INDEX IF NOT EXISTS idx_{{table}}_pending
23    ON {{table}} (created_at)
24    WHERE delivered_at IS NULL;
25CREATE INDEX IF NOT EXISTS idx_{{table}}_subject
26    ON {{table}} (subject_id, id)
27    WHERE subject_id IS NOT NULL;
28";
29
30/// Canonical MySQL schema for an outbox table (requires MySQL 8.0.13+).
31///
32/// MySQL supports neither partial indexes nor `CREATE INDEX IF NOT EXISTS`,
33/// so the indexes are declared inline in the `CREATE TABLE` statement. UUIDs
34/// are stored as `BINARY(16)` and the payload as native `JSON`. Timestamps use
35/// `DATETIME(6)` holding UTC, with an expression default `(UTC_TIMESTAMP(6))`
36/// that requires MySQL 8.0.13 or later.
37const MYSQL_SCHEMA_SQL: &str = r"
38CREATE TABLE IF NOT EXISTS {{table}} (
39    id            BIGINT       NOT NULL AUTO_INCREMENT PRIMARY KEY,
40    event_id      BINARY(16)   NOT NULL UNIQUE,
41    event_type    VARCHAR(64)  NOT NULL,
42    payload       JSON         NOT NULL,
43    subject_id    BINARY(16)   NULL,
44    created_at    DATETIME(6)  NOT NULL DEFAULT (UTC_TIMESTAMP(6)),
45    attempts      INT          NOT NULL DEFAULT 0,
46    last_error    TEXT         NULL,
47    next_retry_at DATETIME(6)  NULL,
48    delivered_at  DATETIME(6)  NULL,
49    INDEX idx_{{table}}_pending (delivered_at, created_at),
50    INDEX idx_{{table}}_subject (subject_id, id)
51);
52";
53
54/// Canonical PostgreSQL dead-letter schema.
55///
56/// Rows are moved here when `attempts >= max_attempts`. `exhausted_at`
57/// defaults to `NOW()` and records when the envelope was declared poison.
58/// `{{table}}` is substituted by [`Dialect::dead_letter_schema_ddl`].
59///
60/// Note: `event_id` is declared `UNIQUE`, which already creates an implicit
61/// B-tree index. A separate `idx_{{table}}_dead_letter_event_id` index would
62/// be a duplicate and is therefore omitted.
63const POSTGRES_DLQ_SCHEMA_SQL: &str = r"
64CREATE TABLE IF NOT EXISTS {{table}}_dead_letter (
65    id            BIGSERIAL    PRIMARY KEY,
66    event_id      UUID         NOT NULL UNIQUE,
67    event_type    VARCHAR(64)  NOT NULL,
68    payload       JSONB        NOT NULL,
69    subject_id    UUID         NULL,
70    created_at    TIMESTAMPTZ  NOT NULL,
71    attempts      INTEGER      NOT NULL,
72    last_error    TEXT         NOT NULL,
73    exhausted_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
74);
75CREATE INDEX IF NOT EXISTS idx_{{table}}_dead_letter_exhausted_at
76    ON {{table}}_dead_letter (exhausted_at);
77";
78
79/// Canonical MySQL dead-letter schema (requires MySQL 8.0.13+).
80///
81/// Mirrors the MySQL outbox schema: UUIDs as `BINARY(16)`, payload as
82/// `JSON`, timestamps as `DATETIME(6)` UTC. `{{table}}` is substituted
83/// by [`Dialect::dead_letter_schema_ddl`].
84///
85/// Note: `event_id` is declared `UNIQUE`, which already creates an implicit
86/// index. A separate `idx_{{table}}_dead_letter_event_id` index is omitted.
87const MYSQL_DLQ_SCHEMA_SQL: &str = r"
88CREATE TABLE IF NOT EXISTS {{table}}_dead_letter (
89    id            BIGINT       NOT NULL AUTO_INCREMENT PRIMARY KEY,
90    event_id      BINARY(16)   NOT NULL UNIQUE,
91    event_type    VARCHAR(64)  NOT NULL,
92    payload       JSON         NOT NULL,
93    subject_id    BINARY(16)   NULL,
94    created_at    DATETIME(6)  NOT NULL,
95    attempts      INT          NOT NULL,
96    last_error    TEXT         NOT NULL,
97    exhausted_at  DATETIME(6)  NOT NULL DEFAULT (UTC_TIMESTAMP(6)),
98    INDEX idx_{{table}}_dead_letter_exhausted_at (exhausted_at)
99);
100";
101
102/// Canonical SQLite dead-letter schema.
103///
104/// Mirrors the SQLite outbox schema: UUIDs as `BLOB`, timestamps as
105/// `TEXT` in RFC 3339 form. `{{table}}` is substituted by
106/// [`Dialect::dead_letter_schema_ddl`].
107///
108/// Note: `event_id` is declared `UNIQUE`, which already creates an implicit
109/// index. A separate `idx_{{table}}_dead_letter_event_id` index is omitted.
110const SQLITE_DLQ_SCHEMA_SQL: &str = r"
111CREATE TABLE IF NOT EXISTS {{table}}_dead_letter (
112    id            INTEGER  PRIMARY KEY AUTOINCREMENT,
113    event_id      BLOB     NOT NULL UNIQUE,
114    event_type    TEXT     NOT NULL,
115    payload       TEXT     NOT NULL,
116    subject_id    BLOB,
117    created_at    TEXT     NOT NULL,
118    attempts      INTEGER  NOT NULL,
119    last_error    TEXT     NOT NULL,
120    exhausted_at  TEXT     NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
121);
122CREATE INDEX IF NOT EXISTS idx_{{table}}_dead_letter_exhausted_at
123    ON {{table}}_dead_letter (exhausted_at);
124";
125
126/// Canonical SQLite schema for an outbox table.
127///
128/// SQLite has dynamic typing, so UUIDs are stored as `BLOB` and the payload
129/// and timestamps as `TEXT`. The `created_at` default is rendered as RFC 3339
130/// (`...T...Z`) so it sorts lexicographically against the bound timestamps.
131const SQLITE_SCHEMA_SQL: &str = r"
132CREATE TABLE IF NOT EXISTS {{table}} (
133    id            INTEGER  PRIMARY KEY AUTOINCREMENT,
134    event_id      BLOB     NOT NULL UNIQUE,
135    event_type    TEXT     NOT NULL,
136    payload       TEXT     NOT NULL,
137    subject_id    BLOB,
138    created_at    TEXT     NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
139    attempts      INTEGER  NOT NULL DEFAULT 0,
140    last_error    TEXT,
141    next_retry_at TEXT,
142    delivered_at  TEXT
143);
144CREATE INDEX IF NOT EXISTS idx_{{table}}_pending
145    ON {{table}} (created_at)
146    WHERE delivered_at IS NULL;
147CREATE INDEX IF NOT EXISTS idx_{{table}}_subject
148    ON {{table}} (subject_id, id)
149    WHERE subject_id IS NOT NULL;
150";
151
152/// SQL dialect differences absorbed by the backend stores.
153///
154/// A [`Dialect`] knows how to render the four statements the outbox needs
155/// (poll, mark-delivered, mark-failed, insert) and the canonical schema DDL
156/// for its engine, accounting for placeholder style, row locking, the
157/// "current instant" expression and per-engine column types.
158///
159/// Marked `#[non_exhaustive]` so a future SQL backend can be added in a minor
160/// version: downstream `match` arms must include a wildcard `_` arm.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162#[non_exhaustive]
163pub enum Dialect {
164    /// PostgreSQL (`sqlx::Postgres`).
165    Postgres,
166    /// MySQL 8.0+ (`sqlx::MySql`).
167    MySql,
168    /// SQLite (`sqlx::Sqlite`).
169    Sqlite,
170}
171
172impl Dialect {
173    /// Whether competing-consumers row skip-locking is available.
174    ///
175    /// `true` for PostgreSQL and MySQL 8.0+, `false` for SQLite (which
176    /// serializes writes through a single writer instead).
177    #[must_use]
178    pub fn supports_skip_locked(self) -> bool {
179        matches!(self, Self::Postgres | Self::MySql)
180    }
181
182    /// Render the bind placeholder for the 1-based parameter `index`.
183    ///
184    /// PostgreSQL uses positional `$1`, `$2`; MySQL and SQLite use `?`.
185    ///
186    /// Exposed for sibling SQL backend crates (such as the scheduler) that
187    /// render their own statements against the same dialects. Not part of the
188    /// stable public API.
189    #[doc(hidden)]
190    #[must_use]
191    pub fn placeholder(self, index: usize) -> String {
192        match self {
193            Self::Postgres => format!("${index}"),
194            Self::MySql | Self::Sqlite => "?".to_owned(),
195        }
196    }
197
198    /// Wraps a validated identifier in this dialect's native quoting.
199    ///
200    /// PostgreSQL and SQLite use the SQL-standard double quote; MySQL uses
201    /// backticks because it does not enable `ANSI_QUOTES` by default, so a
202    /// double-quoted identifier is parsed as a string literal and rejected.
203    /// Every embedded occurrence of the delimiter is doubled (the
204    /// SQL-standard escape), so the quoting is safe on its own rather than
205    /// relying solely on the caller having run [`validate_table_name`]
206    /// first. Callers are still expected to validate the name up front
207    /// (defense in depth, not a replacement for validation): this crate's
208    /// own DDL helpers do, and sibling SQL backend crates (such as the
209    /// scheduler) are expected to follow the same convention.
210    ///
211    /// Exposed for sibling SQL backend crates so the injection-safe quoting
212    /// rule has a single source of truth. Not part of the stable public API.
213    #[doc(hidden)]
214    #[must_use]
215    pub fn quote_identifier(self, name: &str) -> String {
216        match self {
217            Self::Postgres | Self::Sqlite => format!("\"{}\"", name.replace('"', "\"\"")),
218            Self::MySql => format!("`{}`", name.replace('`', "``")),
219        }
220    }
221
222    /// SQL expression evaluating to the current instant, in a form
223    /// comparable to the stored timestamps.
224    ///
225    /// Exposed for sibling SQL backend crates (such as the scheduler). Not
226    /// part of the stable public API.
227    #[doc(hidden)]
228    #[must_use]
229    pub fn now_expr(self) -> &'static str {
230        match self {
231            Self::Postgres => "NOW()",
232            // UTC_TIMESTAMP(6) is independent of the server session time zone
233            // and matches the DATETIME(6) microsecond precision the MySQL store
234            // binds, so the poll predicate never skips a sub-second retry.
235            Self::MySql => "UTC_TIMESTAMP(6)",
236            Self::Sqlite => "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
237        }
238    }
239
240    /// SQL expression evaluating to the database current instant offset by a
241    /// bound interval, in a form comparable to the stored timestamps.
242    ///
243    /// The offset is taken from the bind parameter at `index` and is always
244    /// anchored to the **database** clock (`NOW()` / `UTC_TIMESTAMP(6)` /
245    /// `strftime('now')`), never the application clock. This keeps lease and
246    /// retry comparisons consistent even when the worker host and the database
247    /// host disagree on wall-clock time (#230).
248    ///
249    /// The bound value's unit differs per engine, so each store binds the
250    /// matching scalar: PostgreSQL binds seconds as `f64`, MySQL binds whole
251    /// microseconds as `i64`, and SQLite binds a `strftime` modifier string
252    /// such as `"+1.500 seconds"`.
253    ///
254    /// Exposed for sibling SQL backend crates (such as the scheduler) so the
255    /// database-clock lease anchoring (#230) has a single source of truth. Not
256    /// part of the stable public API.
257    #[doc(hidden)]
258    #[must_use]
259    pub fn now_plus_interval_expr(self, index: usize) -> String {
260        let ph = self.placeholder(index);
261        match self {
262            Self::Postgres => {
263                format!("(NOW() + (CAST({ph} AS DOUBLE PRECISION) * INTERVAL '1 second'))")
264            }
265            Self::MySql => format!("(UTC_TIMESTAMP(6) + INTERVAL {ph} MICROSECOND)"),
266            Self::Sqlite => format!("strftime('%Y-%m-%dT%H:%M:%fZ', 'now', {ph})"),
267        }
268    }
269
270    /// `SELECT ... WHERE delivered_at IS NULL ... [FOR UPDATE SKIP LOCKED]`.
271    pub(crate) fn poll_sql(self, table: &str) -> String {
272        let qtable = self.quote_identifier(table);
273        let max_attempts = self.placeholder(1);
274        let limit = self.placeholder(2);
275        let now = self.now_expr();
276        let lock = if self.supports_skip_locked() {
277            " FOR UPDATE SKIP LOCKED"
278        } else {
279            ""
280        };
281        format!(
282            "SELECT event_id, event_type, payload, subject_id, created_at, \
283                    attempts, last_error, next_retry_at \
284             FROM {qtable} \
285             WHERE delivered_at IS NULL \
286               AND attempts < {max_attempts} \
287               AND (next_retry_at IS NULL OR next_retry_at <= {now}) \
288             ORDER BY id \
289             LIMIT {limit}{lock}"
290        )
291    }
292
293    /// `UPDATE {qtable} SET delivered_at = {now} WHERE event_id = {ph}`.
294    pub(crate) fn mark_delivered_sql(self, table: &str) -> String {
295        let qtable = self.quote_identifier(table);
296        let event_id = self.placeholder(1);
297        let now = self.now_expr();
298        format!("UPDATE {qtable} SET delivered_at = {now} WHERE event_id = {event_id}")
299    }
300
301    /// `UPDATE {qtable} SET last_error, next_retry_at = {now + interval} ...`.
302    ///
303    /// `next_retry_at` is derived from the **database** clock plus the bound
304    /// backoff interval (parameter 2), not from an application-supplied
305    /// timestamp, so retry scheduling is immune to app/DB clock skew (#230).
306    ///
307    /// The attempt counter is **not** incremented here: it is consumed once
308    /// per dispatch attempt by [`Self::claim_sql`] at claim time, so that a
309    /// worker that crashes between claim and this call still burns one retry
310    /// slot. Incrementing again here would double-count every clean failure.
311    pub(crate) fn mark_failed_sql(self, table: &str) -> String {
312        let qtable = self.quote_identifier(table);
313        let last_error = self.placeholder(1);
314        let next_retry_at = self.now_plus_interval_expr(2);
315        let event_id = self.placeholder(3);
316        format!(
317            "UPDATE {qtable} \
318             SET last_error = {last_error}, next_retry_at = {next_retry_at} \
319             WHERE event_id = {event_id}"
320        )
321    }
322
323    /// `INSERT INTO {qtable} (event_id, event_type, payload, subject_id) VALUES (...)`.
324    pub(crate) fn insert_sql(self, table: &str) -> String {
325        let qtable = self.quote_identifier(table);
326        let p1 = self.placeholder(1);
327        let p2 = self.placeholder(2);
328        let p3 = self.placeholder(3);
329        let p4 = self.placeholder(4);
330        format!(
331            "INSERT INTO {qtable} (event_id, event_type, payload, subject_id) \
332             VALUES ({p1}, {p2}, {p3}, {p4})"
333        )
334    }
335
336    /// Idempotent insert: inserts a row keyed on `event_id` and silently
337    /// no-ops when the key already exists.
338    ///
339    /// `subject_id` is hard-coded to `NULL` in the statement because the raw
340    /// enqueue path carries no subject.
341    ///
342    /// Placeholder count and binding order differ by dialect:
343    ///
344    /// - Postgres: three positional placeholders (`$1` `$2` `$3`), using
345    ///   `ON CONFLICT (event_id) DO NOTHING`. `rows_affected` is 1 on insert,
346    ///   0 on duplicate.
347    /// - SQLite: three `?` placeholders, same `ON CONFLICT` clause.
348    /// - MySQL: four `?` placeholders (`event_id` appears twice). The statement
349    ///   uses `INSERT INTO ... SELECT ... FROM DUAL WHERE NOT EXISTS (...)` so
350    ///   that `rows_affected` is unambiguously 1 on insert and 0 on duplicate,
351    ///   independent of `CLIENT_FOUND_ROWS`. `INSERT IGNORE` is intentionally
352    ///   avoided because it silently suppresses unrelated errors such as a NOT
353    ///   NULL violation.
354    ///
355    /// Callers must bind `event_id` a second time (as the fourth parameter)
356    /// when targeting MySQL. The other two dialects bind three parameters only.
357    pub(crate) fn insert_idempotent_sql(self, table: &str) -> String {
358        let qtable = self.quote_identifier(table);
359        let p1 = self.placeholder(1);
360        let p2 = self.placeholder(2);
361        let p3 = self.placeholder(3);
362        match self {
363            Self::Postgres | Self::Sqlite => {
364                format!(
365                    "INSERT INTO {qtable} (event_id, event_type, payload, subject_id) \
366                     VALUES ({p1}, {p2}, {p3}, NULL) ON CONFLICT (event_id) DO NOTHING"
367                )
368            }
369            Self::MySql => {
370                let p4 = self.placeholder(4);
371                format!(
372                    "INSERT INTO {qtable} (event_id, event_type, payload, subject_id) \
373                     SELECT {p1}, {p2}, {p3}, NULL FROM DUAL \
374                     WHERE NOT EXISTS (SELECT 1 FROM {qtable} WHERE event_id = {p4})"
375                )
376            }
377        }
378    }
379
380    /// Canonical schema DDL (table + indexes) rendered for this dialect.
381    ///
382    /// # Errors
383    ///
384    /// Returns [`OutboxError::Internal`] if `table` is not a valid
385    /// identifier matching `^[a-zA-Z_][a-zA-Z0-9_]*$` or exceeds the
386    /// `MAX_IDENTIFIER_LEN` (63 bytes) limit.
387    pub fn schema_ddl(self, table: &str) -> Result<String, OutboxError> {
388        validate_table_name(table)?;
389        let template = match self {
390            Self::Postgres => POSTGRES_SCHEMA_SQL,
391            Self::MySql => MYSQL_SCHEMA_SQL,
392            Self::Sqlite => SQLITE_SCHEMA_SQL,
393        };
394        Ok(template.replace("{{table}}", table))
395    }
396
397    /// Dead-letter schema DDL (table + indexes) rendered for this dialect.
398    ///
399    /// Creates a table named `{table}_dead_letter`. Envelopes are moved here
400    /// when they exhaust `max_attempts`.
401    ///
402    /// # Errors
403    ///
404    /// Returns [`OutboxError::Internal`] if `table` is not a valid
405    /// identifier matching `^[a-zA-Z_][a-zA-Z0-9_]*$` or exceeds the
406    /// `MAX_IDENTIFIER_LEN` (63 bytes) limit.
407    pub fn dead_letter_schema_ddl(self, table: &str) -> Result<String, OutboxError> {
408        validate_table_name(table)?;
409        let template = match self {
410            Self::Postgres => POSTGRES_DLQ_SCHEMA_SQL,
411            Self::MySql => MYSQL_DLQ_SCHEMA_SQL,
412            Self::Sqlite => SQLITE_DLQ_SCHEMA_SQL,
413        };
414        Ok(template.replace("{{table}}", table))
415    }
416
417    /// `INSERT INTO {dlq} (...) SELECT ... FROM {main} WHERE event_id = {p1}`.
418    ///
419    /// Copies a row from the main outbox table into the dead-letter table.
420    /// `exhausted_at` is not listed and gets its `DEFAULT` value (`NOW()` or
421    /// equivalent). Called inside the same transaction as `mark_failed`.
422    pub(crate) fn insert_dead_letter_sql(self, main: &str, dlq: &str) -> String {
423        let qmain = self.quote_identifier(main);
424        let qdlq = self.quote_identifier(dlq);
425        let event_id = self.placeholder(1);
426        format!(
427            "INSERT INTO {qdlq} \
428             (event_id, event_type, payload, subject_id, created_at, attempts, last_error) \
429             SELECT event_id, event_type, payload, subject_id, created_at, attempts, last_error \
430             FROM {qmain} \
431             WHERE event_id = {event_id}"
432        )
433    }
434
435    /// `DELETE FROM {qtable} WHERE event_id = {p1}`.
436    ///
437    /// Removes the row from the main outbox table after it has been copied to
438    /// the dead-letter table. Called in the same transaction as
439    /// [`Self::insert_dead_letter_sql`].
440    pub(crate) fn delete_from_main_sql(self, table: &str) -> String {
441        let qtable = self.quote_identifier(table);
442        let event_id = self.placeholder(1);
443        format!("DELETE FROM {qtable} WHERE event_id = {event_id}")
444    }
445
446    /// Claim SQL for Postgres: uses `= ANY($2)` with a single UUID-array bind
447    /// so the number of bind parameters is fixed regardless of batch size,
448    /// avoiding the 65,535 bind-parameter limit inherent to an `IN`-list.
449    ///
450    /// For MySQL and SQLite a per-row `IN`-list is still generated because
451    /// neither supports the `= ANY($n)` array-bind syntax.
452    ///
453    /// Sets a soft lease on the given envelopes so competing workers skip
454    /// them until the lease expires, and consumes one retry slot by
455    /// incrementing `attempts`. The lease expiry is computed from the
456    /// **database** clock plus the bound lease interval (parameter 1), not an
457    /// application timestamp, so the lease window is immune to app/DB clock
458    /// skew (#230).
459    ///
460    /// Incrementing `attempts` at claim time (rather than only on failure in
461    /// [`Self::mark_failed_sql`]) is what makes a worker crash between claim
462    /// and acknowledgement safe: the attempt is already counted, so the
463    /// envelope cannot be redelivered forever without ever reaching the
464    /// dead-letter threshold.
465    // Every store issues a claim now (postgres/mysql for the competing-consumer
466    // lease, sqlite to increment attempts); only a feature-less build leaves it
467    // unused.
468    #[cfg_attr(
469        not(any(feature = "postgres", feature = "mysql", feature = "sqlite")),
470        allow(dead_code)
471    )]
472    pub(crate) fn claim_sql(self, table: &str, n: usize) -> String {
473        let qtable = self.quote_identifier(table);
474        let lease = self.now_plus_interval_expr(1);
475        match self {
476            Self::Postgres => {
477                // $2 is bound as a UUID array, so the bind count is always 2
478                // regardless of batch size, sidestepping the 65,535-parameter limit.
479                format!(
480                    "UPDATE {qtable} SET next_retry_at = {lease}, attempts = attempts + 1 \
481                     WHERE event_id = ANY($2)"
482                )
483            }
484            Self::MySql | Self::Sqlite => {
485                let placeholders = (2..=n + 1)
486                    .map(|i| self.placeholder(i))
487                    .collect::<Vec<_>>()
488                    .join(", ");
489                format!(
490                    "UPDATE {qtable} SET next_retry_at = {lease}, attempts = attempts + 1 \
491                     WHERE event_id IN ({placeholders})"
492                )
493            }
494        }
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn skip_locked_support_matches_engine_capabilities() {
504        assert!(Dialect::Postgres.supports_skip_locked());
505        assert!(Dialect::MySql.supports_skip_locked());
506        assert!(!Dialect::Sqlite.supports_skip_locked());
507    }
508
509    #[test]
510    fn postgres_uses_positional_placeholders() {
511        assert_eq!(Dialect::Postgres.placeholder(1), "$1");
512        assert_eq!(Dialect::Postgres.placeholder(4), "$4");
513    }
514
515    #[test]
516    fn mysql_and_sqlite_use_question_mark_placeholders() {
517        assert_eq!(Dialect::MySql.placeholder(1), "?");
518        assert_eq!(Dialect::Sqlite.placeholder(3), "?");
519    }
520
521    #[test]
522    fn postgres_poll_sql_locks_rows_and_binds_positionally() {
523        let sql = Dialect::Postgres.poll_sql("audit_outbox");
524        assert!(sql.contains("FROM \"audit_outbox\""));
525        assert!(sql.contains("$1"));
526        assert!(sql.contains("$2"));
527        assert!(sql.contains("ORDER BY id"));
528        assert!(sql.contains("FOR UPDATE SKIP LOCKED"));
529        assert!(sql.contains("NOW()"));
530    }
531
532    #[test]
533    fn poll_sql_quotes_reserved_word_table_name() {
534        // A table named "user" is a reserved word in SQL; quoting prevents
535        // a runtime syntax error when the table name is embedded in statements.
536        let sql = Dialect::Postgres.poll_sql("user");
537        assert!(sql.contains("FROM \"user\""));
538    }
539
540    #[test]
541    fn mysql_poll_sql_locks_rows_with_question_marks() {
542        let sql = Dialect::MySql.poll_sql("audit_outbox");
543        // MySQL quotes identifiers with backticks, not the SQL-standard double
544        // quote (which it reads as a string literal unless ANSI_QUOTES is set).
545        assert!(sql.contains("FROM `audit_outbox`"));
546        assert!(!sql.contains('"'));
547        assert!(sql.contains('?'));
548        assert!(sql.contains("FOR UPDATE SKIP LOCKED"));
549    }
550
551    #[test]
552    fn sqlite_poll_sql_omits_skip_locked() {
553        let sql = Dialect::Sqlite.poll_sql("audit_outbox");
554        assert!(sql.contains("FROM \"audit_outbox\""));
555        assert!(sql.contains('?'));
556        assert!(!sql.contains("FOR UPDATE SKIP LOCKED"));
557        assert!(sql.contains("strftime"));
558    }
559
560    #[test]
561    fn postgres_mark_delivered_sets_timestamp_by_event_id() {
562        let sql = Dialect::Postgres.mark_delivered_sql("audit_outbox");
563        assert!(sql.contains("UPDATE \"audit_outbox\""));
564        assert!(sql.contains("delivered_at"));
565        assert!(sql.contains("$1"));
566    }
567
568    #[test]
569    fn postgres_mark_failed_does_not_increment_attempts_with_three_binds() {
570        let sql = Dialect::Postgres.mark_failed_sql("audit_outbox");
571        // The increment moved to claim_sql so a crash between claim and
572        // mark_failed still consumes a retry slot; mark_failed must not
573        // double-count.
574        assert!(!sql.contains("attempts = attempts + 1"));
575        assert!(sql.contains("last_error = $1"));
576        // next_retry_at is computed from the DB clock plus the bound interval
577        // ($2), never an app timestamp (#230).
578        assert!(sql.contains("next_retry_at = (NOW() +"));
579        assert!(sql.contains("$2"));
580        assert!(sql.contains("WHERE event_id = $3"));
581    }
582
583    #[test]
584    fn postgres_insert_sql_binds_four_columns() {
585        let sql = Dialect::Postgres.insert_sql("audit_outbox");
586        assert!(sql.contains("INSERT INTO \"audit_outbox\""));
587        assert!(sql.contains("event_id, event_type, payload, subject_id"));
588        assert!(sql.contains("$1, $2, $3, $4"));
589    }
590
591    #[test]
592    fn sqlite_insert_sql_uses_question_marks() {
593        let sql = Dialect::Sqlite.insert_sql("audit_outbox");
594        assert!(sql.contains("INSERT INTO \"audit_outbox\""));
595        assert!(sql.contains("?, ?, ?, ?"));
596    }
597
598    #[test]
599    fn mysql_insert_sql_quotes_table_with_backticks() {
600        // Regression for the cross-dialect quoting bug: MySQL rejects a
601        // double-quoted identifier in DML (it reads it as a string literal),
602        // so the INSERT must use backticks.
603        let sql = Dialect::MySql.insert_sql("audit_outbox");
604        assert!(sql.contains("INSERT INTO `audit_outbox`"));
605        assert!(!sql.contains('"'));
606        assert!(sql.contains("?, ?, ?, ?"));
607    }
608
609    #[test]
610    fn postgres_insert_idempotent_sql_uses_on_conflict_do_nothing() {
611        let sql = Dialect::Postgres.insert_idempotent_sql("audit_outbox");
612        assert!(sql.contains("INSERT INTO \"audit_outbox\""));
613        assert!(sql.contains("event_id, event_type, payload, subject_id"));
614        assert!(sql.contains("$1, $2, $3, NULL"));
615        assert!(sql.contains("ON CONFLICT (event_id) DO NOTHING"));
616    }
617
618    #[test]
619    fn sqlite_insert_idempotent_sql_uses_on_conflict_do_nothing() {
620        let sql = Dialect::Sqlite.insert_idempotent_sql("audit_outbox");
621        assert!(sql.contains("INSERT INTO \"audit_outbox\""));
622        assert!(sql.contains("?, ?, ?, NULL"));
623        assert!(sql.contains("ON CONFLICT (event_id) DO NOTHING"));
624    }
625
626    #[test]
627    fn mysql_insert_idempotent_sql_uses_where_not_exists_and_backticks() {
628        let sql = Dialect::MySql.insert_idempotent_sql("audit_outbox");
629        assert!(sql.contains("INSERT INTO `audit_outbox`"));
630        assert!(!sql.contains('"'));
631        // Four ? placeholders: event_id, event_type, payload, event_id (for WHERE NOT EXISTS)
632        assert!(sql.contains("FROM DUAL"));
633        assert!(sql.contains("WHERE NOT EXISTS"));
634        assert!(sql.contains("SELECT 1 FROM `audit_outbox` WHERE event_id = ?"));
635        assert!(!sql.contains("INSERT IGNORE"));
636        assert!(!sql.contains("ON DUPLICATE KEY"));
637    }
638
639    #[test]
640    fn quote_identifier_is_dialect_specific() {
641        assert_eq!(Dialect::Postgres.quote_identifier("t"), "\"t\"");
642        assert_eq!(Dialect::Sqlite.quote_identifier("t"), "\"t\"");
643        assert_eq!(Dialect::MySql.quote_identifier("t"), "`t`");
644    }
645
646    #[test]
647    fn quote_identifier_doubles_an_embedded_double_quote_for_postgres_and_sqlite() {
648        // Defense in depth (#359): quote_identifier must be safe on its own,
649        // independent of the validate_table_name convention observed by
650        // in-crate and sibling-crate callers. Doubling the delimiter is the
651        // SQL-standard escape for an embedded quote character.
652        assert_eq!(Dialect::Postgres.quote_identifier("a\"b"), "\"a\"\"b\"");
653        assert_eq!(Dialect::Sqlite.quote_identifier("a\"b"), "\"a\"\"b\"");
654    }
655
656    #[test]
657    fn quote_identifier_doubles_an_embedded_backtick_for_mysql() {
658        // Same defense-in-depth rule for MySQL's backtick delimiter.
659        assert_eq!(Dialect::MySql.quote_identifier("a`b"), "`a``b`");
660    }
661
662    #[test]
663    fn quote_identifier_is_unchanged_for_names_without_a_delimiter() {
664        // The escape must be transparent for the common case (a name that
665        // already passed validate_table_name and contains no delimiter),
666        // so every existing caller across the workspace keeps working.
667        assert_eq!(
668            Dialect::Postgres.quote_identifier("audit_outbox"),
669            "\"audit_outbox\""
670        );
671        assert_eq!(
672            Dialect::Sqlite.quote_identifier("audit_outbox"),
673            "\"audit_outbox\""
674        );
675        assert_eq!(
676            Dialect::MySql.quote_identifier("audit_outbox"),
677            "`audit_outbox`"
678        );
679    }
680
681    #[test]
682    fn postgres_schema_ddl_matches_current_canonical_schema() {
683        let ddl = Dialect::Postgres.schema_ddl("audit_outbox").unwrap();
684        assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox"));
685        assert!(ddl.contains("BIGSERIAL"));
686        assert!(ddl.contains("UUID"));
687        assert!(ddl.contains("JSONB"));
688        assert!(ddl.contains("TIMESTAMPTZ"));
689        assert!(ddl.contains("idx_audit_outbox_pending"));
690        assert!(ddl.contains("idx_audit_outbox_subject"));
691        assert!(!ddl.contains("{{table}}"));
692    }
693
694    #[test]
695    fn mysql_schema_ddl_uses_native_types() {
696        let ddl = Dialect::MySql.schema_ddl("audit_outbox").unwrap();
697        assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox"));
698        assert!(ddl.contains("AUTO_INCREMENT"));
699        assert!(ddl.contains("BINARY(16)"));
700        assert!(ddl.contains("JSON"));
701        assert!(!ddl.contains("{{table}}"));
702    }
703
704    #[test]
705    fn sqlite_schema_ddl_uses_portable_text_types() {
706        let ddl = Dialect::Sqlite.schema_ddl("audit_outbox").unwrap();
707        assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox"));
708        assert!(ddl.contains("AUTOINCREMENT"));
709        assert!(ddl.contains("BLOB"));
710        assert!(ddl.contains("strftime"));
711        assert!(!ddl.contains("{{table}}"));
712    }
713
714    #[test]
715    fn schema_ddl_rejects_invalid_table_name() {
716        let err = Dialect::Postgres.schema_ddl("bad name; DROP").unwrap_err();
717        assert!(matches!(err, OutboxError::Internal(_)));
718    }
719
720    #[test]
721    fn mysql_poll_compares_against_microsecond_utc() {
722        let sql = Dialect::MySql.poll_sql("audit_outbox");
723        assert!(sql.contains("UTC_TIMESTAMP(6)"));
724        assert!(!sql.contains("UTC_TIMESTAMP()"));
725    }
726
727    #[test]
728    fn mysql_mark_delivered_uses_microsecond_utc() {
729        let sql = Dialect::MySql.mark_delivered_sql("audit_outbox");
730        assert!(sql.contains("delivered_at = UTC_TIMESTAMP(6)"));
731    }
732
733    #[test]
734    fn mysql_schema_ddl_defaults_created_at_to_utc() {
735        let ddl = Dialect::MySql.schema_ddl("audit_outbox").unwrap();
736        assert!(ddl.contains("UTC_TIMESTAMP(6)"));
737    }
738
739    #[test]
740    fn postgres_dead_letter_schema_ddl_substitutes_table_name() {
741        let ddl = Dialect::Postgres
742            .dead_letter_schema_ddl("audit_outbox")
743            .unwrap();
744        assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox_dead_letter"));
745        assert!(ddl.contains("exhausted_at"));
746        // The redundant event_id index is dropped: event_id is already UNIQUE,
747        // which creates an implicit index. Only the exhausted_at index remains.
748        assert!(!ddl.contains("idx_audit_outbox_dead_letter_event_id"));
749        assert!(ddl.contains("idx_audit_outbox_dead_letter_exhausted_at"));
750        assert!(!ddl.contains("{{table}}"));
751    }
752
753    #[test]
754    fn mysql_dead_letter_schema_ddl_uses_native_types() {
755        let ddl = Dialect::MySql
756            .dead_letter_schema_ddl("audit_outbox")
757            .unwrap();
758        assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox_dead_letter"));
759        assert!(ddl.contains("BINARY(16)"));
760        assert!(ddl.contains("UTC_TIMESTAMP(6)"));
761        assert!(!ddl.contains("{{table}}"));
762    }
763
764    #[test]
765    fn sqlite_dead_letter_schema_ddl_uses_portable_text_types() {
766        let ddl = Dialect::Sqlite
767            .dead_letter_schema_ddl("audit_outbox")
768            .unwrap();
769        assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox_dead_letter"));
770        assert!(ddl.contains("strftime"));
771        assert!(!ddl.contains("{{table}}"));
772    }
773
774    #[test]
775    fn dead_letter_schema_ddl_rejects_invalid_table_name() {
776        let err = Dialect::Postgres
777            .dead_letter_schema_ddl("bad name; DROP")
778            .unwrap_err();
779        assert!(matches!(err, OutboxError::Internal(_)));
780    }
781
782    #[test]
783    fn postgres_insert_dead_letter_sql_selects_from_main() {
784        let sql =
785            Dialect::Postgres.insert_dead_letter_sql("audit_outbox", "audit_outbox_dead_letter");
786        assert!(sql.contains("INSERT INTO \"audit_outbox_dead_letter\""));
787        assert!(sql.contains("SELECT"));
788        assert!(sql.contains("FROM \"audit_outbox\""));
789        assert!(sql.contains("$1"));
790        assert!(!sql.contains("exhausted_at"));
791    }
792
793    #[test]
794    fn sqlite_insert_dead_letter_sql_uses_question_mark() {
795        let sql = Dialect::Sqlite.insert_dead_letter_sql("audit_outbox", "audit_outbox_dlq");
796        assert!(sql.contains("INSERT INTO \"audit_outbox_dlq\""));
797        assert!(sql.contains("FROM \"audit_outbox\""));
798        assert!(sql.contains('?'));
799    }
800
801    #[test]
802    fn postgres_delete_from_main_sql_binds_positionally() {
803        let sql = Dialect::Postgres.delete_from_main_sql("audit_outbox");
804        assert!(sql.contains("DELETE FROM \"audit_outbox\""));
805        assert!(sql.contains("$1"));
806    }
807
808    #[test]
809    fn sqlite_delete_from_main_sql_uses_question_mark() {
810        let sql = Dialect::Sqlite.delete_from_main_sql("audit_outbox");
811        assert!(sql.contains("DELETE FROM \"audit_outbox\""));
812        assert!(sql.contains('?'));
813    }
814
815    #[test]
816    fn postgres_claim_sql_uses_any_array_instead_of_in_list() {
817        // ANY($2) avoids the 65,535 bind-parameter limit that an IN-list of
818        // UUIDs would hit at large batch sizes (#240).
819        let sql = Dialect::Postgres.claim_sql("audit_outbox", 3);
820        assert!(sql.contains("UPDATE \"audit_outbox\""));
821        // Lease anchored to the DB clock plus the bound interval ($1), #230.
822        assert!(sql.contains("SET next_retry_at = (NOW() +"));
823        assert!(sql.contains("$1"));
824        // Single array bind; no per-row placeholders ($2, $3, $4).
825        assert!(sql.contains("WHERE event_id = ANY($2)"));
826        assert!(!sql.contains("$3"));
827        assert!(!sql.contains("$4"));
828        assert!(!sql.contains("WHERE event_id IN"));
829    }
830
831    #[test]
832    fn postgres_claim_sql_any_bind_count_is_independent_of_batch_size() {
833        // Regardless of n the Postgres claim SQL has exactly two bind
834        // parameters: $1 for the lease interval and $2 for the UUID array.
835        for n in [1, 10, 1000] {
836            let sql = Dialect::Postgres.claim_sql("audit_outbox", n);
837            assert!(
838                sql.contains("ANY($2)"),
839                "n={n}: expected ANY($2), got: {sql}"
840            );
841            assert!(
842                !sql.contains("$3"),
843                "n={n}: unexpected $3 placeholder, got: {sql}"
844            );
845        }
846    }
847
848    #[test]
849    fn mysql_claim_sql_uses_question_marks() {
850        let sql = Dialect::MySql.claim_sql("audit_outbox", 2);
851        assert!(sql.contains("UPDATE `audit_outbox`"));
852        assert!(sql.contains("SET next_retry_at = (UTC_TIMESTAMP(6) + INTERVAL ? MICROSECOND)"));
853        assert!(sql.contains("WHERE event_id IN (?, ?)"));
854    }
855
856    #[test]
857    fn sqlite_claim_sql_uses_question_marks() {
858        let sql = Dialect::Sqlite.claim_sql("audit_outbox", 1);
859        assert!(sql.contains("UPDATE \"audit_outbox\""));
860        assert!(sql.contains("SET next_retry_at = strftime("));
861        assert!(sql.contains("'now', ?)"));
862        assert!(sql.contains("WHERE event_id IN (?)"));
863    }
864
865    #[test]
866    fn now_plus_interval_uses_db_clock_per_dialect() {
867        // Regression guard for #230: the lease/retry anchor is the database
868        // clock, never an application timestamp bound by the worker.
869        assert!(
870            Dialect::Postgres
871                .now_plus_interval_expr(1)
872                .contains("NOW()")
873        );
874        assert!(
875            Dialect::MySql
876                .now_plus_interval_expr(1)
877                .contains("UTC_TIMESTAMP(6)")
878        );
879        assert!(Dialect::Sqlite.now_plus_interval_expr(1).contains("'now'"));
880    }
881
882    #[test]
883    fn claim_sql_increments_attempts_for_every_dialect() {
884        // Regression guard for #213: claiming an envelope must consume a
885        // retry slot so a crash between claim and mark_failed cannot
886        // redeliver a poison row forever.
887        for dialect in [Dialect::Postgres, Dialect::MySql, Dialect::Sqlite] {
888            let sql = dialect.claim_sql("audit_outbox", 2);
889            assert!(
890                sql.contains("attempts = attempts + 1"),
891                "{dialect:?} claim_sql must increment attempts, got: {sql}"
892            );
893        }
894    }
895
896    #[test]
897    fn schema_ddl_rejects_overlength_table_name() {
898        // A name of 64 bytes must exceed MAX_IDENTIFIER_LEN (63) and be rejected
899        // so that derived index names cannot collide after server-side truncation.
900        let long_name = "a".repeat(64);
901        for dialect in [Dialect::Postgres, Dialect::MySql, Dialect::Sqlite] {
902            let err = dialect.schema_ddl(&long_name).unwrap_err();
903            assert!(
904                matches!(err, OutboxError::Internal(_)),
905                "{dialect:?} must reject a 64-byte table name"
906            );
907        }
908    }
909
910    #[test]
911    fn dlq_schema_ddl_does_not_create_redundant_event_id_index() {
912        // event_id is UNIQUE in every DLQ DDL, which creates an implicit index.
913        // A separate named index would be write overhead for no read benefit.
914        for dialect in [Dialect::Postgres, Dialect::MySql, Dialect::Sqlite] {
915            let ddl = dialect.dead_letter_schema_ddl("audit_outbox").unwrap();
916            assert!(
917                !ddl.contains("dead_letter_event_id"),
918                "{dialect:?} DLQ DDL must not create a redundant event_id index, got:\n{ddl}"
919            );
920        }
921    }
922
923    #[test]
924    fn sql_generation_quotes_identifiers() {
925        // All DML helpers must embed the identifier quoted in the dialect's
926        // native style so reserved words (e.g. `user`, `order`) are safe
927        // without runtime errors. MySQL uses backticks; the others use the
928        // SQL-standard double quote.
929        for dialect in [Dialect::Postgres, Dialect::MySql, Dialect::Sqlite] {
930            let table = "user";
931            let quoted = dialect.quote_identifier(table);
932            assert!(
933                dialect.poll_sql(table).contains(&quoted),
934                "{dialect:?} poll_sql must quote the table name"
935            );
936            assert!(
937                dialect.mark_delivered_sql(table).contains(&quoted),
938                "{dialect:?} mark_delivered_sql must quote the table name"
939            );
940            assert!(
941                dialect.mark_failed_sql(table).contains(&quoted),
942                "{dialect:?} mark_failed_sql must quote the table name"
943            );
944            assert!(
945                dialect.insert_sql(table).contains(&quoted),
946                "{dialect:?} insert_sql must quote the table name"
947            );
948            assert!(
949                dialect.claim_sql(table, 1).contains(&quoted),
950                "{dialect:?} claim_sql must quote the table name"
951            );
952            assert!(
953                dialect.delete_from_main_sql(table).contains(&quoted),
954                "{dialect:?} delete_from_main_sql must quote the table name"
955            );
956        }
957    }
958}