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.
You get one Hono app that serves a read route and a write route, and two small entry files that run it on Bun and on Node.js with the same postgres.js driver. CI boots both.
Runtime and driver
| Runtime | Bun 1.4 or newer, or Node.js 22.18 or newer |
| Driver | okmodel/pg/postgresjs + postgres |
| Example | examples/hono |
okmodel/pg/bun loads only on Bun. The example uses postgres.js so one driver covers both runtimes.
Install
bun add okmodel postgres hono @hono/node-server@hono/node-server is only for the Node.js entry.
db module and lifecycle
There is one client per process, opened at module level in src/db.ts. It throws at import when DATABASE_URL is missing, because connect does not read the environment. Both entry files call closeDb() on SIGINT and SIGTERM.
/**
* Process-scoped client for the Hono example (Node and Bun).
*/
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 Bun or Node.js process | A direct TCP URL such as postgres://user:pass@host:5432/app. |
PORT | Environment | Both entries listen on PORT, default 3102. |
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. The route turns a body that is not JSON into an empty object, so it gets the same 400 as a missing title. 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.
POST /notes has no authentication and no body size limit. Add both before you copy it.
/**
* Hono routes shared by the Bun and Node servers.
*/
import { Hono } from "hono";
import { db } from "./db.js";
/** Application routes. */
export const app = new Hono();
app.get("/health", (c) => c.json({ ok: true }));
app.get("/notes", async (c) => {
const rows = await db.example_notes.find({
orderBy: { id: "asc" },
limit: 50,
});
return c.json(rows);
});
app.post("/notes", async (c) => {
const body = await c.req.json<{ title?: string }>().catch(() => ({}));
const title = typeof body.title === "string" ? body.title : undefined;
if (title === undefined || title.length === 0) {
return c.json({ error: "title is required" }, 400);
}
const row = await db.example_notes.insert({ title });
return c.json(row, 201);
});The two entries differ only in how they serve app.fetch. On Bun:
/**
* Hono on Bun (`bun ./src/server.ts`).
*/
import { app } from "./app.js";
import { closeDb } from "./db.js";
const port = Number(process.env.PORT ?? "3102");
Bun.serve({
port,
fetch: app.fetch,
});
console.log(`hono (bun) example on ${port}`);
process.on("SIGINT", () => void closeDb());
process.on("SIGTERM", () => void closeDb());On Node.js:
/**
* Hono on Node via `@hono/node-server`.
*/
import { serve } from "@hono/node-server";
import { app } from "./app.js";
import { closeDb } from "./db.js";
const port = Number(process.env.PORT ?? "3102");
serve({ fetch: app.fetch, port }, () => {
console.log(`hono (node) example on ${port}`);
});
process.on("SIGINT", () => void closeDb());
process.on("SIGTERM", () => void closeDb());Dev and test
Start Postgres, point DATABASE_URL at it, create the table, then run either entry. 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 applyRun the Bun entry, or the Node.js entry. The dev:node script bundles src/server-node.ts with bun build --target=node --packages=external, so Node.js loads postgres from node_modules. Without DATABASE_URL in that shell, either entry stops at start with Error: DATABASE_URL is required.
cd examples/hono
bun run dev
PORT=3112 bun run dev:nodeCall it from another terminal:
curl -i http://localhost:3102/health
curl -i -X POST http://localhost:3102/notes -H 'content-type: application/json' -d '{"title":"hello"}'
curl -i http://localhost:3102/notesThese responses come from the Bun entry 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 | {"error":"title is required"} |
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/hono
cd examples/hono && REQUIRE_DOCKER=1 bun test ./hono.test.tsDeploy and migrate-on-deploy
Deploy the Bun entry or the bundled Node.js entry with DATABASE_URL set. Keep one client per process; do not open one per request.
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
- Run the Node.js entry from a bundle, not from the
.tsfiles: the sources import./db.jsand./app.js. - This app serves long-lived processes. For an isolate platform, see Cloudflare Workers.
- The route returns at most 50 rows. To read past them, page through the rest (Read rows).
- The signal handlers close the pool and do not stop the listener. On Bun, a
SIGTERMleavesBun.serveansweringGET /healthwhile every query fails on a closed pool. Have your process manager followSIGTERMwithSIGKILLafter a grace period, or exit from your own handler oncecloseDb()resolves.
| 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. |
500 Internal Server Error with a plain-text body | An unhandled error in a route, such as 42P01 above or a lost connection. The example has no app.onError, so Hono answers with its default | Read the error in the process log. Add app.onError to return JSON. |
Failed to start server. Is port 3102 in use? on Bun | An earlier copy of the app still holds the port, often one that ignored SIGTERM | Stop it by process ID, 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 | hono.test.ts boots bun ./src/server.ts, then the Node.js bundle with node, each on a fresh database with NOTES_DDL applied. Each waits for /health, then checks POST /notes (201) and GET /notes. |
| Manual | By hand | okm push and the curl calls above against the Bun entry, including the 400s. With the table missing, the POST returned a plain-text 500. A SIGTERM closed the pool and left /health answering. |
| Not covered | 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.
TanStack Start
A typed Postgres client in TanStack Start server routes on Node.js, with the example's files, a migrate step, and what CI proves.
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.