Frameworks

Express

A typed Postgres client behind an Express server with one read route and one write route, with the example's files, a migrate step, and what CI proves.

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

RuntimeBun 1.4 or newer in CI. Node.js 22.18 or newer works with the same driver after you compile the TypeScript.
Driverokmodel/pg/postgresjs + postgres
Exampleexamples/express

CI boots the example with Bun only. The Hono example shows a bundled Node.js entry, if you want the build step.

Install

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

src/db.ts
/**
 * 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:

src/schema.ts
export { app, notes } from "../../shared/schema.js";
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

SettingWhereNotes
DATABASE_URLEnvironment of the server processA direct TCP URL such as postgres://user:pass@host:5432/app.
PORTEnvironmentThe server listens on PORT, default 3101.
maxconnect optionUp to 10 connections per process when omitted. Pool.
sslconnect optionForwarded to postgres.js for hosted databases. Pool.
preparedconnect optionLeave it unset behind a pooler in transaction mode. Pool.

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.

src/server.ts
/**
 * 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.

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:

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:

Terminal
bunx okm push
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.

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

cd examples/express
bun run dev

Call it from another terminal:

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.

RequestStatusBody
GET /health200{"ok":true}
POST /notes with {"title":"hello"}201{"id":"1","title":"hello"}
GET /notes200Up to 50 rows, oldest first: [{"id":"1","title":"hello"}]
POST /notes with {}400{"error":"title is required"}
POST /notes with nope as the body400Express'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:

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:

Terminal
bunx okm migrate apply

A migration that only adds things lets the old version keep running while the release rolls out (Migrations in CI, 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).
SymptomCauseFix
error: DATABASE_URL is required at startsrc/db.ts ran with no DATABASE_URLSet the variable where the process runs.
OKM1520 The database is behind the app on the first queryThe app was deployed before okm migrate applyApply the migrations, then start the app.
OKM1101 find() needs a limit.A read with no limitPass limit, as the example route does.
OkmError: relation "example_notes" does not exist (SQLSTATE 42P01)The table was never created on this databaseRun bunx okm push, or okm generate init then okm migrate apply.
400 with an HTML page that names SyntaxErrorThe body is not valid JSON, so express.json() rejects it before the route runsSend valid JSON. Add an error-handling middleware to answer in JSON.
500 with Express's default HTML error pageA database error reached next(error), such as the 42P01 above or a lost connectionRead the error in the process log. Add an error-handling middleware to return JSON and hide the stack in production.
EADDRINUSE on startAnother process holds PORT (default 3101)Stop it, or set PORT.
Refusing pooler host <host>. Apply needs a direct connection.okm pointed at a pooler URLPoint okm at the direct host.

What is tested

EvidenceWhereWhat runs
CIexamples / frameworks jobexpress.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.
ManualBy handokm 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 coveredThe 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.