NGN Logo

NGN

File-based task runner for Node.js with unmatched flexibility and DX.

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 dashboard.


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>

Or add it to a project, for the TaskContext and defineConfig types:

bash
npm i @apisurf/ngn
yarn add @apisurf/ngn
pnpm add @apisurf/ngn

Workflow examples

Examples

bash
# Scaffold ngn.config.ts and tasks/, then write a task file
ngn init
ngn add scrape.ts

# Schedule every matching task (blocks until Ctrl-C)
ngn run

# Run a subset — quote the glob
ngn run --match "tasks/reports/**/*.ts"

# Run one task now, then exit
ngn run:once tasks/scrape.ts

# Schedule one task on your own pattern
ngn run:single tasks/cleanup.ts -t "*/10 * * * * *"

# Read the run history back
ngn sql "SELECT * FROM task_runs ORDER BY id DESC LIMIT 20"

Command Description
ngn init Initialize ngn in current directory
ngn add <file> Create a new task file in tasks/
ngn run Start scheduler and dashboard
ngn run --match <pattern> Run only tasks matching glob pattern
ngn run:single <file> Run single task with optional custom timing
ngn run:once <file> Execute task once, then exit (no scheduling)
ngn sql <query> Query the run history — table, --json or --csv

Writing Tasks

A task file is as simple as this:

typescript
import { TaskContext } from "@apisurf/ngn";

// Cron pattern (6 fields: second minute hour day month weekday)
export const timing = "0 */5 * * * *"; // Every 5 minutes

// Task function
export const task = async (ctx: TaskContext) => {
  // Your code here
};

Task Context

Every task receives a context object with built-in utilities. Anything else a task needs, it imports itself — only your code is bundled, imports resolve from your own node_modules:

typescript
export const task = async (ctx: TaskContext) => {
  // Task metadata
  const { fileTaskId, fileTaskVersionId, file, tasksRootDir } = ctx.meta;

  // Environment variables from .env
  const apiKey = ctx.env!.API_KEY;

  // Key-value storage
  await ctx.kv.set("lastRun", new Date().toISOString());
  const lastRun = await ctx.kv.get("lastRun");
  await ctx.kv.delete("lastRun");

  // Structured logging (visible in dashboard)
  await ctx.log.info("Processing started");
  await ctx.log.warning("Rate limit approaching");
  await ctx.log.error("Failed to connect");

  // Performance timing
  const recordTime = ctx.timing.start("api-call");
  await fetch("https://api.example.com");
  await recordTime(); // Recorded with the run

  // The task's own SQLite database — see below
  await ctx.sqlite.execute("SELECT 1");
};

Lifecycle Hooks

Control task execution with optional exports:

typescript
// Runs before task. Returning true skips the run — no other hook fires
export const shouldSkip = async (ctx: TaskContext) => {
  const lastRun = await ctx.kv.get("lastRun");
  return lastRun === new Date().toDateString();
};

// Runs after a successful execution
export const onSuccess = async (ctx: TaskContext) => {
  await ctx.log.info("Done");
};

// Runs after a failed execution
export const onError = async (err: Error, ctx: TaskContext) => {
  await ctx.log.error(err.message);
};

// Runs after success or failure. Not called on a skipped run
export const onComplete = async (ctx: TaskContext) => {};

Configuration

Create an ngn.config.ts with defineConfig. Point dbPath at a file: URL to keep the run history — the :memory: default keeps nothing once the process exits:

ngn.config.ts
import { defineConfig } from "@apisurf/ngn";

export default defineConfig({
  dbPath: "file:./ngn.sqlite",
  port: 4545,
  match: ["tasks/**/*.ts"],
  envFile: ".env"
});
Option Example Description
dbPath :memory: or file:./ngn.sqlite Where ngn records runs, logs and timings
port 4545 Port of the loopback endpoint ngn run keeps for the live task editor
match ["tasks/**/*.ts"] Glob patterns for task file discovery
envFile .env Environment variables file to load into ctx.env

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 the database dbPath points at.

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 migrations once each
destroyDB(file) Delete a database file in the task's folder
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]);

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

Runs, logs and timings go into the one SQLite file dbPath points at. There are two ways to read it, and neither needs the scheduler running.

From the terminal

ngn sql takes any query SQLite accepts and prints a table, or --json / --csv. It opens the file without migrating it, so a query can never change the schema. ngn sql --help lists the tables and their columns.

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

In a browser

@apisurf/ngnui is a separate CLI that serves a prebuilt dashboard over the same file:

bash
npx @apisurf/ngnui --db ./ngn.sqlite

Its live task editor needs a runtime to execute against, which only a running ngn run has — pass --live http://127.0.0.1:4545 to connect the two.


Cron Patterns

ngn uses 6-field cron expressions (includes seconds):

┌────────────── second (0-59)
│ ┌──────────── minute (0-59)
│ │ ┌────────── hour (0-23)
│ │ │ ┌──────── day of month (1-31)
│ │ │ │ ┌────── month (1-12)
│ │ │ │ │ ┌──── day of week (0-6, Sun=0)
│ │ │ │ │ │
* * * * * *

Common patterns:

Pattern Description
*/5 * * * * * Every 5 seconds
0 * * * * * Every minute
0 */5 * * * * Every 5 minutes
0 0 * * * * Every hour
0 0 9 * * * Daily at 9:00 AM
0 0 9 * * 1-5 Weekdays at 9:00 AM