# Express

You get a small Express server with a read route, a write route, and a clean shutdown that closes the pool. The example is the smallest long-lived server in this handbook.

## Runtime and driver

| | |
| --- | --- |
| Runtime | Bun 1.4 or newer in CI. Node.js 22.18 or newer works with the same driver after you compile the TypeScript. |
| Driver | `okmodel/pg/postgresjs` + `postgres` |
| Example | `examples/express` |

CI boots the example with Bun only. The [Hono](/docs/frameworks/hono) example shows a bundled Node.js entry, if you want the build step.

## Install

```sh
bun add okmodel postgres express
bun add -D @types/express @types/node
```

## `db` module and lifecycle

There is one client per process, opened at module level in `src/db.ts`, and every request shares its pool. It throws at import when `DATABASE_URL` is missing, because `connect` does not read the environment. `closeDb()` ends the pool.

```ts title="src/db.ts" no-check="imports ./schema.js, which re-exports a file outside the example root (shown below)"
/**
 * Process-scoped client for the Express example.
 *
 * One connect for the process. Close on shutdown.
 */

import { connect } from "okmodel/pg/postgresjs";

import { app } from "./schema.js";

const url = process.env.DATABASE_URL;
if (url === undefined || url.length === 0) {
  throw new Error("DATABASE_URL is required");
}

/** Shared client for this process. */
export const db = connect(url, { schema: app });

/** Closes the process client. */
export async function closeDb(): Promise<void> {
  await db.close();
}
```

`src/schema.ts` re-exports the table every framework example shares:

```ts title="src/schema.ts" no-check="re-exports ../../shared/schema.js, which is outside the example root"
export { app, notes } from "../../shared/schema.js";
```

```ts title="shared/schema.ts"
/**
 * Shared notes table for framework examples.
 *
 * Each example imports this schema. Tests create an isolated database and
 * apply the matching SQL before booting the app.
 */

import { schema, table, t } from "okmodel/pg";

/** Notes table used by every framework example. */
export const notes = table("example_notes", {
  id: t.identity(),
  title: t.text(),
});

/** Schema for the examples. */
export const app = schema({ tables: [notes] });

/** SQL that creates {@link notes} on an empty database. */
export const NOTES_DDL = `
create table example_notes (
  id bigint generated by default as identity primary key,
  title text not null
);
`;
```

In your own app, define the table in `src/schema.ts` directly. The table is named `example_notes`, so the client property is `db.example_notes`.

## Connection config

| Setting | Where | Notes |
| --- | --- | --- |
| `DATABASE_URL` | Environment of the server process | A direct TCP URL such as `postgres://user:pass@host:5432/app`. |
| `PORT` | Environment | The server listens on `PORT`, default `3101`. |
| `max` | `connect` option | Up to 10 connections per process when omitted. [Pool](/docs/operate/session-and-pool#max). |
| `ssl` | `connect` option | Forwarded to postgres.js for hosted databases. [Pool](/docs/operate/session-and-pool#tls). |
| `prepared` | `connect` option | Leave it unset behind a pooler in transaction mode. [Pool](/docs/operate/session-and-pool#prepared). |

## One route that reads and writes

`GET /notes` returns the oldest 50 notes. `POST /notes` takes `{ "title": "..." }` and returns the new row with status 201. Database errors go to Express through `next(error)`. `GET /health` does not touch the database; it is the readiness check the test waits on, and the one to point a load balancer at. On `SIGINT` or `SIGTERM` the server stops accepting connections and calls `closeDb()`.

A body that is not valid JSON never reaches the route: `express.json()` rejects it first. `POST /notes` has no authentication; `express.json()` caps the body at its default 100 KB. Add authentication before you copy it.

```ts title="src/server.ts" no-check="imports express, which is installed in examples/express"
/**
 * Smallest Express app: GET reads notes, POST writes one.
 */

import express from "express";

import { closeDb, db } from "./db.js";

const port = Number(process.env.PORT ?? "3101");
const app = express();
app.use(express.json());

app.get("/notes", async (_req, res, next) => {
  try {
    const rows = await db.example_notes.find({
      orderBy: { id: "asc" },
      limit: 50,
    });
    res.json(rows);
  } catch (error) {
    next(error);
  }
});

app.post("/notes", async (req, res, next) => {
  try {
    const title = typeof req.body?.title === "string" ? req.body.title : undefined;
    if (title === undefined || title.length === 0) {
      res.status(400).json({ error: "title is required" });
      return;
    }
    const row = await db.example_notes.insert({ title });
    res.status(201).json(row);
  } catch (error) {
    next(error);
  }
});

app.get("/health", (_req, res) => {
  res.json({ ok: true });
});

const server = app.listen(port, () => {
  console.log(`express example on ${port}`);
});

async function shutdown(): Promise<void> {
  server.close();
  await closeDb();
}

process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
```

## Dev and test

Start Postgres, point `DATABASE_URL` at it, create the table, then run the server. In this repository, `bun run db:up` starts a local Postgres whose primary URL is below.

```sh
bun run db:up
export DATABASE_URL=postgres://okm:okm@127.0.0.1:55432/okm
```

The example has no `okmodel.config.ts`. In your app, add one next to `package.json`:

```ts title="okmodel.config.ts"
import { defineConfig } from "okmodel/migrate";

export default defineConfig({
  schema: "./src/schema.ts",
  database: process.env.DATABASE_URL!,
});
```

Create the table on a fresh database with `okm push`, which writes no SQL file and suits a database you can drop:

```sh
bunx okm push
```

```text
target default
applied push-1
```

For a database you will deploy, write the first migration and apply it, then commit `migrations/`. After a later schema change, run `bunx okm push` again on a dev database, or `bunx okm generate <name>` then `bunx okm migrate apply`.

```sh
bunx okm generate init
bunx okm migrate apply
```

Start the server in the same shell. Without `DATABASE_URL` there, it stops at start with `error: DATABASE_URL is required`.

```sh
cd examples/express
bun run dev
```

Call it from another terminal:

```sh
curl -i http://localhost:3101/health
curl -i -X POST http://localhost:3101/notes -H 'content-type: application/json' -d '{"title":"hello"}'
curl -i http://localhost:3101/notes
```

These responses come from a run on a fresh database. `id` is a string, because the default codec for `t.identity()` is a string.

| Request | Status | Body |
| --- | --- | --- |
| `GET /health` | 200 | `{"ok":true}` |
| `POST /notes` with `{"title":"hello"}` | 201 | `{"id":"1","title":"hello"}` |
| `GET /notes` | 200 | Up to 50 rows, oldest first: `[{"id":"1","title":"hello"}]` |
| `POST /notes` with `{}` | 400 | `{"error":"title is required"}` |
| `POST /notes` with `nope` as the body | 400 | Express's HTML error page: `SyntaxError: JSON Parse error: Unrecognized token 'n'` and, outside production, the stack |

The example's own test does not run `okm`: it creates an isolated database and applies `NOTES_DDL` through `examples/shared/support.ts`, which is faster. To run it from a clone:

```sh
bun run build
bun run db:up
bun install --cwd examples/express
cd examples/express && REQUIRE_DOCKER=1 bun test ./express.test.ts
```

## Deploy and migrate-on-deploy

Run `src/server.ts` under Bun, or a compiled copy under Node.js, behind your process manager, with `DATABASE_URL` set. Send `SIGTERM` to stop it so `closeDb()` runs.

Apply migrations before the new version takes traffic. Run this in your release job, with `DATABASE_URL` pointing at a direct connection to the primary, not a pooler:

```sh
bunx okm migrate apply
```

A migration that only adds things lets the old version keep running while the release rolls out ([Migrations in CI](/docs/guides/migrations-in-ci), [Environments](/docs/migrations/environments)).

## Limits and gotchas

- Do not create a client per request. Import the one in `src/db.ts`.
- Only `SIGINT` and `SIGTERM` close the pool. A process killed with `SIGKILL` leaves its connections for Postgres to reap.
- The route returns at most 50 rows. To read past them, page through the rest ([Read rows](/docs/queries/read-rows)).

| Symptom | Cause | Fix |
| --- | --- | --- |
| `error: DATABASE_URL is required` at start | `src/db.ts` ran with no `DATABASE_URL` | Set the variable where the process runs. |
| [OKM1520](/docs/reference/errors#okm1520) `The database is behind the app` on the first query | The app was deployed before `okm migrate apply` | Apply the migrations, then start the app. |
| [OKM1101](/docs/reference/errors#okm1101) `find() needs a limit.` | A read with no `limit` | Pass `limit`, as the example route does. |
| `OkmError: relation "example_notes" does not exist` (SQLSTATE `42P01`) | The table was never created on this database | Run `bunx okm push`, or `okm generate init` then `okm migrate apply`. |
| 400 with an HTML page that names `SyntaxError` | The body is not valid JSON, so `express.json()` rejects it before the route runs | Send valid JSON. Add an error-handling middleware to answer in JSON. |
| 500 with Express's default HTML error page | A database error reached `next(error)`, such as the `42P01` above or a lost connection | Read the error in the process log. Add an error-handling middleware to return JSON and hide the stack in production. |
| `EADDRINUSE` on start | Another process holds `PORT` (default `3101`) | Stop it, or set `PORT`. |
| `Refusing pooler host <host>. Apply needs a direct connection.` | `okm` pointed at a pooler URL | Point `okm` at the direct host. |

## What is tested

| Evidence | Where | What runs |
| --- | --- | --- |
| CI | `examples / frameworks` job | `express.test.ts` creates an isolated database, applies `NOTES_DDL`, boots `bun ./src/server.ts` on a free port, waits for `/health`, then checks `POST /notes` (201) and `GET /notes`. |
| Manual | By hand | `okm push` and the `curl` calls above, including both 400s. With the table missing, the `POST` returned a 500 HTML page. The process exited after `SIGTERM`. |
| Not covered | | The example on Node.js, `okm migrate apply` against this example, and any hosted deployment. |

The frameworks job runs on pull requests that touch `examples/`, the runtime, the dialects, or the Frameworks pages, and after each merge.

Index: [/llms.txt](/llms.txt)
