kastor0.1.0
Get started

GUIDE 30Hono

kastor with Hono

Hono runs the same app on Node.js, Bun, Deno and Cloudflare Workers, and kastor runs on all of them. The only part that changes per runtime is how the client is created: once per process on servers, once per request from a binding on Workers. A middleware hides that difference behind c.var.db.

Project structure

A Hono app is a single object with routes and middleware, and the entry file decides the runtime. Keeping the app separate from the entry lets the same routes serve Node in development and Workers in production, if you want that.

Project
src/
  db/schema.ts            tables and relations
  db/client.ts            createDb(adapter) and the Db type
  app.ts                  Hono app, middleware, routes
  node.ts                 entry for Node (also works on Bun and Deno)
  worker.ts               entry for Cloudflare Workers
kastor.config.ts
kastor/migrations/
Terminal
npm install kastor-orm hono @hono/node-server pg
npx kastor-orm init --db postgres
npx kastor migrate dev --name init

A typed db variable set by middleware

The client factory takes an adapter, so each runtime can pass its own. The Db type comes from the factory, and the Hono generics put it on c.var.

src/db/client.ts
import { kastor, type Adapter } from 'kastor-orm'
import { schema, relations } from './schema'

export const createDb = (adapter: Adapter) => kastor({ adapter, schema, relations })
export type Db = ReturnType<typeof createDb>
src/app.ts
import { Hono } from 'hono'
import { isKastorError } from 'kastor-orm'
import type { Db } from './db/client'

export type Env = { Variables: { db: Db } }

export function buildApp(getDb: (c: unknown) => Db) {
  const app = new Hono<Env>()

  app.use('*', async (c, next) => {
    c.set('db', getDb(c))
    await next()
  })

  app.get('/posts', async (c) => {
    const tag = c.req.query('tag')
    const posts = await c.var.db.posts.findMany({
      where: { published: true, ...(tag ? { tags: { some: { name: tag } } } : {}) },
      include: { author: { select: { name: true } }, tags: true },
      orderBy: { id: 'desc' },
      take: 20,
    })
    return c.json(posts)
  })

  app.post('/posts', async (c) => {
    const body = await c.req.json<{ title: string; authorId: number }>()
    const post = await c.var.db.posts.create({ data: { title: body.title, authorId: body.authorId } })
    return c.json(post, 201)
  })

  app.onError((err, c) => {
    if (isKastorError(err)) {
      const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'UNIQUE_VIOLATION' ? 409 : 400
      return c.json({ error: err.code, message: err.message }, status)
    }
    return c.json({ error: 'INTERNAL' }, 500)
  })

  return app
}

Entries per runtime

On Node, the client is created once when the module loads, and the middleware hands the same instance to every request. The same file runs on Bun and Deno, which both support pg; on Bun you can also serve with export default app instead of @hono/node-server.

src/node.ts
import { serve } from '@hono/node-server'
import pg from 'pg'
import { postgres } from 'kastor-orm/postgres'
import { buildApp } from './app'
import { createDb } from './db/client'

const db = createDb(postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 })))
const app = buildApp(() => db)

const server = serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) })
process.on('SIGTERM', () => server.close(() => void db.$disconnect()))

On Workers there is no process to hold a pool, and the D1 binding comes with each request on c.env. The middleware builds a client per request from the binding:

src/worker.ts
import { d1 } from 'kastor-orm/d1'
import { buildApp } from './app'
import { createDb } from './db/client'

const app = buildApp((c) => createDb(d1((c as { env: { DB: D1Database } }).env.DB)))

export default app

Environment variables and migrations

On servers, DATABASE_URL comes from the environment: node --env-file=.env, Bun's automatic .env loading, or deno run --env-file. Run npx kastor migrate deploy in CI before the new version starts. On Workers, there is no URL: the binding is declared in wrangler.toml, and D1 migrations are applied with wrangler from the SQL files kastor writes. The Workers guide shows the whole loop.

With Postgres on Node, Bun or Deno, the first command is all you need; with D1, the last two:

Terminal
npx kastor migrate deploy
npx wrangler d1 migrations apply app-db --remote
npx wrangler deploy

Development reloads are process restarts with tsx watch, bun --watch or deno run --watch, so the client is recreated cleanly. With bun --hot, which keeps the process alive, store the client on globalThis as the Bun guide shows.

Choosing the driver per runtime

The adapter is the only runtime specific line, so it is worth choosing deliberately. On Node.js, Bun and Deno, a long lived process holds a pool: postgres(new pg.Pool(...)) for PostgreSQL, mysql(...) with mysql2 for MySQL, or sqlite(...) for a local file. Bun can also pass a bun:sqlite database to the same sqlite adapter. On Cloudflare Workers there is no process to hold a pool, so you choose between bindings and HTTP: d1(env.DB) for D1, a pg.Client through Hyperdrive for PostgreSQL, or libsql() with the web client for Turso.

Because createDb takes any adapter, tests can use a third option: SQLite in memory with sqlite(new Database(':memory:')), migrated with npx kastor db push or by applying the migration files at startup. The routes do not change, which is the point of keeping the app separate from the entries.

Transactions and request scope

On servers, c.var.db.$transaction(async (tx) => ...) borrows one connection from the pool for the callback and returns it afterwards. On Workers with D1, use the array form instead, which kastor sends as one atomic batch. A useful pattern is to keep the middleware generic and let each route decide: routes that only read never open a transaction, routes that write several rows wrap exactly those writes.

Production checklist

Before the first deploy, check these points. Each one prevents a problem that otherwise shows up only under real traffic, usually as exhausted connections, a leaked secret or a schema that does not match the code that is running:

  • Servers build one client per process; Workers build one per request.
  • The Variables type gives c.var.db its full type.
  • app.onError maps KastorError codes to responses.
  • D1 routes use the array form of $transaction.
  • Migrations run before each deploy, with kastor or wrangler.

Pitfalls

  • Per request pools on servers. Do not call new pg.Pool inside the middleware on Node; that opens a pool per request. Build it once.
  • Transactions on D1. D1 runs atomic batches, not interactive transactions. Use db.$transaction([a, b]) there.
  • JSON and bigint. c.json cannot serialize bigint; convert big keys first.
  • Validation. Pair routes with a validator middleware so bodies are checked before they reach create.

Questions people ask

Q1How do I pass a database client to Hono routes?

Set it in middleware with c.set('db', db) and declare it in the app's Variables type. Routes read it as c.var.db, fully typed.

Q2Should the client be created per request?

On Node, Bun and Deno, no: create one client per process and reuse it. On Cloudflare Workers, bindings such as D1 arrive with each request, so build the client per request; wrapping a binding is cheap.

Q3Which driver should I use on each runtime?

pg or mysql2 on Node, Bun and Deno for server databases, bun:sqlite on Bun, better-sqlite3 on Node, the D1 binding or @libsql/client/web for Turso on Workers.

Q4Does Hono RPC keep kastor types?

Yes. Routes that return c.json(result) expose the result type to the hc client. Dates arrive as strings over JSON, so type the client side accordingly.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.