# ezx — Documentation (v0.8.1)

ezx documentation: the require("ezx") host API, dependency graph with per-edge wait modes and oneshot services, per-service logging with rotation, probes, env-driven config and args, scheduling, health, runtime API — plus recipes and every edge case.

## Contents

1. [Getting started](#getting-started)
2. [The dependency graph](#dependency-graph)
3. [Oneshot services & init DAG → exec main](#oneshot)
4. [Per-service logging with rotation](#logging)
5. [Probes & readiness](#probes)
6. [Env → config files (declarative provisioning)](#files)
7. [Env → CLI arguments](#args)
8. [Config & YAML builders](#builders)
9. [Imperative modules](#script-api)
10. [Cron scheduler with gates](#scheduler)
11. [Health endpoints & runtime API](#health-api)
12. [Launch modes](#modes)
13. [Use cases & recipes](#recipes)
14. [Edge cases & gotchas](#edge-cases)
15. [Status](#status)

<a id="getting-started"></a>

## 1. Getting started

ezx ships as a single static Go binary. Prebuilt binaries are on the GitHub releases page, or build from source:

```sh
# Linux x86_64 — download v0.8.1 from GitHub releases
curl -LO https://github.com/supanadit/ezx/releases/download/0.8.1/ezx-0.8.1-linux-amd64
mv ezx-0.8.1-linux-amd64 ezx
chmod +x ezx

# or build from the repo root
go build -o ezx ./app
```

### Your first entrypoint

A script is plain JavaScript run by ezx's Go-hosted JS engine (goja). It reads env, provisions files, and declares a dependency graph:

```js
// entrypoint.js
const { env, chain, log } = require("ezx");

chain.run({
  nodes: [
    {
      name: "app",
      process: {
        binaryPath: "/bin/sh",
        arguments: ["-c", "echo 'GREETING='$GREETING"],
        environment: ["GREETING=Hello from ezx"],
        filterEnv: ["PHP_MEMORY_LIMIT"],
      },
    },
    {
      name: "sidecar",
      dependsOn: ["app"],
      needParentReady: true,
      process: { binaryPath: "/bin/sh", arguments: ["-c", "echo sidecar ready"] },
    },
  ],
});

log.info("entrypoint complete");
```

```sh
./ezx bootstrap entrypoint.js
```

That is the whole model: **one binary, one declarative script, zero shell.** The rest of this page is a reference for everything the script can declare.

<a id="dependency-graph"></a>

## 2. The dependency graph

`chain.run({ nodes: [...] })` hands a flat list of `ProcessNode`s to the orchestrator, connected by explicit `dependsOn` edges, which spawns and supervises the whole graph. Each node:

| Field | Type | What it does |
| --- | --- | --- |
| name | string | Unique identifier. Used by scheduler.trigger / scheduler.status and in logs. |
| process | Process | The executable: binaryPath, arguments, env, filters, user/group, workingDir, log. |
| files | FileProvision[] | Declarative file provisioning, applied before this process starts. |
| dependsOn | string[] | Dependencies this node waits for before starting (the flat `nodes`/`dependsOn` form). |
| dependsOnEdges | Edge[] | Per-edge wait modes: `{ name, waitFor: "started"|"ready"|"exit" }`. Each edge independently decides when a dependent may start. |
| needParentReady | bool | Legacy sugar: wait until the dependency's readiness passes before starting — exactly `dependsOnEdges: [{ name, waitFor: "ready" }]` (default false). |
| oneshot | bool | Run to completion; signals started + ready only on exit 0. Dependents start only after it finishes. |
| readiness | Probe | Readiness probe checked after Start. Dependents with needParentReady / waitFor: "ready" wait for it. |
| readinessFunc | JS function | JS callback alternative: readinessFunc: () => true. Overrides the declarative probe. Must be short and non-blocking. |
| restart | RestartPolicy | Restart on failure: { mode, maxRetries, backoff }. Default: never restart. |
| shutdown | ShutdownConfig | Graceful drain: { signal, timeout, forceKill }. Default: SIGTERM, 30s timeout, force-kill. |
| optional | bool | Non-fatal sidecar: if it fails (and is not restarted), the error is logged but the graph and container survive. Default false (fatal). |
| exec | bool | Mode A: syscall.Exec this process as PID 1. Leaf-only, mutually exclusive with health. |
| scheduler | SchedulerConfig | Cron-driven node: runs process on each tick instead of once. Mutually exclusive with exec and health. |
| health | HealthConfig | Drive the shared /readyz from this node: ready only while readyProbe passes. Mutually exclusive with exec. |
| forwardSignals | string[] | Signals relayed from ezx (PID 1) to this process's group, e.g. ["SIGTERM", "SIGINT", "SIGHUP"]. |

### Ordering semantics

- A node starts after all of its dependencies have **started**.
- `dependsOnEdges` lets each edge independently wait for `started` (default), `ready`, or `exit`. The legacy `dependsOn: ["a"]` + `needParentReady: true` is sugar for `dependsOnEdges: [{ name: "a", waitFor: "ready" }]` and still works.
- Sibling / independent nodes start **in parallel**; multi-root chains start concurrently.
- A **oneshot** node (`oneshot: true`) runs to completion and signals success only on exit 0, so its dependents start after it finishes.
- When a dependency exits permanently, its running dependents drain and its not-yet-started dependents are skipped.
- When the app shuts down (SIGTERM/SIGINT), the whole graph is drained: nodes get their shutdown signal, then force-kill per config.

```js
const { chain } = require("ezx");

chain.run({
  nodes: [
    { name: "postgres", process: { binaryPath: "postgres", arguments: ["-D", "/var/lib/postgresql/data"] } },
    {
      name: "pgbouncer",
      dependsOn: ["postgres"],
      needParentReady: true,   // waits until postgres readiness passes
      process: { binaryPath: "pgbouncer", arguments: ["/etc/pgbouncer/pgbouncer.ini"] },
      restart: { mode: "always", backoff: 2e9 },
    },
  ],
});
```

<a id="oneshot"></a>

## 3. Oneshot services & init DAG → exec main

A **oneshot** node (`oneshot: true`) runs its process to completion instead of supervising it as a long-running service. It is the declarative form of the imperative `process.run` one-shot, composed into the dependency graph:

- **Exit 0 = success gate.** A oneshot signals `started` + `ready` only when it exits 0, so its dependents start only after it finishes successfully. A non-zero exit fails the node (fatal unless `optional`), skipping its dependents.
- **Retryable.** `restart` on a oneshot retries it on failure up to `MaxRetries` (the classic run-to-completion supervisor model).
- **Mutually exclusive** with `exec`, `scheduler`, `health`, and `needParentReady` (a oneshot is "ready" only when it exits 0) — validated fail-fast at chain validation.

The headline pattern is **"init DAG → exec main"**: a chain of init steps (initdb, stanza-create, migrations) run to completion, then the main long-running app becomes PID 1. The exec node fires only after all its oneshot deps exit 0 and no other long-running node is still supervised:

```js
const { chain } = require("ezx");

const PGDATA = "/var/lib/postgresql/data";

chain.run({
  nodes: [
    {
      name: "initdb",
      oneshot: true,
      process: { binaryPath: "/usr/local/bin/initdb", arguments: ["--username=postgres", "--pwfile=/tmp/ezx-pwfile", PGDATA], user: "postgres", group: "postgres" },
    },
    {
      name: "stanza-create",
      oneshot: true,
      dependsOn: ["initdb"],
      process: { binaryPath: "/usr/bin/pgbackrest", arguments: ["stanza-create", "--stanza=main", "--pg1-path=" + PGDATA] },
    },
    {
      name: "postgres",
      exec: true,
      dependsOn: ["initdb", "stanza-create"],
      process: { binaryPath: "/usr/local/bin/postgres", arguments: ["-D", PGDATA], user: "postgres", group: "postgres" },
    },
  ],
});
```

Try it: `./ezx bootstrap examples/bootstrap/oneshot-init.js`.

<a id="logging"></a>

## 4. Per-service logging with rotation

Each supervised process can route its stdout/stderr to its own **file-backed log destination with size-based rotation**. Set `log.stdout` / `log.stderr` to `"file"` and point `filePath` at the target:

```js
process: {
  binaryPath: "/usr/bin/pgbouncer",
  arguments: ["/etc/pgbouncer/pgbouncer.ini"],
  log: {
    stdout: "file",                 // route stdout to a rotating file
    filePath: "/var/log/ezx/pgbouncer.log",
    maxBytes: 5 * 1024 * 1024,      // rotate at 5 MiB (default 10 MiB)
    maxBackups: 2,                  // keep .log + .1 + .2 (default 3)
    stderr: "stderr",               // stderr still goes to container stderr
  },
},
```

- **Rotation** is size-based: when a write would push the active file past `maxBytes`, it is closed, shifted to `.1` (newest), older backups shift down, and backups beyond `maxBackups` are dropped. `maxBackups < 0` keeps backups indefinitely. There is no time-based rotation or compression.
- **Append on open** — a restarted node appends to the current file; rotation is purely size-driven.
- **Shared path** — when both `stdout` and `stderr` are `"file"` with the same `filePath`, they share one writer so interleaving stays coherent and rotation happens once. Different paths get independent writers.
- **Validation** — a `"file"` destination requires a non-empty `filePath` (fail-fast at chain validation, naming the node).

Try it: `./ezx bootstrap examples/bootstrap/log-rotation.js`.

<a id="probes"></a>

## 5. Probes & readiness

A `Probe` is the readiness/health primitive used for node `readiness`, scheduler `gate`, and per-node `health.readyProbe`. Real probes replace `sleep 5`.

| Field | Type | What it does |
| --- | --- | --- |
| type | "exec" | "tcp" | "http" | Which check runs. Empty defaults to "exec". |
| exec | string[] | Command + args. Ready when it exits 0. |
| execExpect | string | Additionally require this substring on the probe's stdout (e.g. pg_is_in_recovery() = f). |
| execExpectExact | bool | Trimmed stdout must equal execExpect exactly, not just contain it. |
| tcp | { host, port } | Dial target for TCP probes. |
| http | { url, expectStatus } | HTTP GET target; expectStatus 0 accepts any 2xx. |
| interval | duration (ns) | Wait between attempts, e.g. 1e9 = 1s. |
| timeout | duration (ns) | Per-attempt timeout, e.g. 5e9 = 5s. |
| maxAttempts | int | Give up after this many failed attempts. |

```js
readiness: { type: "exec",  exec: ["pg_isready", "-h", "/run/postgresql"], interval: 1e9, timeout: 5e9, maxAttempts: 30 }
readiness: { type: "tcp",   tcp: { host: "127.0.0.1", port: 5432 } }
readiness: { type: "http",  http: { url: "http://127.0.0.1:8008/master", expectStatus: 200 } }

// execExpect: exit code 0 is not enough — the output must match too.
readiness: {
  type: "exec",
  exec: ["psql", "-c", "select pg_is_in_recovery()"],
  execExpect: "f",   // only the primary is "ready"
}
```

> **needParentReady without a probe** — If the parent has no readiness probe, children with needParentReady: true start as soon as the parent's Start returns — not when the app inside is actually serving. Always attach a probe when ordering matters.

<a id="files"></a>

## 6. Env → config files (declarative provisioning)

Nodes take a `files` array of `FileProvision` rules. Each rule targets one file and runs an ordered list of operations — replacing the `sed -i` pipeline in classic entrypoints. Provisioning runs **before** the node's process starts.

```js
// POSTGRESQL_CONFIG_LOG_DESTINATION=stderr
// becomes log_destination = 'stderr' in postgresql.conf
const { chain } = require("ezx");

chain.run({
  nodes: [{
    name: "postgres",
    files: [{
      path: "/etc/postgresql/postgresql.conf",
      operations: [{
        type: "set-property",
        fromEnvPattern: "^POSTGRESQL_CONFIG_(.+)$",
        nameTransform: "lower",
        pattern: "^\\s*#?\\s*${name}\\s*=.*",
        value: "${name} = ${value}",
        valueFormat: "auto",
      }],
    }],
    // files are provisioned before the process starts
    process: {
      binaryPath: "postgres",
      arguments: ["-D", "/var/lib/postgresql/data"],
    },
  }],
});
```

### Operation types

| type | Behaviour |
| --- | --- |
| replace | Replace the entire file content with Value/FromEnv. Creates the file if missing. |
| append | Append Value/FromEnv as new line(s) at the end. |
| remove | Remove all lines matching Pattern (regex). |
| ensure | Ensure a line equal to Value exists; append if not found. |
| set-property | Remove lines matching Pattern, then append Value. Remove + Ensure — the most common config mutation. Idempotent. |
| insert-before | Insert Value before the first line matching Pattern. Not idempotent — for run-once startup. |
| insert-after | Insert Value after the first line matching Pattern. Not idempotent — for run-once startup. |
| replace-block | Replace the block from Pattern to BlockEnd with Value. Idempotent. |
| set-block | Remove the block if present, then insert before Marker (or append). Idempotent — the PHP-config injection op. |
| copy | Copy Source to the rule's path. No content transformation. |

### Shared operation fields

| Field | What it does |
| --- | --- |
| pattern | Regex for remove / set-property / insert-* / block ops. Interpolates ${name}, ${1}, ${2} when fromEnvPattern is set. |
| blockEnd / marker | End regex and fallback insertion point for block ops. |
| value | Literal with ${ENV_VAR}, ${name}, ${value}, ${1} interpolation. |
| fromEnv | Use this env var's value as ${value}. |
| fromEnvPattern | Enumerate env vars matching a regex; the op repeats once per match. |
| nameTransform | "lower" | "upper" | "snake-to-dot" | "snake-to-camel" | "snake-to-kebab" — transforms the captured name. |
| valueFormat | "auto" smart-quotes (numbers, on|off|true|false, unit-suffixed stay bare; strings get single quotes). "quote" always quotes. Default raw. |
| when | { name, value } env condition gating the op. Empty value means "any non-empty". |
| createOnly | Skip if the target file already exists — the `if [ ! -f ]` guard for default configs. |
| permission / owner | File mode (e.g. 0640) and user:group applied after writing. |

> **Filtering never affects provisioning** — filterEnv / filterEnvPattern apply at spawn time only. File-provisioning callbacks still see the full environment — consume secrets into config files first, then strip them from the child env.

<a id="args"></a>

## 7. Env → CLI arguments

`argOperations` build CLI flags from environment variables — the argument-side mirror of file operations. They run in slice order and are appended after `arguments`.

```js
// PROMETHEUS_ENABLE_WEB_LIFECYCLE=true
// THANOS_QUERY_STORE_ADDRESSES=store1:10901,store2:10901
// becomes: --web.enable-lifecycle --endpoint=store1:10901 --endpoint=store2:10901
const { chain } = require("ezx");

chain.run({
  nodes: [{
    name: "thanos",
    process: {
      binaryPath: "thanos",
      argOperations: [
        { when: { name: "PROMETHEUS_ENABLE_WEB_LIFECYCLE", value: "true" }, flag: "--web.enable-lifecycle", format: "bare-flag" },
        { flag: "--endpoint", fromEnv: "THANOS_QUERY_STORE_ADDRESSES", split: "," },
      ],
    },
  }],
});
```

### Operation forms

| Pattern | Produces |
| --- | --- |
| { flag, fromEnv } | if-set value: --flag=value once, only when the var is non-empty. |
| { when: {name, value}, flag, format: "bare-flag" } | if-truthy toggle: --flag only when the var equals value. |
| { when: {...}, flag, value } | conditional flag+literal: --enable-feature=native-histograms. |
| { flag, fromEnv, split: "," } | list split: one --endpoint=<el> per comma-separated element. |
| { flag, fromEnvPattern: "^X_(.+)$", value: '${name}="${value}"', nameTransform: "lower" } | pattern-enum: one arg per matching env var, capture group 1 as ${name}. |
| { fromEnv, split: " ", format: "raw" } | raw pass-through: whitespace-split extra args, no flag prefix. |

- `format` defaults to `"flag-value"` (`--flag=value`). Other formats: `"flag-space"` (two args), `"bare-flag"` (no value), `"raw"` (value only).
- `${VAR:-default}` interpolation works inside `arguments` entries too, e.g. `"--web.listen-address=:${PROMETHEUS_PORT:-9090}"`.
- `value` templates interpolate `${VAR}`, `${VAR:-default}`, `${value}`, `${name}`, `${1}`.

<a id="builders"></a>

## 8. Config & YAML builders

Structured objects in, deterministic text out — no template-string concatenation, byte by byte.

### config.build — key=value, tables, INI

```js
const { config } = require("ezx");

// 1. Flat key=value (postgresql.conf) — auto value formatting
const postgresql = config.build({
  comment: "PostgreSQL configuration generated by container",
  separator: " = ",
  valueFormat: "auto",
  properties: [["listen_addresses", "*"], ["port", 5432], ["shared_buffers", "256MB"]],
});

// 2. Table/matrix (pg_hba.conf) — column-aligned rows
const pgHba = config.build({
  rows: [["local", "all", "postgres", "trust"], ["host", "all", "all", "0.0.0.0/0", "md5"]],
});

// 3. INI sections (pgbackrest.conf)
const pgbackrest = config.build({
  separator: "=",
  sections: { global: { "log-level-console": "info" }, default: { "pg1-port": 5432 } },
});
```

### config.builder — fluent & conditional

```js
const b = config.builder({ separator: "=", valueFormat: "none" });
const srv = b.section("server");
srv.set("port", 8080);
srv.setIf("host", env.get("HOST", "")); // skipped when empty
srv.setFlag("tls", true);               // tls=y (skipped when false)
```

### yaml.build — deterministic YAML

```js
const { yaml } = require("ezx");
const doc = yaml.build({
  scope: "postgres-cluster",
  postgresql: { listen: "0.0.0.0:5432", parameters: { hot_standby: "on" } },
});
// scope: postgres-cluster
// postgresql:
//   listen: 0.0.0.0:5432
//   parameters:
//     hot_standby: "on"
```

> **Determinism** — Map objects are sorted for determinism; arrays keep insertion order. Same env, same output, every time — which is the point.

<a id="script-api"></a>

## 9. Imperative modules

Everything under `require("ezx")`. Use the declarative `chain` where you can; drop to these modules for logic that cannot be expressed declaratively.

| Module | Methods (verified against v0.8.1) |
| --- | --- |
| env | get(name, def?), has(name), isTruthy(name, def?), bool(name, def?), int(name, def?), list(name, sep, def?), normalizeBool(v), all() |
| fs | exists, stat, mkdir, mkdirAll, ensureDir, chmod, chmodRecursive, chown, chownRecursive, readDir, glob, remove, removeAll, rename, symlink, realpath, tempFile, tempDir, umask, write, which, copy, copyTree |
| editor | open(path) → upsert, replace, append, ensure, remove, insertBefore, insertAfter, replaceBlock, setBlock, read, readLines, writeLines |
| process | spawn(node) → handle, run(opts), capture(opts), exec(node), shell(cmd, opts), sleep(ns) |
| shell | quote(value) — single-quote-escape for safe shell embedding |
| probe | tcp(host, port), http(url, expectStatus), exec(cmd...) — boolean one-shot checks |
| log | debug/info/warn/error(format, ...args), enabled(level) |
| chain | run({ nodes }) — the declarative supervisor (see Dependency graph). Flat nodes + dependsOn / dependsOnEdges edges. |
| config | build(spec), builder(opts) — see Builders |
| yaml | build(object) — deterministic YAML serialization |
| scheduler | build(cfg), parse(expr), every(expr, fn, opts?), trigger(name), status(name) |
| health | setReady(bool), ready() |
| api | get(path, handler), post(path, handler), put/delete — routes on the shared health server |
| archive | create(format, dest, sources), extract(src, dest, opts?) — native tar/tar.gz/tar.zst/zip, pure Go stdlib |
| crypto | sha256(input), sha256File(path), base64Encode(input), base64Decode(input), randomHex(bytes) |
| net | http(method, url, opts?), download(url, dest, opts?) — native HTTP client with timeout, redirects, optional SHA-256 verification |

### One-shot processes & handles

```js
const { process, log } = require("ezx");

// One-shot with inherited output; blocks until exit, returns the code.
const code = process.run({ name: "init", process: { binaryPath: "/bin/sh", arguments: ["-c", "echo hi"] } });

// Capture output; check: true throws on non-zero exit.
const { stdout } = process.capture({
  process: { binaryPath: "pg_ctl", arguments: ["status"] },
  check: true,
});

// Long-running spawn returns a handle.
const h = process.spawn({ name: "app", process: { binaryPath: "/bin/app" } });
h.start([]);                // start (idempotent); env array appended after inherited
log.info("pid=%d", h.pid());
h.wait();                   // blocks → exit code
h.signal("SIGTERM");        // TERM | INT | KILL | QUIT | HUP only
```

### Privilege drop (initdb pattern)

```js
// Run initdb as the postgres user, like the official entrypoint.
process.run({
  name: "initdb",
  process: {
    binaryPath: "initdb",
    arguments: ["-D", PGDATA],
    user: "postgres",           // name or numeric UID
    group: "postgres",          // optional; default = user's primary group
  },
  check: true,
});
```

<a id="scheduler"></a>

## 10. Cron scheduler with gates

A node with a `scheduler` runs its process on each cron tick instead of once at startup — the cron sidecar container, gone. Ticks can be gated on a probe (e.g. only back up while the primary role is healthy) and fired manually from the runtime API.

```js
const { chain, scheduler } = require("ezx");

chain.run({
  nodes: [
    { name: "postgres", process: { binaryPath: "postgres", arguments: ["-D", "/var/lib/postgresql/data"] } },
    {
      name: "hourly-backup",
      dependsOn: ["postgres"],
      needParentReady: true,
      process: { binaryPath: "pgbackrest", arguments: ["--config=/etc/pgbackrest.conf", "backup"] },
      scheduler: scheduler.build({
        schedule: { expression: "0 * * * *", timezone: "UTC" },
        minInterval: 60e9,   // dedupe: never run twice within 60s
        gate: { type: "exec", exec: ["pg_isready", "-h", "/run/postgresql"] },
      }),
    },
  ],
});
```

| Option / method | What it does |
| --- | --- |
| schedule.expression | 5-field cron expression, e.g. "0 2 * * *". |
| schedule.timezone | IANA name ("UTC", "Asia/Jakarta"). Empty = process local time. |
| initialDelay | Wait before the first tick evaluation (nanoseconds). Zero = start immediately. |
| minInterval | Dedupe window: a job never runs twice within it. Zero defaults to one minute. |
| gate | Probe checked before each tick; a failing probe skips that tick (the loop continues). |
| maxRetries | Caps consecutive per-tick restarts of the tick process; <= 0 means unlimited. |
| scheduler.every(expr, fn, opts?) | JS callback on each tick instead of spawning a process. Must be short and non-blocking. |
| scheduler.trigger(name) | Manual fire-and-forget tick (e.g. from an api route). |
| scheduler.status(name) | Returns { exists, inflight }. |

> **Trigger semantics** — scheduler.trigger is fire-and-forget: it returns true when accepted, but the tick is skipped if one is already in flight or if the last run is inside the minInterval window. minInterval defaults to 60s — an hourly schedule plus a 60s minInterval is a dedupe, not a frequency change.

<a id="health-api"></a>

## 11. Health endpoints & runtime API

ezx serves `/readyz`, `/livez` and `/healthz` on `EZX_HEALTH_ADDR` (default `:8080`). The same server hosts user-defined routes via `api` — no sidecar needed.

```js
const { api, scheduler, health, chain } = require("ezx");

// Flip readiness from script logic (drives /readyz and /healthz).
health.setReady(true);

// Register api routes BEFORE chain.run — the script blocks inside chain.run,
// so anything after it never executes. Handlers return plain values (JSON).
api.post("/backup/full", () => { scheduler.trigger("hourly-backup"); return { ok: true }; });
api.get("/backup/status", () => scheduler.status("hourly-backup"));

// Per-node readiness: /readyz reports ready only while the probe passes.
chain.run({
  nodes: [{
    name: "app",
    process: { binaryPath: "/bin/app" },
    health: { readyProbe: { type: "http", http: { url: "http://127.0.0.1:3000/healthz" } } },
  }],
});
```

- `/livez` — is the process alive.
- `/readyz`, `/healthz` — readiness state, driven by `health.setReady()` and/or a node's `health.readyProbe` (falling back to its `readiness` probe).

> **Register api routes before chain.run** — chain.run blocks the script while it supervises the tree — api.post after it never executes and the routes 404 (verified). Register routes first, with EZX_HEALTH_ADDR set, or registration fails with "ezx.api requires EZX_HEALTH_ADDR to be set". Handlers run on HTTP goroutines: return plain values, never promises or long-running work.

<a id="modes"></a>

## 12. Launch modes

Every node chooses how its main process launches — as an alternative to tini/dumb-init, or with your app as PID 1.

|  | Mode A — exec | Mode B — supervised |
| --- | --- | --- |
| PID 1 | Your app (via syscall.Exec) | ezx |
| Signals | Native, handled by your app | Forwarded by ezx (forwardSignals) + reaping |
| Restart / health / scheduler | None — ezx vanishes after exec | Full: restart, health, scheduler, tree |
| Constraints | Leaf node only; no active supervised siblings; mutually exclusive with health | None |
| Set with | exec: true — or a lone node, which is auto-exec'd (see below) | restart / health / scheduler / forwardSignals, or execDefault: false |

> **A lone node is auto-exec'd** — A chain with a single node that sets no `restart`, no `scheduler` and no `health` is exec'd as PID 1 automatically — even without `exec: true` — so ezx is gone and zombie reaping / signal forwarding stop. ezx logs a warning when this implicit exec fires. Keep ezx as PID 1 by declaring supervision (`restart`, `health`, `scheduler`) or by opting out with `execDefault: false` (chain-level).

<a id="recipes"></a>

## 13. Use cases & recipes

Each recipe is runnable — the full versions live in the repo under `examples/bootstrap/`.

### 1. Gated dependency graph (postgres → pgbouncer → pgpool)

Flat `nodes` + `dependsOn` edges; `needParentReady` replaces the `$PARENT_PID` polling hack. Siblings (etcd, pgbouncer) start in parallel.

```js
chain.run({
  nodes: [
    { name: "postgresql", process: { binaryPath: "/bin/sh", arguments: ["-c", "start-postgres"] } },
    { name: "etcd",       dependsOn: ["postgresql"], process: { binaryPath: "etcd" } },
    { name: "pgbouncer",  dependsOn: ["postgresql"], needParentReady: true,
      process: { binaryPath: "pgbouncer", arguments: ["/etc/pgbouncer/pgbouncer.ini"] } },
    { name: "pgpool",     dependsOn: ["pgbouncer"], needParentReady: true, process: { binaryPath: "pgpool" } },
  ],
});
```

### 2. Secrets handling for pgBackRest

pgBackRest breaks if `PGBACKREST_*` vars leak into its child. Consume them into config, then strip them from the spawned env:

```js
chain.run({
  nodes: [{
    name: "pgbackrest",
    process: {
      binaryPath: "pgbackrest",
      arguments: ["--config=/etc/pgbackrest.conf", "backup"],
      filterEnvPattern: ["^PGBACKREST_"],   // strip consumed secrets before spawn
      environment: ["KEEP=kept"],           // additive entries survive filtering
    },
  }],
});
```

### 3. Scheduled backup, role-gated, manually triggerable

The cron + gate + API combo from the production containers project — a 1:1 port of a bash entrypoint into a few readable lines:

```js
const { api, scheduler } = require("ezx");

// Only tick while this node is the primary (http gate).
node.scheduler = scheduler.build({
  schedule: { expression: "0 2 * * *", timezone: "UTC" },
  initialDelay: 120e9,
  gate: { type: "http", http: { url: "http://localhost:8008/master" } },
});

// in a real script, register api routes BEFORE chain.run
api.post("/backup/full", () => { scheduler.trigger("backup-full"); return { ok: true }; });
api.get("/backup/status", () => scheduler.status("backup-full"));
```

### 4. First-run init + exec (init DAG → exec main)

The official postgres entrypoint flow, declaratively: init steps run to completion as `oneshot` nodes, then the main app becomes PID 1 via `exec` only after they all exit 0:

```js
const { chain } = require("ezx");

const PGDATA = "/var/lib/postgresql/data";

chain.run({
  nodes: [
    {
      name: "initdb",
      oneshot: true,
      process: { binaryPath: "/usr/local/bin/initdb", arguments: ["--username=postgres", "--pwfile=/tmp/ezx-pwfile", PGDATA], user: "postgres", group: "postgres" },
    },
    {
      name: "pg-hba",
      oneshot: true,
      dependsOn: ["initdb"],
      process: { binaryPath: "/bin/sh", arguments: ["-c", "echo 'local all all trust' >> " + PGDATA + "/pg_hba.conf"] },
    },
    {
      name: "postgres",
      exec: true,
      dependsOn: ["initdb", "pg-hba"],
      process: { binaryPath: "/usr/local/bin/postgres", arguments: ["-D", PGDATA], user: "postgres", group: "postgres" },
    },
  ],
});
// the exec node never returns — postgres is now PID 1 with native signal handling
```

### 5. Deterministic Patroni YAML

```js
const { yaml, editor } = require("ezx");
const patroni = yaml.build({
  scope: "postgres-cluster",
  postgresql: { listen: "0.0.0.0:5432", parameters: { hot_standby: "on" } },
});
editor.open("/etc/patroni/patroni.yml").replace(patroni);
```

<a id="edge-cases"></a>

## 14. Edge cases & gotchas

Everything here is verified behaviour of v0.8.1 — the stuff you would otherwise discover at 2 a.m. inside a container.

> **Callback-only nodes** — Nodes can omit the `process` field entirely (or set `binaryPath: ""`), making them pure JavaScript callbacks. The node completes immediately after `onStart` (if set) returns. Use this for oneshot init tasks that are pure JS logic, or for keepalive nodes that just hold the container open for supervised children.

> **Lone nodes exec automatically** — A single node with no restart/scheduler/health becomes a syscall.Exec — your app replaces ezx as PID 1, so zombie reaping and signal forwarding stop. Great for the classic "set up then vanish" entrypoint; surprising if you expected supervision. Opt out with `execDefault: false` (chain-level), or declare the supervision you want (`restart`, `health`, `scheduler`).

> **Mutual exclusions are hard errors** — exec + health → "cannot have both Exec and Health set". scheduler + exec or health → "cannot combine Scheduler with Exec or Health". An exec node with still-supervised siblings → "cannot have active siblings". Oneshot nodes are mutually exclusive with `exec`, `scheduler`, `health`, and `needParentReady` (a oneshot is "ready" only when it exits 0). The orchestrator returns these as errors, not warnings.

> **restart.maxRetries <= 0 means unlimited** — Default mode is "never". "on-failure" restarts only on non-zero exit; "always" restarts regardless of exit code (a clean exit 0 is also restarted). With maxRetries unset or <= 0, restarts are unlimited — set a cap unless you truly want forever.

> **Shutdown timeout semantics** — Default drain: SIGTERM, 30s timeout, then force-kill. timeout: -1 means wait indefinitely (unbounded drain, no force-kill). forceKill: false never SIGKILLs even if the timeout elapses.

> **Gate failure ≠ error** — A failing scheduler gate skips that tick and keeps the loop alive — designed for role-gated backups (skip while standby). The tick process itself, however, is restarted on failure up to maxRetries.

> **Env filtering is spawn-time only — and additive env wins** — filterEnv/filterEnvPattern strip variables from the inherited environment at spawn time. Entries in `environment` are appended after filtering and always survive. Invalid regexes in filterEnvPattern are configuration errors that fail the spawn.

> **process.signal knows five signals** — Handle.signal accepts SIGTERM/TERM/15, SIGINT/INT/2, SIGKILL/KILL/9, SIGQUIT/QUIT and SIGHUP/HUP. Any other name is a silent no-op — it does not throw. process.exec never returns on success (the image is replaced).

> **env.get default applies when empty too** — env.get("X", "d") returns "d" when X is unset *or empty* — mirroring ${X:-d}, not ${X-d}. isTruthy(name, def) implements the shell default-true pattern: with a default provided, unset/empty resolves via the default.

> **api routes registered after chain.run silently never exist** — Verified: a script that calls api.post after chain.run serves 404 on every route — chain.run blocks the rest of the script. Register all routes before chain.run, with EZX_HEALTH_ADDR set.

> **Unsatisfiable cron expressions fire immediately** — Verified: a scheduled node with an impossible date (e.g. 0 0 31 2 * — Feb 31) runs its first tick immediately at startup, because cron.Next degenerates for schedules that can never match. ParseCron only validates syntax and ranges — pick a satisfiable expression.

> **Idempotency by operation** — set-property, set-block, replace-block, ensure and remove are idempotent (safe across restarts). insert-before / insert-after are intentionally not — they are for run-once startup mutations. Use createOnly for the `if [ ! -f file ]` default-config guard.

<a id="status"></a>

## 15. Status

ezx is pre-1.0 and in active use. The `require("ezx")` surface is additive-only: existing modules and method names are frozen, additions are allowed, and any unavoidable breaking change ships as `require("ezx/v2")` with the prior surface preserved.

The supervision model draws on the init-supervisor tradition (tini, dumb-init, s6-overlay): dependency graph, per-edge wait modes, oneshot services, init DAG → exec main, per-service logging with rotation, exit-code propagation, restart, health, and scheduling — declared in JavaScript, no shell.

Apache License 2.0 — © 2026 Supan Adit Pratama.

