Back to home
Apisurf Logo
ngn logo

ngn

Schedules and runs Node tasks, with every run, log and timing kept in one SQLite file.

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:

bash
npm i @apisurf/ngn

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)
CommandDoes
ngn initCreate 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 runSchedule 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 ctxWhat it does
ctx.loginfo(msg), warning(msg), error(msg) — printed and recorded against the run
ctx.kvget(key), set(key, value), delete(key) — string values per task that outlive a run
ctx.timingstart(label) returns the function you call to record the elapsed milliseconds
ctx.sqliteThe task's own database. See below
ctx.envVariables from envFile, or null if there is none
ctx.metafileTaskId, 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",
});
OptionDefaultWhat it is
dbPath:memory:Where runs, logs and timings go: :memory: or a file: URL. The default keeps nothing once the process exits
port4545Port 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.envLoaded 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`);
};
MemberDoes
execute(sql, args?)One statement against the task's own database
batch(statements)Several statements in one round trip
clientThe raw libsql client, for anything the above does not cover
pathAbsolute 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:

TableHolds
task_runsEach run: status (pending, skipped, running, success, failure), started_at, ended_at
file_tasksEach task file: path, parent_path, status
file_task_versionsEach compiled version of a file: version, md5_hash, compiled_code
logsWhat ctx.log wrote, per run: status (info, warning, error), value
timingsWhat ctx.timing measured: label, value
kvsWhat 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)
│ │ │ │ │ │
* * * * * *
PatternRuns
*/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-5Weekdays at 9:00