{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "jobs",
  "type": "registry:item",
  "title": "Background jobs",
  "description": "Background jobs and cron on MongoDB (no Redis): retries with backoff, schedules, event triggers, admin API and a devtools panel.",
  "version": "1.0.1",
  "author": "CalmAPI",
  "categories": [
    "jobs",
    "queue",
    "cron"
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "envVars": {
    "JOBS_WORKER": "on",
    "JOBS_CONCURRENCY": "4"
  },
  "docs": "Jobs live in app/jobs/*.js (see welcome-email.js):\n  export default defineJob({ name, run(data, { app, job, log }), retries, schedule: '0 * * * *' | 'every 5m', on: 'auth.registered' })\nEnqueue from anywhere: ctx.app.ext.jobs.enqueue('name', data, { delay: '10m' })\nAdmin API: GET /api/jobs, GET /api/jobs/stats, POST /api/jobs/:id/retry. Set JOBS_WORKER=off on instances that should not process jobs.",
  "meta": {
    "calmapi": {
      "minVersion": "2.0.0-alpha.0",
      "conflicts": []
    }
  },
  "files": [
    {
      "path": "registry/blocks/jobs/files/app/blocks/jobs/cron.js",
      "type": "registry:file",
      "target": "app/blocks/jobs/cron.js",
      "content": "// Tiny schedule parser (UTC).\n//   cron:      '*/15 * * * *', '0 9 * * 1-5', '@hourly', '@daily', '@weekly', '@monthly'\n//   interval:  'every 30s', 'every 5m', 'every 2h', 'every 1d'\n\nconst SHORTCUTS = { '@hourly': '0 * * * *', '@daily': '0 0 * * *', '@weekly': '0 0 * * 0', '@monthly': '0 0 1 * *' };\nconst RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]];\nconst UNITS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };\n\nexport const parseDuration = (text) => {\n  if (typeof text === 'number') return text;\n  const m = /^(\\d+)\\s*(s|m|h|d)$/.exec(String(text).trim());\n  if (!m) throw new Error(`Invalid duration \"${text}\" (use e.g. 30s, 5m, 2h, 1d)`);\n  return Number(m[1]) * UNITS[m[2]];\n};\n\nfunction parseField(field, [min, max]) {\n  const values = new Set();\n  for (const part of field.split(',')) {\n    const [range, stepText] = part.split('/');\n    const step = stepText ? Number(stepText) : 1;\n    const [lo, upper] = range === '*' ? [min, max] : range.split('-').map(Number);\n    const hi = upper ?? (stepText ? max : lo);\n    if (![lo, hi, step].every(Number.isInteger) || lo < min || hi > max || lo > hi || step < 1) throw new Error(`Invalid cron field \"${field}\"`);\n    for (let v = lo; v <= hi; v += step) values.add(v);\n  }\n  return values;\n}\n\nexport function parseSchedule(expr) {\n  const text = String(expr).trim();\n  if (text.startsWith('every ')) return { kind: 'interval', ms: parseDuration(text.slice(6)) };\n  const fields = (SHORTCUTS[text] ?? text).split(/\\s+/);\n  if (fields.length !== 5) throw new Error(`Invalid cron \"${expr}\": expected 5 fields`);\n  const [minute, hour, dom, month, dow] = fields.map((f, i) => parseField(f.replace(/^7$/, '0'), RANGES[i]));\n  return { kind: 'cron', minute, hour, dom, month, dow, domAny: fields[2] === '*', dowAny: fields[4] === '*' };\n}\n\n/** Next run strictly after `from`. */\nexport function nextRun(schedule, from = new Date()) {\n  if (schedule.kind === 'interval') return new Date(from.getTime() + schedule.ms);\n  const d = new Date(from.getTime());\n  d.setUTCSeconds(0, 0);\n  d.setUTCMinutes(d.getUTCMinutes() + 1);\n  for (let i = 0; i < 366 * 24 * 60; i++) {\n    const dayOk = schedule.domAny && schedule.dowAny\n      ? true\n      : schedule.domAny\n        ? schedule.dow.has(d.getUTCDay())\n        : schedule.dowAny\n          ? schedule.dom.has(d.getUTCDate())\n          : schedule.dom.has(d.getUTCDate()) || schedule.dow.has(d.getUTCDay());\n    if (!schedule.month.has(d.getUTCMonth() + 1) || !dayOk) {\n      d.setUTCDate(d.getUTCDate() + 1);\n      d.setUTCHours(0, 0, 0, 0);\n      continue;\n    }\n    if (!schedule.hour.has(d.getUTCHours())) {\n      d.setUTCHours(d.getUTCHours() + 1, 0, 0, 0);\n      continue;\n    }\n    if (schedule.minute.has(d.getUTCMinutes())) return d;\n    d.setUTCMinutes(d.getUTCMinutes() + 1);\n  }\n  throw new Error('Schedule never matches');\n}\n"
    },
    {
      "path": "registry/blocks/jobs/files/app/blocks/jobs/index.js",
      "type": "registry:file",
      "target": "app/blocks/jobs/index.js",
      "content": "import { existsSync, readdirSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { definePlugin, notFound, badRequest } from '#calm';\nimport { createQueue } from './queue.js';\n\n/** Declare a job in app/jobs/<name>.js */\nexport const defineJob = (def) => ({ kind: 'calm.job', ...def });\n\nexport default definePlugin({\n  name: 'jobs',\n  env: {\n    JOBS_WORKER: 'Set to \"off\" on instances that should not run jobs',\n    JOBS_CONCURRENCY: 'Jobs run at the same time per instance (default 4)',\n  },\n  async setup(calm) {\n    const queue = createQueue(calm.app, { concurrency: Number(process.env.JOBS_CONCURRENCY || 4) });\n    calm.provide('jobs', queue);\n\n    const dir = join(calm.app.root, 'app', 'jobs');\n    if (existsSync(dir)) {\n      for (const file of readdirSync(dir).filter((f) => f.endsWith('.js')).sort()) {\n        const def = (await import(pathToFileURL(join(dir, file)).href)).default;\n        if (def?.kind !== 'calm.job') throw new Error(`app/jobs/${file} must \"export default defineJob({ ... })\"`);\n        queue.define(def);\n      }\n    }\n\n    const workerOn = process.env.JOBS_WORKER !== 'off' && calm.config.mode !== 'test';\n    if (workerOn) {\n      calm.onStart(() => queue.start());\n      calm.onShutdown(() => queue.stop());\n    }\n\n    calm.routes(\n      (r) => {\n        r.get('/', { policy: 'admin', summary: 'List jobs', tags: ['jobs'] }, async (ctx) => {\n          const filter = {};\n          if (ctx.query.status) filter.status = String(ctx.query.status);\n          if (ctx.query.name) filter.name = String(ctx.query.name);\n          const limit = Math.min(Number(ctx.query.limit) || 50, 200);\n          const jobs = await queue.Job.find(filter).sort({ updatedAt: -1 }).limit(limit).lean();\n          return jobs.map(view);\n        });\n        r.get('/stats', { policy: 'admin', summary: 'Job counts by name and status', tags: ['jobs'] }, () => queue.stats());\n        r.post('/:jobId/retry', { policy: 'admin', summary: 'Retry a failed job', tags: ['jobs'] }, async (ctx) => {\n          const job = await queue.retry(ctx.params.jobId).catch(() => null);\n          if (!job) throw notFound('No failed job with that id');\n          return view(job);\n        });\n        r.post('/enqueue', { policy: 'admin', summary: 'Queue a job by name', tags: ['jobs'] }, async (ctx) => {\n          const { name, data } = ctx.req.body ?? {};\n          if (typeof name !== 'string') throw badRequest('name is required');\n          return view(await queue.enqueue(name, data ?? {}));\n        });\n      },\n      { prefix: '/jobs' },\n    );\n\n    calm.devtools.panel('jobs', async () => {\n      const stats = await queue.stats();\n      const recent = await queue.Job.find().sort({ updatedAt: -1 }).limit(25).lean();\n      return { ...stats, recent: recent.map(view) };\n    });\n\n    calm.doctor(() =>\n      process.env.JOBS_WORKER === 'off'\n        ? [{ level: 'info', id: 'jobs-worker-off', title: 'Jobs worker is off on this instance', detail: 'JOBS_WORKER=off: make sure another process runs with JOBS_WORKER=on.' }]\n        : [],\n    );\n  },\n});\n\nconst view = (j) => ({\n  id: String(j._id),\n  name: j.name,\n  status: j.status,\n  attempts: j.attempts,\n  maxAttempts: j.maxAttempts,\n  runAt: j.runAt,\n  finishedAt: j.finishedAt ?? null,\n  lastError: j.lastError ? j.lastError.split('\\n')[0] : null,\n  scheduled: Boolean(j.uniqueKey?.startsWith('schedule:')),\n});\n"
    },
    {
      "path": "registry/blocks/jobs/files/app/blocks/jobs/queue.js",
      "type": "registry:file",
      "target": "app/blocks/jobs/queue.js",
      "content": "import { hostname } from 'node:os';\nimport { randomUUID } from 'node:crypto';\nimport { parseSchedule, nextRun, parseDuration } from './cron.js';\n\n// A job queue stored in MongoDB (collection calm_jobs). Workers claim jobs atomically\n// with findOneAndUpdate, so you can run as many instances as you like.\n\nexport function createQueue(app, { concurrency = 4, pollMs = 1000, lockTimeoutMs = 5 * 60_000, keepCompletedDays = 7 } = {}) {\n  const { Schema } = app.mongoose;\n  const schema = new Schema(\n    {\n      name: { type: String, required: true },\n      data: { type: Schema.Types.Mixed, default: {} },\n      status: { type: String, enum: ['queued', 'running', 'completed', 'failed'], default: 'queued' },\n      runAt: { type: Date, default: () => new Date() },\n      attempts: { type: Number, default: 0 },\n      maxAttempts: { type: Number, default: 3 },\n      lockedAt: Date,\n      lockedBy: String,\n      lastError: String,\n      finishedAt: Date,\n      uniqueKey: String,\n      result: Schema.Types.Mixed,\n    },\n    { collection: 'calm_jobs', timestamps: true, versionKey: false },\n  );\n  schema.index({ status: 1, runAt: 1 });\n  schema.index({ name: 1, status: 1 });\n  schema.index({ uniqueKey: 1 }, { unique: true, partialFilterExpression: { uniqueKey: { $type: 'string' } } });\n  schema.index({ finishedAt: 1 }, { expireAfterSeconds: keepCompletedDays * 86_400, partialFilterExpression: { status: 'completed' } });\n  const Job = app.mongoose.model('calmJob', schema);\n\n  const definitions = new Map();\n  const workerId = `${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`;\n  let running = 0;\n  let timer = null;\n  let stopping = false;\n  let ticks = 0;\n  const inflight = new Set();\n\n  const api = {\n    Job,\n    /** Register a job handler. */\n    define(def) {\n      if (!def?.name || typeof def.run !== 'function') throw new Error('defineJob({ name, run }) needs a name and a run function');\n      if (definitions.has(def.name)) throw new Error(`Job \"${def.name}\" is defined twice`);\n      const full = { retries: 3, backoff: '10s', ...def, scheduleParsed: def.schedule ? parseSchedule(def.schedule) : null };\n      definitions.set(def.name, full);\n      if (def.on) app.events.on(def.on, (payload) => api.enqueue(def.name, payload));\n      return full;\n    },\n\n    /** Queue a job. opts: { delay: '5m' | ms, runAt: Date, uniqueKey, retries } */\n    async enqueue(name, data = {}, opts = {}) {\n      const def = definitions.get(name);\n      if (!def) throw new Error(`Unknown job \"${name}\". Known: ${[...definitions.keys()].join(', ')}`);\n      const runAt = opts.runAt ?? new Date(Date.now() + (opts.delay ? parseDuration(opts.delay) : 0));\n      const doc = { name, data, runAt, maxAttempts: (opts.retries ?? def.retries) + 1, ...(opts.uniqueKey ? { uniqueKey: opts.uniqueKey } : {}) };\n      try {\n        return (await Job.create(doc)).toObject();\n      } catch (err) {\n        if (err.code === 11000 && opts.uniqueKey) return Job.findOne({ uniqueKey: opts.uniqueKey }).lean();\n        throw err;\n      }\n    },\n\n    async stats() {\n      const rows = await Job.aggregate([{ $group: { _id: { name: '$name', status: '$status' }, count: { $sum: 1 } } }]);\n      const byName = {};\n      for (const { _id, count } of rows) (byName[_id.name] ??= { queued: 0, running: 0, completed: 0, failed: 0 })[_id.status] = count;\n      return {\n        worker: timer ? workerId : 'off',\n        running,\n        definitions: [...definitions.values()].map((d) => ({ name: d.name, schedule: d.schedule ?? null, on: d.on ?? null, retries: d.retries })),\n        byName,\n      };\n    },\n\n    async retry(id) {\n      const res = await Job.findOneAndUpdate({ _id: id, status: 'failed' }, { $set: { status: 'queued', runAt: new Date(), attempts: 0 }, $unset: { lastError: 1, finishedAt: 1 } }, { returnDocument: 'after' }).lean();\n      return res;\n    },\n\n    /** Process everything that is due right now (tests and scripts). */\n    async drain() {\n      for (let i = 0; i < 1000; i++) {\n        const claimed = await claim();\n        if (!claimed) break;\n        await execute(claimed);\n      }\n      await Promise.all([...inflight]);\n    },\n\n    async start() {\n      for (const def of definitions.values()) if (def.scheduleParsed) await ensureScheduled(def);\n      stopping = false;\n      timer = setInterval(() => tick().catch((err) => app.log.error('Job worker tick failed', { err })), pollMs);\n      timer.unref();\n      app.log.info(`Jobs worker started (${definitions.size} jobs, concurrency ${concurrency})`);\n    },\n\n    async stop() {\n      stopping = true;\n      if (timer) clearInterval(timer);\n      timer = null;\n      await Promise.race([Promise.all([...inflight]), new Promise((r) => setTimeout(r, 10_000))]);\n    },\n  };\n\n  async function ensureScheduled(def) {\n    await Job.updateOne(\n      { uniqueKey: `schedule:${def.name}` },\n      { $setOnInsert: { name: def.name, data: {}, status: 'queued', runAt: nextRun(def.scheduleParsed), attempts: 0, maxAttempts: def.retries + 1 } },\n      { upsert: true },\n    );\n  }\n\n  const claim = () =>\n    Job.findOneAndUpdate(\n      { status: 'queued', runAt: { $lte: new Date() }, name: { $in: [...definitions.keys()] } },\n      { $set: { status: 'running', lockedAt: new Date(), lockedBy: workerId }, $inc: { attempts: 1 } },\n      { sort: { runAt: 1 }, returnDocument: 'after' },\n    ).lean();\n\n  async function tick() {\n    if (++ticks % 30 === 0) {\n      // Recover jobs whose worker died mid-run.\n      await Job.updateMany({ status: 'running', lockedAt: { $lt: new Date(Date.now() - lockTimeoutMs) } }, { $set: { status: 'queued' } });\n    }\n    while (!stopping && running < concurrency) {\n      const job = await claim();\n      if (!job) break;\n      running++;\n      const p = execute(job).finally(() => {\n        running--;\n        inflight.delete(p);\n      });\n      inflight.add(p);\n    }\n  }\n\n  async function execute(job) {\n    const def = definitions.get(job.name);\n    const log = app.log;\n    const started = Date.now();\n    try {\n      const result = await def.run(job.data, { app, job, log });\n      if (def.scheduleParsed) {\n        await Job.updateOne({ _id: job._id }, { $set: { status: 'queued', runAt: nextRun(def.scheduleParsed), attempts: 0, result: result ?? null }, $unset: { lockedAt: 1, lockedBy: 1, lastError: 1 } });\n      } else {\n        await Job.updateOne({ _id: job._id }, { $set: { status: 'completed', finishedAt: new Date(), result: result ?? null }, $unset: { lockedAt: 1, lockedBy: 1 } });\n      }\n      app.events.emit('job.completed', { id: String(job._id), name: job.name, ms: Date.now() - started });\n    } catch (err) {\n      const message = String(err?.stack ?? err).slice(0, 4000);\n      if (job.attempts < job.maxAttempts) {\n        const wait = parseDuration(def.backoff) * 2 ** (job.attempts - 1);\n        await Job.updateOne({ _id: job._id }, { $set: { status: 'queued', runAt: new Date(Date.now() + Math.min(wait, 3_600_000)), lastError: message }, $unset: { lockedAt: 1, lockedBy: 1 } });\n        log.warn(`Job ${job.name} failed (attempt ${job.attempts}/${job.maxAttempts}), retrying`, { err: err?.message });\n      } else if (def.scheduleParsed) {\n        await Job.updateOne({ _id: job._id }, { $set: { status: 'queued', runAt: nextRun(def.scheduleParsed), attempts: 0, lastError: message }, $unset: { lockedAt: 1, lockedBy: 1 } });\n        log.error(`Scheduled job ${job.name} failed, will run again at its next time`, { err: err?.message });\n      } else {\n        await Job.updateOne({ _id: job._id }, { $set: { status: 'failed', finishedAt: new Date(), lastError: message }, $unset: { lockedAt: 1, lockedBy: 1 } });\n        log.error(`Job ${job.name} failed permanently`, { err: err?.message });\n        app.events.emit('job.failed', { id: String(job._id), name: job.name, error: err?.message });\n      }\n    }\n  }\n\n  return api;\n}\n"
    },
    {
      "path": "registry/blocks/jobs/files/app/jobs/welcome-email.js",
      "type": "registry:file",
      "target": "app/jobs/welcome-email.js",
      "content": "import { defineJob } from '#app/blocks/jobs/index.js';\n\n// Example job: runs in the background whenever someone signs up.\n// Other triggers: schedule: '0 9 * * 1-5' (cron, UTC) or schedule: 'every 10m',\n// or enqueue manually: ctx.app.ext.jobs.enqueue('welcome-email', { user })\nexport default defineJob({\n  name: 'welcome-email',\n  on: 'auth.registered',\n  retries: 3,\n  async run({ user }, { app }) {\n    await app.mailer.send({\n      to: user.email,\n      subject: `Welcome to ${app.config.app.name}`,\n      text: `Hi ${user.name},\\n\\nYour account is ready. Happy building!`,\n    });\n  },\n});\n"
    }
  ]
}
