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
| 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 example shows a bundled Node.js entry, if you want the build step.
Install
bun add okmodel postgres expressbun add -D @types/express @types/nodedb 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.
/**
* 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:
export { app, notes } from "../../shared/schema.js";/**
* 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. |
ssl | connect option | Forwarded to postgres.js for hosted databases. Pool. |
prepared | connect option | Leave 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.
/**
* 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/okmThe example has no okmodel.config.ts. In your app, add one next to package.json:
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:
bunx okm pushtarget default
applied push-1For 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.
bunx okm generate initbunx okm migrate applyStart 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 devCall 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/notesThese 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:
bun run build
bun run db:up
bun install --cwd examples/express
cd examples/express && REQUIRE_DOCKER=1 bun test ./express.test.tsDeploy 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:
bunx okm migrate applyA 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
SIGINTandSIGTERMclose the pool. A process killed withSIGKILLleaves its connections for Postgres to reap. - The route returns at most 50 rows. To read past them, page through the rest (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 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 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.
Hono
A typed Postgres client behind one Hono app that runs on Bun and on Node.js, with the example's files, a migrate step, and what CI proves.
Cloudflare Workers
A typed Postgres client in a Cloudflare Worker over Neon, opened and closed per request, with the example's files, a migrate step, and what CI and Nightly prove.