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, logs, timings, key-value
state — is recorded into one SQLite file. Each task also gets its own SQLite database, with nothing
to configure. Read the history back with ngn sql from the terminal, or
in a browser with ngnui.
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/. --dbFile, --apiPort, --match, --envFile and --configFile write non-default values |
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> | Narrow that set. Tested case-insensitively against the path relative to root, so include the directory: "tasks/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. Blocks until Ctrl-C |
ngn sql <query> | Query the run database and print rows |
Every command takes --root to point at a project other than the working
directory. Quote your globs so the shell does not expand them.
run:single ignores ngn.config.ts
entirely: no .env is loaded and the run database is
:memory:, so nothing is recorded. To schedule one
configured task, use ngn run --match.
Writing a task
A task file exports timing and task.
task may also be the default export.
tasks/scrape.ts
import { TaskContext } from "@apisurf/ngn";
export const timing = "*/5 * * * * *";
export const task = async (ctx: TaskContext) => {
await ctx.log.info("fetching");
const html = await fetch("https://example.com").then((res) => res.text());
await ctx.kv.set("last-length", String(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.
The context
| On ctx | What it does |
ctx.log | info(msg), warning(msg), error(msg) — printed and recorded against the run |
ctx.kv | get(key), set(key, value), delete(key) — string values per task that outlive a run |
ctx.timing | start(label) returns the function you call to record the elapsed milliseconds |
ctx.sqlite | The task's own database. See below |
ctx.env | Variables from envFile, or null if there is none |
ctx.meta | fileTaskId, fileTaskVersionId, file, tasksRootDir |
Hooks
Four more optional exports run around task, each with the same context:
typescript
// Runs 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) => {};
Configuration
ngn.config.ts (or ngn.config.js),
written by ngn init and typed by
defineConfig:
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: | Where runs, logs and timings go: :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 task editor |
match | ["tasks/**/*.ts"] | Globs for task discovery, relative to root. Skips node_modules and anything starting with _ |
envFile | .env | Loaded into ctx.env, relative to root |
Storing data with ctx.sqlite
Every task gets its own SQLite database. 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 the 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 round trip |
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, running each { id, up } migration once. Returns execute, batch, client, path and close() |
destroyDB(file) | Close and delete a database file in the task's folder |
A task can only reach databases inside its own folder — absolute paths and paths that climb out with
.. are rejected. Connections are held open between runs and closed on
shutdown.
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, or
--json / --csv. Rows go to stdout
and counts to stderr, so piping into jq works.
--db <file> queries a database without a config, and
--max-width <n> sets where table cells are cut (default 60, 0
disables).
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 --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 |
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 |
In a browser
@apisurf/ngnui is a separate CLI that serves a dashboard over the same
file.
bash
npx @apisurf/ngnui --db ./ngn.sqlite --live http://127.0.0.1:4545
Its live task 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. ngnui is a paid module; the scheduler itself is open source and never requires
it.
Cron patterns
Six fields, seconds 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 |
0 * * * * * | Every minute |
0 */5 * * * * | Every 5 minutes |
0 0 * * * * | Every hour |
0 0 9 * * * | Daily at 9:00 |
0 0 9 * * 1-5 | Weekdays at 9:00 |