Skip to content

Getting started ​

Install the package:

sh
bun add effect-mq        # or npm install / pnpm add / yarn add

effect-mq targets Effect v4 (effect@4.0.0-rc). The Postgres and Redis stores have optional peer dependencies; you'll add those in the storage step.

1. Define a job ​

A job is a class: a tag, a payload schema, and defaults. Producers and runners share this one definition.

ts
import { Job } from "effect-mq"
import { Schema } from "effect"

class SendEmail extends Job.make("SendEmail", {
  payload: { to: Schema.String, subject: Schema.String },
  success: Schema.String,                            // typed result
  idempotencyKey: ({ to, subject }) => `${to}:${subject}`,
  metadata: ({ to }) => ({ to }),                    // queryable context
  queue: "email",
  defaults: {
    attempts: 3,
    backoff: { type: "exponential", delay: "1 second" }
  }
}) {}

Payloads are real schemas. Redacted, DateTimeUtc, branded types, and unions round-trip through storage and come back typed in the handler.

2. Enqueue from anywhere ​

Producers need the job's store in context, never the worker. Your API server can enqueue without depending on any handler code.

ts
import { Effect } from "effect"

const program = Effect.gen(function*() {
  // fire and forget: returns the JobId
  const jobId = yield* SendEmail.enqueue({ to: "ada@example.com", subject: "hi" })

  // ...or enqueue and await the typed result
  const messageId = yield* SendEmail.execute(
    { to: "grace@example.com", subject: "now" },
    { delay: "5 seconds", priority: 2 }
  )
})

Thanks to idempotencyKey, enqueueing the same email twice is a no-op that returns the existing id.

3. Run a worker ​

Handlers are Effects; workers are Layers. Compose them wherever you deploy: same process or a dedicated machine.

ts
import { Worker, MemoryJobStore } from "effect-mq"
import { Effect, Layer } from "effect"

const RunnerLive = SendEmail.toLayer(
  (payload) => Effect.map(Worker.CurrentJob, ({ jobId }) => `message-${jobId}`),
  { concurrency: 5 }
).pipe(
  Layer.provideMerge(Worker.layer()),
  Layer.provideMerge(MemoryJobStore.layer)   // swap for Postgres/Redis below
)

Provide RunnerLive to your app and the worker claims, runs, retries, and records jobs. Interrupting the layer's scope triggers a graceful shutdown: the worker releases in-flight jobs back to waiting without consuming an attempt.

4. Pick real storage ​

MemoryJobStore is for tests and demos. For production, swap the layer; nothing else changes:

ts
import { DrizzleJobStore, mqJobs, mqJobAttempts, mqSchedules, mqQueueControl, mqDedupe } from "effect-mq/drizzle-postgres"

// The tables live in YOUR drizzle schema; drizzle-kit owns migrations.
export const jobs = mqJobs()
export const jobAttempts = mqJobAttempts(jobs)
export const schedules = mqSchedules()
export const queues = mqQueueControl()
export const dedupe = mqDedupe()

const StoreLive = DrizzleJobStore.layer({ jobs, attempts: jobAttempts, schedules, queues, dedupe })
ts
import { RedisJobStore } from "effect-mq/redis"
import { NodeRedis } from "@effect/platform-node"

const StoreLive = RedisJobStore.layer().pipe(
  Layer.provide(NodeRedis.layer({ url: "redis://localhost:6379" }))
)

See Postgres for the migration workflow and Redis for client setup.

5. Check on your jobs ​

ts
const status = yield* SendEmail.poll(jobId)        // Option<JobStatus>
const runs = yield* SendEmail.attempts(jobId)      // the decoded run ledger

Every attempt (success, retry, failure, stall) is persisted and decodes back to a typed exit.

Where to next ​