What is ngn?
ngn is a cron scheduler for JavaScript and TypeScript task
files. Write tasks as plain files, run one command, and every run — status, result, logs, timings,
key-value state — is recorded into one SQLite file. Each task also gets its own SQLite database, and
tasks can hand work to each other through job queues and keep out of each other's way with named locks.
Everything lives in SQLite files on your disk; there is no server or broker to run beside it.
Installation
One package. Install it globally, or run it without installing:
bash
npm install -g @apisurf/ngn
ngn <command>
# or, without installing
npx @apisurf/ngn <command>
Add it to the project too, for the TaskContext and
defineConfig types:
Commands
bash
ngn init # ngn.config.ts + tasks/
ngn add scrape.ts # tasks/scrape.ts, from a template
ngn run # schedule every task (Ctrl-C to stop)
| Command | Does |
ngn init | Create ngn.config.ts and tasks/. Leaves files that already exist alone |
ngn add <filePath> | Create tasks/<filePath> from a template. Subfolders are created as needed |
ngn run | Schedule every task the config match finds. Blocks until Ctrl-C |
ngn run --match <glob> | Schedule a subset of those. Tested case-insensitively against the path relative to root, so include the directory: "tasks/scrape.ts" or "**/scrape.ts", not "scrape.ts" |
ngn run:once <pattern> | Run the first configured task matching the pattern now, then exit. Non-zero exit if it throws |
ngn run:single <file> -t <cron> | Schedule one file on your own pattern, without the config. Blocks until Ctrl-C |
ngn sql <query> | Query the run database and print rows |
ngn --version | Print the installed version |
Every command takes --root to point at a project other than the working
directory, and --help for its details. Quote your globs so the shell
does not expand them.
run:once only picks from tasks the config
match already covers. It retries as the task's
retry allows, but the circuit breaker never holds it back.
run:single ignores ngn.config.ts
entirely: no .env is loaded and the run database is
:memory:, so nothing is recorded and its locks are not shared with other
processes. To schedule one configured task, use ngn run --match.
ngn init
Flags write non-default values into the new config:
| Flag | Writes |
--dbFile <path> | dbPath to write. ./ngn.sqlite becomes file:./ngn.sqlite |
--apiPort <port> | port to write |
--match <glob> | match to write |
--envFile <path> | envFile to write |
--configFile <path> | Name of the config file to create |
Configuration
ngn.config.ts (or ngn.config.js),
written by ngn init and typed by
defineConfig. Every option is optional:
ngn.config.ts
import { defineConfig } from "@apisurf/ngn";
export default defineConfig({
dbPath: "file:./ngn.sqlite",
port: 4545,
match: ["tasks/**/*.ts"],
envFile: ".env",
});
| Option | Default | What it is |
dbPath | :memory: | ngn's own database: runs, logs, timings, kv and locks. :memory: or a file: URL. The default keeps nothing once the process exits |
port | 4545 | Port of the loopback endpoint ngn run opens for ngnui's live editor |
match | ["tasks/**/*.ts"] | Globs for task discovery, relative to root. Skips node_modules, .git, and any file or folder starting with _ |
envFile | .env | Loaded into ctx.env, relative to root |
A bad value stops the command with the option's name. Set dbPath to a
file: URL to keep your run history, and to share locks between
ngn run and a run:once in a second
terminal.
Writing a task
A task file exports timing and task.
Everything else is optional.
tasks/scrape.ts
import { TaskContext } from "@apisurf/ngn";
// Every 5 seconds
export const timing = "*/5 * * * * *";
export const task = async (ctx: TaskContext) => {
await ctx.log.info("fetching");
const html = await fetch("https://example.com", { signal: ctx.run.signal }).then((res) =>
res.text(),
);
await ctx.kv.set("last-length", String(html.length));
// Recorded with the run, as JSON in task_runs.result
return { length: html.length };
};
Your file and its relative imports are bundled. Package imports are left alone and resolved from your own
node_modules at run time, so the version you installed is the version
that runs. Anything else a task needs, it imports itself.
What a task can export
| Export | Is |
timing | Cron pattern. Required for ngn run |
task | async (ctx) => result. Required. May also be the default export |
overlap | "skip" (default) or "allow": what a tick does while the last run is still going |
budget | Duration. The longest a run should take; brings ctx.run.deadline closer |
retry | { attempts, delay, breaker }. Retry a failed run inside its tick |
exclusive | Lock name, or a list of them, held for the whole run |
shouldSkip, shouldRetry, onSuccess, onError, onComplete | Hooks, below |
A duration is milliseconds or a string: "500ms",
"30s", "4m",
"2h", "1d". A bad
overlap, budget,
retry or exclusive fails when the task
is scheduled, naming the task, not on its first tick.
The context
task and every hook get the same ctx:
| On ctx | What it does |
ctx.log | info(text), warning(text), error(text). Printed, and stored with the run |
ctx.timing | start(label) returns a function; call it to record the milliseconds since |
ctx.kv | get(key), set(key, value), delete(key). String values per task that outlive a run |
ctx.sqlite | The task's own database. See below |
ctx.run | This run's id, scheduledAt, nextAt, deadline and abort signal |
ctx.queue(name, options?) | Open a job queue |
ctx.lock(name, fn, options?) | Run fn holding a named lock shared with other tasks |
ctx.env | Variables from envFile, or null when there is no such file |
ctx.meta | fileTaskId, fileTaskVersionId, file, tasksRootDir |
Hooks
Five optional exports run around task:
typescript
// Before task. Returning true records the run as skipped; no other hook fires
export const shouldSkip = async (ctx: TaskContext) => {
return (await ctx.kv.get("paused")) === "1";
};
export const onSuccess = async (ctx: TaskContext) => {
await ctx.log.info("scrape ok");
};
// The run is already recorded as a failure; the error is rethrown after this
export const onError = async (error: Error, ctx: TaskContext) => {
await ctx.log.error(error.message);
};
// After success or failure. Not called on a skipped run
export const onComplete = async (ctx: TaskContext) => {
await ctx.kv.set("last-run", new Date().toISOString());
};
// Before each retry that retry allows. Returning false gives up until the next tick
export const shouldRetry = async (error: Error, ctx: TaskContext) => {
return !error.message.includes("401");
};
Scheduling
Cron patterns
Six fields, seconds first, or the usual five, minutes first. With five fields, tasks run at second 0.
cron
┌────────────── second (0-59, optional)
│ ┌──────────── minute (0-59)
│ │ ┌────────── hour (0-23)
│ │ │ ┌──────── day of month (1-31)
│ │ │ │ ┌────── month (1-12 or jan-dec)
│ │ │ │ │ ┌──── day of week (0-7 or sun-sat, 0 and 7 are Sunday)
│ │ │ │ │ │
* * * * * *
| Pattern | Runs |
*/5 * * * * * | Every 5 seconds |
* * * * * | Every minute |
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour |
0 9 * * * | Daily at 9:00 |
0 9 * * 1-5 | Weekdays at 9:00 |
When a run is still going
A tick can arrive before the previous run has finished. By default ngn does not start a second run beside
it: the tick is recorded as a skipped run, with
skip_reason = 'previous run still in progress', and none of the task's
code runs, shouldSkip included.
tasks/report.ts
export const timing = "*/5 * * * *";
export const overlap = "allow"; // start another run anyway
How long a run has
ctx.run says when this run's slot ends, so a task working through a
backlog can stop taking on more before the next tick and leave the rest for it:
tasks/backlog.ts
export const timing = "*/5 * * * *";
export const budget = "2m"; // optional: stop sooner than the next tick
export const task = async (ctx: TaskContext) => {
for (const item of await loadBacklog()) {
if (ctx.run.signal.aborted) break; // the slot is nearly over
await process(item);
}
};
| ctx.run | Is |
id | This run's task_runs.id |
scheduledAt | When the tick fired. null for a run with no schedule |
nextAt | When the next tick is due. null for a run with no schedule |
deadline | When to stop starting new work, or null when there is none |
signal | An AbortSignal that fires at deadline. Pass it to fetch and friends |
The deadline is a tenth of the period before the next tick, and never more than five seconds before it.
budget brings it closer. run:once has
no next tick, so it gets a deadline only from a budget. The signal is a request: ngn does not stop a run
that ignores it. Times are epoch milliseconds.
When a run fails
retry retries a failed run inside its tick, but never into the next one:
a retry that would not start before the next tick is left to that tick. Each attempt is its own run, with
retry_of pointing at the tick's first. A task that fails to load is not
retried, and shouldRetry gets the last word on the rest.
tasks/sync.ts
export const timing = "*/10 * * * *";
export const retry = {
attempts: 3, // tries per tick, the first included
delay: "30s", // before each retry
breaker: { after: 5, maxSkip: 16 },
};
breaker stops a task that keeps failing from failing on every tick. After
after failed ticks in a row, ngn skips the next tick, then lets one
through; if that fails too it skips two, then four, up to maxSkip (32 by
default). One tick that succeeds closes it. Skipped ticks are recorded with
skip_reason = 'circuit open after repeated failures'. The streak is
read from the run history, so restarting ngn run does not reset it.
Storing data with ctx.sqlite
Every task gets its own SQLite database, with nothing to configure. It lives next to the task file —
tasks/scrape.ts writes to
tasks/scrape.db — and the file is only created once the task
actually uses it. This is your data, separate from ngn's own run history in
dbPath.
tasks/scrape.ts
export const task = async (ctx: TaskContext) => {
await ctx.sqlite.execute("CREATE TABLE IF NOT EXISTS pages (url TEXT, seen TEXT)");
await ctx.sqlite.execute("INSERT INTO pages (url, seen) VALUES (?, ?)", [
"https://example.com",
new Date().toISOString(),
]);
const { rows } = await ctx.sqlite.execute("SELECT COUNT(*) AS n FROM pages");
await ctx.log.info(`${rows[0].n} pages`);
};
| Member | Does |
execute(sql, args?) | One statement against the task's own database |
batch(statements) | Several statements in one transaction |
client | The raw libsql client, for anything the above does not cover |
path | Absolute path of the task's own database file |
initDB({ file, migrations? }) | Open another database in the task's folder. Returns execute, batch, client, path and close() |
destroyDB(file) | Close and delete a database file in the task's folder |
initDB takes a path relative to the task's folder and runs each
migration once, tracked in a _ngn_migrations table inside that
database:
typescript
const cache = await ctx.sqlite.initDB({
file: "cache.db",
migrations: [
{ id: "2026-01-13-001", up: "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)" },
{ id: "2026-01-14-001", up: "ALTER TABLE items ADD COLUMN qty INTEGER" },
],
});
await cache.execute("INSERT INTO items (name, qty) VALUES (?, ?)", ["widget", 3]);
Two tasks in one folder that open the same file share it, which is how they share a queue. A task can only
reach databases inside its own folder or its subfolders — absolute paths and paths that climb out with
.. are rejected. Connections are held open between runs and closed on
shutdown.
Queues
A task that finds work and a task that does it can share a queue. It is a table,
_ngn_jobs, in whichever database you open it on, and ngn handles the
parts that are easy to get wrong:
- two runs, or two processes, never take the same job;
-
a failed job backs off until the next tick instead of being retried at once, and is
dead after maxAttempts;
- a job held by a run that died becomes available again once its lease runs out;
- the worker stops taking jobs before the next tick, and leaves the rest for it.
tasks/collect.ts
// Every 2 minutes, find pages
export const timing = "*/2 * * * *";
export const task = async (ctx: TaskContext) => {
const db = await ctx.sqlite.initDB({ file: "pages.db" });
const pages = await ctx.queue<{ url: string }>("pages", { db });
const links = await findLinks();
// A key already in the queue, in any status, is not added again
const added = await pages.add(
links.map((url) => ({ key: url, group: new URL(url).host, payload: { url } })),
);
return { found: links.length, added };
};
tasks/read.ts
// Every 5 minutes, read as many as fit
export const timing = "*/5 * * * *";
export const task = async (ctx: TaskContext) => {
const db = await ctx.sqlite.initDB({ file: "pages.db" });
const pages = await ctx.queue<{ url: string }>("pages", { db, maxAttempts: 3 });
// Return to complete a job, throw to fail the attempt
return pages.work(async (job) => read(job.payload.url), { concurrency: 4, perGroup: 10 });
};
Both tasks open pages.db from the same folder, so they share the queue.
Without db, a queue lives in the task's own database, where only that
task sees it.
| Option | Default | What it is |
db | ctx.sqlite | The database the queue lives in: ctx.sqlite or any initDB database |
maxAttempts | 3 | Tries before a job is dead |
backoff | "next-tick" | When a failed job may run again: "next-tick", a duration, or { base, max } to double each time |
lease | "10m" | How long a claim lasts before the job counts as abandoned. Make it outlast a run |
Working a queue
work() claims a few jobs at a time and stops taking new ones at
ctx.run.deadline, finishing the ones in hand. It never works two jobs of
one group (one site, one account) at once, and spreads each call across groups instead of draining one. It
returns a summary; return that from task and every run records how the
queue is doing:
typescript
{ done, failed, dead, released, remaining, oldestAgeMs, stoppedGroups, stoppedBy }
// stoppedBy: "empty" | "deadline" | "max"
| Option | Does |
concurrency | Jobs worked at once. Default 1 |
max | Most jobs this call takes |
perGroup | Most jobs one group gives this call: a number, or (group) => number. 0 leaves a group alone |
until | When to stop taking jobs: an AbortSignal or epoch ms. Default ctx.run.signal |
worker.setup(index) / worker.teardown(resource, index) | A resource per worker, such as a browser tab, made on its first job, so an empty queue costs nothing |
The handler gets the job and a handle:
| Handle | Is |
index | Which worker this is, from 0 |
resource | What worker.setup returned |
signal | The until signal |
release({ delay?, refund? }) | Hand this job back unworked. By default its attempt is not spent |
stopGroup({ delay?, refund? }) | The same for every job of this group, for the rest of the call. For a site that answered with a bot wall |
A browser-backed worker, with a bot wall that stops the whole site:
typescript
// One browser for every worker, launched by whichever worker needs it first
let browser: Promise<Browser> | null = null;
const summary = await pages.work(
async (job, { resource: page, stopGroup }) => {
const result = await extract(page, job.payload.url);
if (result.blocked) return stopGroup(); // every page on this site would say the same
await save(result);
},
{
concurrency: 4,
worker: {
setup: async () => (await (browser ??= launch())).newPage(),
teardown: (page) => page.close(),
},
},
);
await (await browser)?.close();
The queue itself
| Method | Does |
add(job | jobs) | { key?, group?, payload?, priority?, delay?, maxAttempts? }. Returns how many were new |
addStatement(job) | The same INSERT as a statement, for your own batch |
work(handler, options?) | Work through due jobs, above |
claim({ limit, perGroup?, skipGroups? }) | Take due jobs yourself, when work() does not fit |
complete(job) / fail(job, error, { delay? }) / release(job, { delay?, refund? }) | Settle a claimed job. false if its lease ran out and it moved on |
park(filter, reason?) | Set ready jobs aside. Parked jobs are never claimed |
reopen(filter?, { from? }) | Make jobs ready again with attempts reset: parked by default, or from: ["dead"] |
prune({ olderThan, status? }) | Delete old done jobs (or status), freeing their keys |
get(key) | One job, with its status |
stats() | { ready, due, leased, done, dead, parked, oldestReadyAgeMs } |
typescript
// A job goes in the same transaction as the row it is about
await db.batch([
{ sql: "INSERT INTO listings (url) VALUES (?)", args: [url] },
pages.addStatement({ key: url, group: host, payload: { url } }),
]);
// Filters take { group, groupNotIn, key }
await pages.park({ groupNotIn: SUPPORTED_HOSTS }, "no extractor");
await pages.reopen({ group: SUPPORTED_HOSTS }); // a new extractor just landed
await pages.reopen({}, { from: ["dead"] }); // retry what gave up
await pages.prune({ olderThan: "7d" });
A job's status is one of ready,
leased, done,
dead or parked. A job's
attempts counts the one in hand, and
lastError holds what the previous one failed with.
Locks
Two tasks that must not work on the same thing at once can share a lock, by name. Locks live in ngn's
database, so with a file: dbPath they
also hold against ngn run:once in a second terminal.
typescript
// Hold a lock around part of a run. Waits for it, until the run's deadline
export const task = async (ctx: TaskContext) => {
await ctx.lock("site:example.com", async () => {
await scrape("https://example.com");
});
};
typescript
// Or hold locks for the whole run. A run that cannot have them all is
// recorded as skipped, and the next tick tries again
export const exclusive = ["browser"];
A run skipped by exclusive is recorded with
skip_reason = 'lock "browser" is held'.
ctx.lock takes options as its third argument:
| Option | Default | What it is |
wait | true | true waits until the run's deadline, false gives up at once, a duration waits that long |
ttl | "1m" | How long the lock outlives a process that died holding it. Renewed while held |
When the lock cannot be had in time, ctx.lock throws a
LockBusyError. Check
error.name === "LockBusyError"; it works even where
instanceof would not.
Reading what your tasks did
Both readers open the file dbPath points at, so it must be a
file: URL. Neither needs the scheduler running.
From the terminal
ngn sql passes any query straight to SQLite and prints a table. Rows go to
stdout and counts to stderr, so piping into jq works. It never migrates
the file, so a query cannot change its schema.
bash
ngn sql "SELECT status, COUNT(*) FROM task_runs GROUP BY status"
ngn sql "SELECT * FROM logs WHERE status = 'error' ORDER BY id DESC" --json
ngn sql "SELECT id, skip_reason FROM task_runs WHERE status = 'skipped' ORDER BY id DESC"
ngn sql --db tasks/pages.db "SELECT status, COUNT(*) FROM _ngn_jobs GROUP BY 1"
| Flag | Does |
--json | A JSON array of objects. Best for parsing |
--csv | CSV |
--db <path> | Query any SQLite file, a task's own database included, without a config |
--max-width <n> | Where table cells are cut. Default 60, 0 disables |
--root <path> | Where to read ngn.config.ts from |
ngn sql --help lists every column. Times are unix milliseconds. The
tables:
| Table | Holds |
task_runs | Each run: status (pending, skipped, running, success, failure), started_at, ended_at, result (what task returned, JSON), retry_of, skip_reason |
file_tasks | Each task file: path, parent_path, status |
file_task_versions | Each compiled version of a file: version, md5_hash, compiled_code |
logs | What ctx.log wrote, per run: status (info, warning, error), value |
timings | What ctx.timing measured: label, value |
kvs | What ctx.kv holds, per task: key, value |
locks | Locks held right now: name, holder, task_run_id, acquired_at, expires_at |
In a browser
@apisurf/ngnui is a separate CLI that serves a dashboard over the same
file: tasks, runs with their logs, timings and results, stored keys, code versions and a SQL console. Run it
from the project folder and it reads dbPath from the config. Install it
with npm i -g @apisurf/ngnui, or run it with
npx.
bash
ngnui # dbPath from ./ngn.config.ts, on http://127.0.0.1:3000
ngnui --live http://127.0.0.1:4545 # with the live editor
ngnui --db ~/work/ngn.sqlite --port 4000 # another file, another port
It opens the file read-only and binds to loopback. Its live editor runs code through a running
ngn run, which --live points at, on
the config's port; that endpoint listens on loopback only.
ngn run prints the exact line for your project when it starts. The
scheduler never requires ngnui.
Upgrading from 0.2.x
| Change | What to do |
| Overlapping runs are skipped | A task that relied on a second run starting while the first was still going needs export const overlap = "allow" |
shouldRetry is called | As (error, ctx), and only when retry allows a retry. It was declared before but never called |
| The run database is migrated | On the first ngn run or run:once: task_runs gains result, retry_of and skip_reason, and a locks table appears. ngn sql and ngnui read old and new files alike |
| Cron runs on croner | Instead of node-cron. The same five- and six-field patterns are accepted |