kastor0.1.0
Get started

GUIDE 34Cloudflare Workers

kastor on Cloudflare Workers

Workers have no long lived process and no TCP pool of your own, so the database arrives as a binding or a connection string on every request. kastor builds a client from that per request at almost no cost, with three choices of database: D1, Postgres through Hyperdrive, or Turso over HTTP.

Project structure and bindings

A Cloudflare Workers project is a wrangler.toml plus an entry module. Bindings declared in the config appear on env in each request, and npx wrangler types generates an Env interface so they are typed.

Project
wrangler.toml             bindings and compatibility flags
worker-configuration.d.ts generated by wrangler types
src/db/schema.ts          tables and relations
src/index.ts              fetch handler, client per request
kastor.config.ts          for the CLI (local SQLite or Postgres)
kastor/migrations/        SQL written by migrate dev
migrations/               D1 copies for wrangler
wrangler.toml
name = "api"
main = "src/index.ts"
compatibility_date = "2026-10-01"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "app-db"
database_id = "paste-the-id-from-wrangler-d1-create"
migrations_dir = "migrations"

[[hyperdrive]]
binding = "HYPERDRIVE"
id = "paste-the-id-from-wrangler-hyperdrive-create"
Terminal
npm install kastor-orm pg
npm install -D wrangler better-sqlite3
npx wrangler d1 create app-db
npx wrangler hyperdrive create app-pg --connection-string="postgres://user:pass@host:5432/app"
npx wrangler types

You will usually pick one of the two databases; both are shown so the code below can use either.

D1: a client per request

The D1 binding is an object, not a connection. d1(env.DB) wraps it, and each call becomes one D1 query, or one batch for $transaction([...]). A small factory keeps the schema and relations in one place.

src/index.ts
import { kastor, isKastorError } from 'kastor-orm'
import { d1 } from 'kastor-orm/d1'
import { schema, relations } from './db/schema'

export interface Env {
  DB: D1Database
}

const createDb = (env: Env) => kastor({ adapter: d1(env.DB), schema, relations })

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const db = createDb(env)
    const url = new URL(request.url)
    try {
      if (url.pathname === '/posts' && request.method === 'GET') {
        const posts = await db.posts.findMany({ where: { published: true }, include: { author: true }, take: 20 })
        return Response.json(posts)
      }
      if (url.pathname === '/posts' && request.method === 'POST') {
        const body = (await request.json()) as { title: string; authorId: number }
        const [post] = await db.$transaction([
          db.posts.create({ data: body }),
          db.users.update({ where: { id: body.authorId }, data: { role: 'admin' } }),
        ])
        return Response.json(post, { status: 201 })
      }
      return new Response('Not found', { status: 404 })
    } catch (e) {
      if (isKastorError(e)) return Response.json({ error: e.code, message: e.message }, { status: 400 })
      throw e
    }
  },
}

The array form runs as one atomic D1 batch. The callback form of $transaction throws UNSUPPORTED on D1 unless you opt into d1(env.DB, { transactions: 'sequential' }), which runs the callback without atomicity. The D1 guide covers local development and limits in detail.

Postgres through Hyperdrive

Hyperdrive keeps a pool of real connections to your Postgres database close to Cloudflare's network and hands each Worker invocation a fast local connection string. With nodejs_compat, the pg package runs in Workers. Connect a Client per request and close it after the response is sent:

src/pg-worker.ts
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from './db/schema'

interface Env {
  HYPERDRIVE: Hyperdrive
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const client = new pg.Client({ connectionString: env.HYPERDRIVE.connectionString })
    await client.connect()
    ctx.waitUntil(client.end())
    const db = kastor({ adapter: postgres(client), schema, relations })

    const authors = await db.users.findMany({
      where: { posts: { some: { published: true } } },
      include: { posts: { where: { published: true }, take: 3 }, _count: { select: { posts: true } } },
      take: 10,
    })
    return Response.json(authors)
  },
}

Interactive transactions work here, because a pg.Client is a real session: db.$transaction(async (tx) => ...) commits or rolls back as on Node. Keep them short; the Worker's CPU and wall clock limits apply.

For Turso, use createClient from @libsql/client/web with the URL and token from Worker secrets (npx wrangler secret put TURSO_AUTH_TOKEN) and the libsql adapter. It talks HTTP, so no compatibility flag is needed.

Migrations and deploy

The kastor CLI runs on Node, not inside a Worker. Point kastor.config.ts at a local database of the same kind: a SQLite file for D1, or your Postgres database for Hyperdrive.

kastor.config.ts
import Database from 'better-sqlite3'
import { defineConfig } from 'kastor-orm'
import { sqlite } from 'kastor-orm/sqlite'

export default defineConfig({
  schema: './src/db/schema.ts',
  adapter: () => sqlite(new Database('.kastor/dev.db')),
  migrations: './kastor/migrations',
})

For D1, generate the SQL locally and hand it to wrangler, which applies it to the local and the remote database:

Terminal
npx kastor migrate dev --name add_posts
cp kastor/migrations/*_add_posts/migration.sql migrations/0002_add_posts.sql
npx wrangler d1 migrations apply app-db --local
npx wrangler d1 migrations apply app-db --remote
npx wrangler deploy

For Postgres behind Hyperdrive, kastor applies the migrations itself, through the direct URL rather than Hyperdrive:

Terminal
DATABASE_URL=postgres://user:pass@host:5432/app npx kastor migrate deploy
npx wrangler deploy

In CI, run the same steps in that order: apply migrations, then wrangler deploy, so the new code never meets an old schema. Secrets such as the Cloudflare API token and the direct Postgres URL live in the CI secret store, never in wrangler.toml.

Which database to pick on Workers

D1 is the simplest: no connection string, no network setup, data close to the Worker, and the SQLite dialect. Its limits are a database size cap and batches instead of interactive transactions. Postgres through Hyperdrive suits teams that already run Postgres or need its features, at the cost of managing a database. Turso sits between the two: SQLite over HTTP with replicas in many regions. kastor uses the same schema and queries for all three, so the choice can follow your data rather than your code.

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:

  • Clients are built inside fetch from env, never at module scope.
  • Postgres clients are ended with ctx.waitUntil.
  • D1 code uses the array form of $transaction.
  • Secrets live in wrangler secret and the CI store, not in wrangler.toml.
  • Migrations are applied before wrangler deploy.

Pitfalls

  • Module scope clients. env is not available at module scope, and connections opened in one request must not be reused by another. Build the client inside fetch.
  • Forgetting to end pg clients. Without ctx.waitUntil(client.end()), connections linger until the isolate is evicted.
  • D1 limits. D1 caps statement size and bound parameters. kastor splits large IN lists, but very large createMany calls should be chunked.
  • Local versus remote. wrangler dev uses a local D1 by default. Pass --remote only when you mean to touch the real database.

Questions people ask

Q1Why create the kastor client per request in a Worker?

Bindings such as D1 and Hyperdrive are passed to fetch on env, not available at module scope. Wrapping a binding opens nothing by itself, so building the client per request is cheap.

Q2Can I use PostgreSQL from Cloudflare Workers?

Yes. Create a Hyperdrive config for your database, enable the nodejs_compat flag, connect a pg Client per request with env.HYPERDRIVE.connectionString, and pass it to the postgres adapter. Hyperdrive pools the real connections.

Q3Do transactions work on D1?

D1 runs atomic batches. Use db.$transaction([a, b]), which kastor sends as one batch. Callback transactions need an interactive connection, which D1 does not offer; they work on Postgres through Hyperdrive.

Q4How do I run migrations for a Worker?

Generate SQL with npx kastor migrate dev locally. For D1, apply it with wrangler d1 migrations apply; for Postgres, run npx kastor migrate deploy from CI with the direct database URL.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.