npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sigil-dev/plugin-jobs

v0.9.15

Published

Durable background jobs for Grimoire: enqueue, retry, and a `sigil jobs` CLI

Readme

@sigil-dev/plugin-jobs

Durable background jobs for Grimoire: enqueue, retry, recurring schedules, and a sigil jobs CLI.

Opt-in. Grimoire core knows nothing about it: not installed means no queue, no table, no worker.

Setup

The queue lives in a database @sigil-dev/plugin-sql opened — "db" unless you say otherwise — so there is nothing to connect:

// src/lib/jobs.ts
import { jobs } from "@sigil-dev/plugin-jobs";

export const queue = jobs({
	inlineWorker: true, // run the worker in the server; see "Running it"
	handlers: {
		"send-welcome": async (p: { email: string }) => sendEmail(p.email),
		"rankings.snapshot": async () => snapshotRankings(),
	},
	schedules: {
		"rankings.snapshot": { every: "5m" },
	},
});
// sigil.config.ts
import { adapter } from "@sigil-dev/plugin-sql/bun";
import { queue } from "./src/lib/jobs.ts";

export default {
	plugins: [adapter(process.env.DATABASE_URL, { maxTotal: 12 }), queue],
};

database: "userDb" puts the queue in another plugin-sql database. The database is looked up when a query runs, so the order of the plugins array does not matter.

Enqueue

From anywhere on the server — import the queue:

import { queue } from "$lib/jobs";

await queue.jobs.enqueue("send-welcome", { email: "[email protected]" });
await queue.jobs.enqueue("send-welcome", { mail: "x" }); // type error: wrong payload
await queue.jobs.enqueue("send-welcom", {});             // type error: no such handler
await queue.jobs.enqueue("rankings.snapshot");           // no payload needed

In a route it is also locals.jobs. With plugin-sql's perRequestTx, a job enqueued during a request is part of the request's transaction: an order that rolls back takes its confirmation email with it.

Options: { runAt: Date, maxAttempts: number } (default 5).

Handlers

async (payload, { id, name, attempt }) => { ... }

id is stable across retries, so it can key idempotency. attempt is 1 on the first run. A handler that throws is retried after a backoff (30s by default); after maxAttempts it stays in the table as failed, for sigil jobs retry.

Schedules

schedules: { "rankings.snapshot": { every: "5m", payload: { region: "jp" } } }

every is milliseconds or "30s", "5m", "1h", "1d". Each schedule is one row that is rescheduled when it completes, so:

  • It runs once however many workers there are. Every worker makes sure the row exists at boot; the primary key admits one, and one worker claims it at a time.
  • A restart keeps its clock, and a deploy does not fire every schedule at once: the first run is one interval after the row is created.
  • A schedule that keeps failing moves on to its next run once its retries are used up, with the error kept in last_error.
  • A schedule removed from the config is removed from the table.

Running it

inlineWorker: true runs the worker inside the server. That is right for one process; with sigil start --scale full=3 every worker also works jobs, which is safe (claims are race-free) but may be more than you want.

The alternative is a separate process:

sigil jobs work      # run the worker until stopped (drains on SIGTERM)
sigil jobs list      # most recent jobs  [--limit 20]
sigil jobs stats     # counts by state
sigil jobs retry     # requeue every exhausted job, or one: retry <id>
sigil jobs migrate   # create the table (also done at boot)

The CLI opens the database from sigil.config.ts itself, so it works without a running server.

Other stores

Instead of database, pass sql — any object with query(sql, params) filling ? placeholders — for a queue that owns its connection:

import { bunSqlStore } from "@sigil-dev/plugin-jobs";
jobs({ sql: bunSqlStore(process.env.JOBS_URL!), handlers });

A store can declare what its dialect can do; everything defaults to false, so a store that declares nothing takes the path that is correct everywhere.

| capability | true on | what it changes | | --- | --- | --- | | skipLocked | postgres, MySQL | appends FOR UPDATE SKIP LOCKED, so a worker does not block behind rows another holds. Throughput only. | | spaceSeparatedTimestamp | MySQL | writes 2026-01-01 00:00:00 rather than ISO 8601, which MySQL rejects on a TIMESTAMP column. |

Drizzle

import { drizzleStore, bunSqlRunner } from "@sigil-dev/plugin-jobs";
sql: drizzleStore(bunSqlRunner(client), "mysql"),

This takes the Bun.SQL connection, not your Drizzle db: Drizzle's execute ignores a parameter array, and its bun-sql driver emits $1 on every dialect.

Guarantees, precisely

Delivery is at-least-once. A worker can die between your side effect and the row delete, so a job may run twice. Handlers must be idempotent. The alternative — holding a transaction open across every handler, including every HTTP call inside one — is a worse trade than a duplicate email.

Order is not guaranteed. A job that fails and retries runs after jobs enqueued later. Two jobs that write the same thing should carry what they need to be applied in any order.

Claiming is race-free without SKIP LOCKED. The claim is an UPDATE guarded on the row being unclaimed or stale, tagged with a random token, followed by a SELECT of that token — so it works on MariaDB, which has no UPDATE ... RETURNING. Two workers racing means the loser gets an empty set, not a duplicate job.

A stale claim is reclaimed after 60s by default, so a hard-killed worker does not strand its jobs.

Dialects

Tested against real SQLite, postgres and MariaDB.

License

MIT