Frameworks

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

RuntimeBun 1.4 or newer, or Node.js 22.18 or newer
Driverokmodel/pg/postgresjs + postgres
Exampleexamples/hono

okmodel/pg/bun loads only on Bun. The example uses postgres.js so one driver covers both runtimes.

Install

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

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

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 Bun or Node.js processA direct TCP URL such as postgres://user:pass@host:5432/app.
PORTEnvironmentBoth entries listen on PORT, default 3102.
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. 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.

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

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

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

Run 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:node

Call 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/notes

These responses come from the Bun entry 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 body400{"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.ts

Deploy 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:

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

  • Run the Node.js entry from a bundle, not from the .ts files: the sources import ./db.js and ./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 SIGTERM leaves Bun.serve answering GET /health while every query fails on a closed pool. Have your process manager follow SIGTERM with SIGKILL after a grace period, or exit from your own handler once closeDb() resolves.
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.
500 Internal Server Error with a plain-text bodyAn unhandled error in a route, such as 42P01 above or a lost connection. The example has no app.onError, so Hono answers with its defaultRead the error in the process log. Add app.onError to return JSON.
Failed to start server. Is port 3102 in use? on BunAn earlier copy of the app still holds the port, often one that ignored SIGTERMStop it by process ID, 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 jobhono.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.
ManualBy handokm 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 coveredokm 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.