Drizzle ORM + pg-boss Atomic Transactions Tutorial (2026)

May 31, 2026

Drizzle ORM + pg-boss Atomic Transactions Tutorial (2026)

Wire Drizzle ORM 0.45.2 to pg-boss 12.18.2 through pg-boss's built-in fromDrizzle adapter so a row insert and the matching job send share one Postgres transaction. Commit together, roll back together. End-to-end TypeScript, retry policies, dead-letter queues, cron schedules, runnable against postgres:18-alpine.

TL;DR

The "send an email after creating a row" pattern is almost always written with two unrelated calls: INSERT INTO orders ... then boss.send('email', ...). If the process dies between them, you ship an order with no email — or worse, an email for an order that was rolled back. pg-boss 12 ships a fromDrizzle(tx, sql) adapter that routes the job insert through your Drizzle transaction object, so both writes share one Postgres transaction. Commit together, roll back together, no glue code. This post builds the full stack in 8 steps.

What you'll learn

  • How pg-boss.fromDrizzle makes the queue insert share a transaction with your Drizzle writes
  • How to scaffold a Postgres 18 + pg-boss 12 + Drizzle 0.45.2 project with end-to-end TypeScript types
  • How to send a job atomically with a row insert so both commit or both roll back
  • How to set retry limits, exponential backoff, and a dead-letter queue per queue
  • How to schedule recurring jobs with cron expressions and a timezone
  • How to write a worker that sends downstream jobs inside its own transaction
  • How to verify the rollback behavior with a deliberately failing transaction
  • How to shut down the queue and pool cleanly on SIGTERM

Prerequisites

You need Node.js 24 (Active LTS as of May 2026, supported through April 2028 — Node 22 also works because pg-boss 12 requires Node ≥22.12.0)1. You need Docker (or another way to run Postgres 18) and a terminal with npm and psql available locally is helpful but not required. Familiarity with TypeScript generics and async/await is assumed.

All commands are tested on macOS and Linux. Windows users should run everything from WSL2 so Docker, Node, and psql share one Linux userspace.

Why "atomic enqueue" matters

Background jobs that fire after a database write are everywhere: order receipts, post-signup welcome emails, downstream webhooks, search-index updates, fulfillment kickoffs. The naive pattern looks like this:

await db.insert(orders).values(input);     // commit 1
await boss.send('order.receipt-email', { ... });  // commit 2

There are four failure modes between commit 1 and commit 2 that hose your data:

  1. Process crash after commit 1 → order exists, no email. Customer paid, got nothing.
  2. Process crash after commit 2, before responding to the HTTP caller → caller retries, you create a second order, the customer gets two emails.
  3. boss.send rejects with a transient Postgres connection error → order exists, no email.
  4. A later step in the same handler throws → you try/catch and rollback the row, but the job is already in the queue, and the worker emails a customer about an order that no longer exists.

The fix is to put both writes in the same Postgres transaction. pg-boss's boss.send accepts a { db } option that overrides where the queue insert goes. If you point that db at your Drizzle transaction's execute method, the job row lands inside the same transaction as the row insert.2

Step 1 — Scaffold the project

Make a fresh directory and install pinned versions:

mkdir orders-api && cd orders-api
npm init -y
npm pkg set type=module
npm install --save-exact pg-boss@12.18.2 drizzle-orm@0.45.2 pg@8.21.0
npm install --save-exact --save-dev typescript@6.0.3 \
  drizzle-kit@0.31.10 tsx@4.22.3 dotenv@17.4.2 \
  @types/pg@8.20.0 @types/node@22.19.17

The --save-exact flag is critical: npm install pg-boss@12.18.2 without it writes ^12.18.2, so a future npm install may upgrade to 12.19.x and quietly change the queue option schema. Lock the patch level.3

Add a tsconfig.json with the strict-most options you can stomach. The combo below is the production baseline used in the rest of the post:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"]
}

allowImportingTsExtensions: true lets you import files as './schema.ts'. Node 22.18+ and Node 24 strip types natively, and tsx accepts the same syntax, so the same source runs in both4.

Add a .env with the connection string you'll point everything at:

echo 'DATABASE_URL=postgres://orders:orders@127.0.0.1:5432/orders' > .env
echo '.env' >> .gitignore
echo 'node_modules' >> .gitignore

Step 2 — Boot Postgres 18

Use the official postgres:18-alpine image — the floating tag currently aliases 18-alpine3.23 (Alpine 3.23 base)5:

docker run --rm -d --name orders-pg \
  -e POSTGRES_USER=orders \
  -e POSTGRES_PASSWORD=orders \
  -e POSTGRES_DB=orders \
  -p 5432:5432 \
  postgres:18-alpine

Verify it boots:

docker exec orders-pg pg_isready -U orders -d orders
# Expected: /var/run/postgresql:5432 - accepting connections

pg-boss will create its own schema (pgboss by default — we'll lock that explicitly in Step 4) and run its migrations on first start. You don't need to pre-create anything.

Step 3 — Define the Drizzle schema

Create src/db/schema.ts with the single business table this tutorial cares about — an orders table with a status enum:

// src/db/schema.ts
import {
  pgTable,
  uuid,
  text,
  integer,
  timestamp,
  index,
  pgEnum,
} from 'drizzle-orm/pg-core';

export const orderStatus = pgEnum('order_status', [
  'pending',
  'paid',
  'fulfilled',
  'failed',
]);

export const orders = pgTable(
  'orders',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    customerEmail: text('customer_email').notNull(),
    amountCents: integer('amount_cents').notNull(),
    currency: text('currency').notNull().default('USD'),
    status: orderStatus('status').notNull().default('pending'),
    createdAt: timestamp('created_at', { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    index('orders_email_idx').on(t.customerEmail),
    index('orders_status_idx').on(t.status),
  ],
);

export type Order = typeof orders.$inferSelect;
export type OrderInsert = typeof orders.$inferInsert;

$inferSelect and $inferInsert give you typed row shapes for free — no separate DTO file6.

Wire the Drizzle client to a pg.Pool in src/db/client.ts:

// src/db/client.ts
import 'dotenv/config';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from './schema.ts';

const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required');

export const pool = new Pool({
  connectionString: url,
  max: 10,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});

pool.on('error', (err) => {
  console.error('pg pool error', err);
});

export const db = drizzle({ client: pool, schema, casing: 'snake_case' });
export type Database = typeof db;

The casing: 'snake_case' option is a safety default. The schema above passes the explicit column names (text('customer_email')), so casing has no effect there — but any column you add later without an explicit name (text() alone) will still be emitted as snake_case in the generated SQL, matching the rest of your tables.

Add a Drizzle Kit config and generate the migration:

// drizzle.config.ts
import 'dotenv/config';
import type { Config } from 'drizzle-kit';

const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required');

export default {
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: { url },
} satisfies Config;

Add scripts to package.json:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:push": "drizzle-kit push",
    "typecheck": "tsc --noEmit",
    "dev": "tsx watch src/index.ts"
  }
}

Then push the schema:

npm run db:push
# [✓] Pulling schema from database...
# [✓] Changes applied

Step 4 — Wire up pg-boss

Create src/queue.ts:

// src/queue.ts
import 'dotenv/config';
import { PgBoss } from 'pg-boss';

const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required');

export const boss = new PgBoss({
  connectionString: url,
  schema: 'pgboss',
  max: 5,
  monitorIntervalSeconds: 30,
  application_name: 'orders-api',
});

boss.on('error', (err: Error) => {
  console.error('pg-boss error', err);
});

export const QUEUES = {
  receiptEmail: 'order.receipt-email',
  fulfillment: 'order.fulfillment',
} as const;

Two non-obvious things here:

  • The import is import { PgBoss } from 'pg-boss' — named, not default. pg-boss 12 is pure ESM (Node ≥22.12.0) and the class is exported as a named export only7.
  • application_name flows through to Postgres's pg_stat_activity.application_name, which makes it trivial to find pg-boss's connections (SELECT pid, query FROM pg_stat_activity WHERE application_name = 'orders-api';) when triaging. Set it explicitly even though it's optional.

When you call boss.start() for the first time, pg-boss creates the pgboss schema and runs its migrations. The schema version on pg-boss 12.18.2 is 30 (you can check at runtime with boss.schemaVersion() or via the CLI: npx pg-boss version).

Step 5 — The atomic create-order function

This is the heart of the tutorial. Create src/orders/create.ts:

// src/orders/create.ts
import { sql } from 'drizzle-orm';
import { fromDrizzle } from 'pg-boss';
import { db } from '../db/client.ts';
import { orders, type OrderInsert } from '../db/schema.ts';
import { boss, QUEUES } from '../queue.ts';

export interface CreateOrderInput {
  customerEmail: string;
  amountCents: number;
  currency?: string;
}

export interface ReceiptEmailJob {
  orderId: string;
  customerEmail: string;
  amountCents: number;
  currency: string;
}

export async function createOrderAtomic(
  input: CreateOrderInput,
): Promise<{ orderId: string; receiptJobId: string | null }> {
  return db.transaction(async (tx) => {
    const insert: OrderInsert = {
      customerEmail: input.customerEmail,
      amountCents: input.amountCents,
      currency: input.currency ?? 'USD',
      status: 'pending',
    };

    const [row] = await tx
      .insert(orders)
      .values(insert)
      .returning({ id: orders.id });

    if (!row) throw new Error('order insert returned no row');

    const receiptJobId = await boss.send(
      QUEUES.receiptEmail,
      {
        orderId: row.id,
        customerEmail: input.customerEmail,
        amountCents: input.amountCents,
        currency: input.currency ?? 'USD',
      } satisfies ReceiptEmailJob,
      { db: fromDrizzle(tx, sql) },
    );

    return { orderId: row.id, receiptJobId };
  });
}

Three things to internalize:

  1. db.transaction(async (tx) => {...}) opens a single Postgres transaction. Every query against tx runs on the same physical connection until the callback resolves or throws.
  2. boss.send(name, data, { db: fromDrizzle(tx, sql) }) is where the magic happens. The db option overrides pg-boss's default connection pool with the IDatabase adapter returned by fromDrizzle. The adapter's executeSql(text, values) rewrites the $1/$2 placeholders to a Drizzle template-tag form and calls tx.execute(sql(strings, ...reordered)) — same transaction, same connection, same commit boundary8.
  3. The satisfies keyword on the job payload enforces shape without widening: if you later add a required field to ReceiptEmailJob, every call site fails type-checking until you fix it.

If anything inside the callback throws, Drizzle rolls back automatically — and the job send rolls back with it. There is no "pending" job in the queue for an order that doesn't exist.

Step 6 — Configure retries and a dead-letter queue

The default retry budget is 2 with no delay, which is fine for hello-world but irresponsible for production9. Override it per-queue. Create src/orders/retry.ts:

// src/orders/retry.ts
import { boss } from '../queue.ts';

export async function configureRetries(): Promise<void> {
  await boss.createQueue('order.receipt-email', {
    policy: 'standard',
    retryLimit: 5,
    retryDelay: 30,
    retryBackoff: true,
    expireInSeconds: 60,
    retentionSeconds: 60 * 60 * 24 * 14,
    deleteAfterSeconds: 60 * 60 * 24 * 7,
    deadLetter: 'order.receipt-email.dlq',
  });

  await boss.createQueue('order.receipt-email.dlq');
}

Field names worth memorizing because they're a common fact-sheet error:

FieldDefaultNotes
retryLimit2Max attempts after the initial run
retryDelay0Seconds between attempts
retryBackofffalseWhen true, applies exponential backoff to retryDelay
expireInSeconds900Max seconds in active state before the job is marked failed
retentionSeconds1209600 (14d)How long unfinished jobs are kept
deleteAfterSeconds604800 (7d)How long completed jobs are kept
deadLetternoneName of another queue that receives the payload after final failure

retryLimit: 5 + retryDelay: 30 + retryBackoff: true gives the simplified backoff 30 * 2^n seconds between attempts — roughly 30s, 60s, 120s, 240s, 480s, with jitter — then the payload lands in order.receipt-email.dlq for human review. The pg-boss source documents the formula as Math.min(retryDelayMax, retryDelay * (2 ** Math.min(16, retryCount) / 2 + 2 * Math.min(16, retryCount) / 2 * Math.random())).

There is no retentionDays field on a queue. Trying to pass one is a TypeScript error against the typed Omit<Queue, 'name'> parameter; use the *Seconds fields above instead10. (Note that pg-boss does ship one *Days field — warningRetentionDays on the global MaintenanceOptions — but that controls the BAM warning log, not job retention.)

Step 7 — Workers that send downstream jobs atomically

Workers run inside their own transactions. When a worker fans out to other jobs, you want the same rollback semantics. Create src/orders/fulfill.ts:

// src/orders/fulfill.ts
import { eq, sql } from 'drizzle-orm';
import { fromDrizzle } from 'pg-boss';
import { db } from '../db/client.ts';
import { orders } from '../db/schema.ts';
import { boss, QUEUES } from '../queue.ts';

export interface FulfillmentJob {
  orderId: string;
}

export async function registerFulfillmentWorker(): Promise<void> {
  await boss.work<FulfillmentJob, void>(
    QUEUES.fulfillment,
    {
      batchSize: 1,
      pollingIntervalSeconds: 1,
    },
    async (jobs) => {
      for (const job of jobs) {
        const { orderId } = job.data;
        await db.transaction(async (tx) => {
          const [row] = await tx
            .update(orders)
            .set({ status: 'fulfilled' })
            .where(eq(orders.id, orderId))
            .returning({ id: orders.id });

          if (!row) {
            throw new Error(`order ${orderId} not found`);
          }

          await boss.send(
            QUEUES.receiptEmail,
            { orderId, type: 'fulfilled' },
            { db: fromDrizzle(tx, sql) },
          );
        });
      }
    },
  );
}

export async function scheduleNightlyReconcile(): Promise<void> {
  await boss.schedule(
    'reconcile.orders',
    '0 2 * * *',
    {},
    { tz: 'UTC' },
  );

  await boss.work('reconcile.orders', async () => {
    console.log('[reconcile] running nightly check at', new Date().toISOString());
  });
}

Three takeaways:

  • boss.work<ReqData, ResData>(name, options, handler) — the handler receives an array of Job<ReqData>, not a single job. batchSize: 1 makes the array always length 1, which is what most people actually want when they first reach for the API.
  • The handler opens a Drizzle transaction. Inside, both the row update and the receipt-email send share that transaction. If the receipt send fails after the row update, the row update rolls back — the next poll picks up the same fulfillment job and retries cleanly.
  • boss.schedule(name, cron, data, { tz }) registers a cron schedule. The tz option defaults to 'UTC'; pass a different IANA zone (e.g. 'America/New_York') only when you actually need wall-clock scheduling.11

Step 8 — Prove rollback works

A demo function that deliberately throws after the job send, so you can see the queue stay empty when the transaction rolls back. Create src/orders/rollback-demo.ts:

// src/orders/rollback-demo.ts
import { sql } from 'drizzle-orm';
import { fromDrizzle } from 'pg-boss';
import { db } from '../db/client.ts';
import { orders } from '../db/schema.ts';
import { boss, QUEUES } from '../queue.ts';

export class FraudCheckFailed extends Error {
  override readonly name = 'FraudCheckFailed';
}

export async function createOrderWithFraudCheck(input: {
  customerEmail: string;
  amountCents: number;
}): Promise<{ orderId: string } | { rolledBack: true }> {
  try {
    return await db.transaction(async (tx) => {
      const [row] = await tx
        .insert(orders)
        .values({
          customerEmail: input.customerEmail,
          amountCents: input.amountCents,
          status: 'pending',
        })
        .returning({ id: orders.id });

      if (!row) throw new Error('insert returned no row');

      await boss.send(
        QUEUES.receiptEmail,
        { orderId: row.id, customerEmail: input.customerEmail },
        { db: fromDrizzle(tx, sql) },
      );

      if (input.amountCents > 1_000_000) {
        throw new FraudCheckFailed('amount exceeds $10,000 limit');
      }

      return { orderId: row.id };
    });
  } catch (err) {
    if (err instanceof FraudCheckFailed) {
      console.warn('fraud check failed; transaction rolled back', err.message);
      return { rolledBack: true };
    }
    throw err;
  }
}

Wire everything into an entry point in src/index.ts:

// src/index.ts
import 'dotenv/config';
import { boss } from './queue.ts';
import { createOrderAtomic } from './orders/create.ts';
import { configureRetries } from './orders/retry.ts';
import { registerFulfillmentWorker, scheduleNightlyReconcile } from './orders/fulfill.ts';
import { createOrderWithFraudCheck } from './orders/rollback-demo.ts';
import { pool } from './db/client.ts';

async function shutdown(): Promise<void> {
  console.log('shutting down...');
  await boss.stop({ close: true, graceful: true, timeout: 30_000 });
  await pool.end();
  process.exit(0);
}

process.on('SIGTERM', () => void shutdown());
process.on('SIGINT', () => void shutdown());

async function main(): Promise<void> {
  await boss.start();
  await configureRetries();
  await registerFulfillmentWorker();
  await scheduleNightlyReconcile();

  const ok = await createOrderAtomic({
    customerEmail: 'alice@example.com',
    amountCents: 4999,
  });
  console.log('committed order:', ok);

  const rb = await createOrderWithFraudCheck({
    customerEmail: 'mallory@example.com',
    amountCents: 2_500_000,
  });
  console.log('fraud demo:', rb);
}

main().catch((err) => {
  console.error('fatal', err);
  process.exit(1);
});

boss.stop({ close: true, graceful: true, timeout: 30_000 }) lets active jobs finish for up to 30 seconds before forcing the shutdown. All three options are also the pg-boss 12.18.2 defaults — close = true, graceful = true, timeout = 30000 — so passing them explicitly here just documents intent at the call site. When close: true, pg-boss closes its own internal pg.Pool (the one it created from your connection string) so the process can exit cleanly. Note that stop does not accept a wait flag; that field lives on OffWorkOptions (used by offWork), which is a common source of TS2353 errors when adapting tutorials from older versions.

Verification

Boot the app:

npm run dev

You should see something like:

committed order: { orderId: '8f3a...', receiptJobId: 'a9c1...' }
fraud check failed; transaction rolled back amount exceeds $10,000 limit
fraud demo: { rolledBack: true }

(No worker is registered for order.receipt-email in this minimal demo, so the receipt job lands in the queue and waits there. That's exactly what we want for the rollback check.) Now confirm the rollback in Postgres directly:

docker exec -it orders-pg psql -U orders -d orders \
  -c "SELECT count(*) FROM orders WHERE customer_email='alice@example.com';"
# count: 1
docker exec -it orders-pg psql -U orders -d orders \
  -c "SELECT count(*) FROM orders WHERE customer_email='mallory@example.com';"
# count: 0
docker exec -it orders-pg psql -U orders -d orders \
  -c "SELECT count(*) FROM pgboss.job WHERE name='order.receipt-email' AND data->>'customerEmail'='mallory@example.com';"
# count: 0

The successful order is committed, but mallory's row never existed and her job was never enqueued. That's the atomic-enqueue guarantee. (pgboss.job is the partitioned parent table that every queue's rows go into; non-partition queues all share the pgboss.job_common partition by default.)

Troubleshooting

TS2613: Module 'pg-boss' has no default export. Change import PgBoss from 'pg-boss' to import { PgBoss } from 'pg-boss'. pg-boss 12 is pure ESM and exports PgBoss as a named export only — the published dist/index.d.ts declares it as export declare class PgBoss.

TS2353: Object literal may only specify known properties, and 'retentionDays' does not exist in type 'Omit<Queue, "name">'. There is no retentionDays field on QueueOptions. Use retentionSeconds: 60 * 60 * 24 * 14 for the same effect10. (pg-boss does ship warningRetentionDays on MaintenanceOptions, but that controls the BAM warning log — different scope.)

error: relation "pgboss.job" does not exist You called boss.send or queried the schema before boss.start(). The first start() call runs the migrations that create the schema, the partitioned parent table pgboss.job, and the default partition pgboss.job_common. Always await boss.start() before any other pg-boss method.

Worker handler types out to any for jobs. Provide both generics: boss.work<ReqData, ResData>(name, options, handler). With only one, the handler param falls through to the default any signature. The jobs parameter is always Job<ReqData>[], not a single Job<ReqData> — see the README's "batch size" note2.

fromDrizzle is undefined at runtime. You're on pg-boss <12.17.0. The fromDrizzle/fromKnex/fromKysely/fromPrisma adapters all landed in pg-boss 12.17.0 (published 2026-04-24); earlier 12.x releases shipped only the manual IDatabase interface. Upgrade with npm install pg-boss@>=12.17.0. If you can't upgrade, write a small IDatabase wrapper that calls tx.execute(sql.raw(...)) with the placeholders already interpolated — sql.raw accepts only a string, so you have to substitute $1/$2 values yourself, which is exactly the wheel the official adapter reinvents for you.

Next steps

This stack covers the atomic-enqueue case end-to-end, but production systems usually grow three more things:

  • Observability: emit OpenTelemetry spans per send/work invocation so the queue shows up next to your HTTP traces. The Fail-open vs fail-closed Hono middleware tutorial covers the same pattern for middleware fan-out.
  • Rate-limiting the publisher: pair this queue with a sliding-window limiter so a single user can't flood the receipt-email queue. See the Upstash sliding-window rate-limit tutorial for the limiter; replace the in-memory store with a Postgres counter table to stay in one database.
  • Partitioning the job table: pg-boss 12 already partitions pgboss.job, with a dedicated partition: true opt-in per high-volume queue (the partition table name is j<sha224hash>). For organic time-based cleanup of any large companion tables you run alongside it, the pg_partman + pg_cron Postgres 18 tutorial shows how to partition by created_at so cleanup is a DROP PARTITION instead of a DELETE.

Footnotes

  1. Node.js release schedule, nodejs.org/en/about/previous-releases. Node 24 entered Active LTS on 2025-10-28 (codename "Krypton") and is supported through April 2028; Node 22 transitioned to Maintenance LTS around the same window.

  2. pg-boss 12 README, "ORM Transaction Adapters" section, github.com/timgit/pg-boss. The README documents the fromKnex, fromKysely, fromDrizzle, and fromPrisma exports and their usage inside ORM transactions. Note: per npm view pg-boss time --json, the adapters first shipped in pg-boss 12.17.0 (2026-04-24); they are NOT in 12.0.0–12.16.0. 2

  3. npm docs, "install — save-exact flag", docs.npmjs.com/cli/v10/commands/npm-install. The default is a caret range; --save-exact writes the exact version. Set save-exact=true in .npmrc to make it global.

  4. Node.js docs, "Type stripping", nodejs.org/api/typescript.html#type-stripping. Native .ts execution arrived as --experimental-strip-types in Node 22.6.0 and was promoted to enabled-by-default in Node 22.18.0 (and Node 24).

  5. Postgres official Docker image, hub.docker.com/_/postgres. The postgres:18-alpine tag currently aliases 18-alpine3.23 (Alpine 3.23 base); a pinned 18.4-alpine3.23 variant is also available if you need to lock the Postgres patch level.

  6. Drizzle ORM docs, "Type API", orm.drizzle.team/docs/goodies#type-api. $inferSelect and $inferInsert are exported from every pgTable instance.

  7. pg-boss package.json declares "type": "module" and the published dist/index.d.ts exports PgBoss as a named class. Verified against the pg-boss@12.18.2 npm tarball.

  8. pg-boss source, dist/adapters/drizzle.ts. The adapter's executeSql(text, values) rewrites $1,$2 placeholders to Drizzle's tagged-template form and calls tx.execute(sql(strings, ...reordered)). The JSDoc states "without a runtime dependency on drizzle-orm" — the sql import is supplied by the caller.

  9. pg-boss API docs, timgit.github.io/pg-boss/, and the QueueOptions JSDoc in node_modules/pg-boss/dist/types.d.ts. The retryLimit default is 2 and retryDelay is 0; the backoff formula is documented under retryBackoff.

  10. pg-boss type definitions, node_modules/pg-boss/dist/types.d.ts. The QueueOptions interface exports expireInSeconds, retentionSeconds, deleteAfterSeconds, retryLimit, retryDelay, retryBackoff, retryDelayMax, and deadLetter. No retentionDays. 2

  11. pg-boss timekeeper.js source destructures schedule() options as { tz = 'UTC', key = '', ...rest } — the default timezone is UTC, verified against the pg-boss@12.18.2 tarball. Cron expressions are parsed by cron-parser 5.x (5-field standard cron, plus a 6-field seconds variant).


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.