d1-kyt
Opinionated Cloudflare D1 + Kysely toolkit.
ky(sely) + t(oolkit) = kyt
Not an ORM. Thin wrapper with helpers that relies on Kysely's type inference and Valibot schemas. No magic, no runtime overhead.
Install
npm install d1-kyt kysely valibot
Workflow
schema.ts → schema:diff → .sql migration → wrangler apply → types from schema
- Define your schema with Valibot types in
schema.ts - Run
d1-kyt schema:diff <name>— diffs against a snapshot, writes a.sqlmigration - Apply with
wrangler d1 migrations apply <db> --local - Use
$inferSelect/$inferInsertfrom your schema for type-safe queries
No code generation step required — types come directly from the schema file.
Quick start
# In your Cloudflare Workers project: d1-kyt init # Edit the generated schema file, then: d1-kyt schema:diff create_users # Apply to local D1: wrangler d1 migrations apply <db-name> --local
init auto-detects the right directory. Defaults to db/. If your wrangler config has a migrations_dir with a parent folder (e.g. src/migrations/), it uses that parent instead.
Schema
// db/schema.ts import { defineTable, defineIndex, defineTrigger, type InferDB } from 'd1-kyt/schema'; import { createQueryBuilder } from 'd1-kyt'; import * as v from 'valibot'; export const users = defineTable('users', { email: v.string(), // TEXT NOT NULL name: v.optional(v.string()), // TEXT (nullable) age: v.optional(v.pipe(v.number(), v.integer())), // INTEGER (nullable) prefs: v.optional(v.object({ theme: v.string() })), // TEXT JSON (nullable) role: v.optional(v.string(), 'user'), // TEXT DEFAULT 'user' }); export const usersEmailIdx = defineIndex(users, ['email'], { unique: true }); export const auditTrigger = defineTrigger('users_audit_trg', { timing: 'AFTER', event: 'INSERT', on: users, body: `INSERT INTO audit (action, at) VALUES ('insert', datetime('now'));`, }); export type DB = InferDB<{ users: typeof users }>; // Compile-only Kysely query builder — stateless, no connection held. // Use with queryAll/queryFirst/queryRun to execute against D1. export const db = createQueryBuilder<DB>();
Valibot → SQL type mapping
| Valibot schema | SQL type | Nullable |
|---|---|---|
v.string() |
TEXT | NOT NULL |
v.number() |
REAL | NOT NULL |
v.pipe(v.number(), v.integer(), ...) |
INTEGER | NOT NULL |
v.boolean() |
INTEGER | NOT NULL |
v.object({...}) or v.array(...)
|
TEXT (JSON) | NOT NULL |
v.optional(X) |
type of X | NULL |
v.nullable(X) |
type of X | NULL |
v.optional(X, defaultVal) |
type of X + DEFAULT | NULL |
Auto columns
Every table gets id, createdAt, updatedAt by default, plus an AFTER UPDATE trigger for updatedAt. Control via options:
// Disable everything defineTable('events', { uuid: v.string() }, { primaryKey: false, createdAt: false, updatedAt: false, }) // Custom names (snake_case) defineTable('users', { email: v.string() }, { primaryKeyColumn: 'user_id', createdAtColumn: 'created_at', updatedAtColumn: 'updated_at', })
CLI
d1-kyt init [--dir <dir>] # scaffold config + schema template d1-kyt schema:diff <name> [--dir <dir>] # diff schema → write .sql migration d1-kyt schema:diff <name> --schema <path> # use a custom schema file path
init
Creates (skips if already exists):
-
<dir>/config.ts— migrationsDir + namingStrategy -
<dir>/schema.ts— schema template to fill in -
<dir>/schema.snapshot.jsonc— diff baseline (commit this to git)
Directory resolution:
-
--dir <path>if provided -
db/if it contains aconfig.ts(default) -
d1-kyt/if it contains aconfig.ts(legacy) - Parent of wrangler
migrations_dirif not the project root
schema:diff <name>
Reads your schema.ts, diffs against schema.snapshot.jsonc, writes a numbered .sql file to your migrationsDir, and updates the snapshot. Commit the .sql and the snapshot together — they are the source of truth for migration history.
d1-kyt schema:diff create_users # generates 0001_create_users.sql d1-kyt schema:diff add_email_index # generates 0002_add_email_index.sql d1-kyt schema:diff --dir db add_posts # use db/config.ts, db/schema.ts
Config
// db/config.ts (or d1-kyt/config.ts) import { defineConfig } from 'd1-kyt/config'; export default defineConfig({ migrationsDir: 'db/migrations', namingStrategy: 'sequential', // or 'timestamp' });
Type inference
Types come directly from your schema — no code generation step required:
import { users } from './db/schema'; // Full row returned by SELECT type UserRow = typeof users.$inferSelect; // { id: number; email: string; name: string | undefined; age: number | undefined; // prefs: { theme: string } | undefined; role: string | undefined; // createdAt: string; updatedAt: string } // Input for INSERT type NewUser = typeof users.$inferInsert; // { email: string; name?: string | undefined; age?: number | undefined; ... id?: number } // Kysely DB type — auto-columns are Generated<T> so insert doesn't require them import { type InferDB } from 'd1-kyt/schema'; export type DB = InferDB<{ users: typeof users }>;
Query Builder
db is a compile-only Kysely instance exported from your schema file. It holds no connection — it just builds typed SQL that you pass to queryAll/queryFirst/queryRun for execution.
// src/queries.ts import { db } from './db/schema'; export const listUsers = () => db.selectFrom('users').selectAll().compile(); export const getUserByEmail = (email: string) => db.selectFrom('users').selectAll().where('email', '=', email).compile(); export const insertUser = (email: string, name?: string) => db.insertInto('users').values({ email, name }).returning(['id']).compile();
Execute Queries
// src/app.ts import { Hono } from 'hono'; import { queryAll, queryFirst, queryRun } from 'd1-kyt'; import * as q from './queries'; const app = new Hono(); app.get('/users', async (c) => { const users = await queryAll(c.env.DB, q.listUsers()); return c.json(users); }); app.get('/users/:email', async (c) => { const user = await queryFirst(c.env.DB, q.getUserByEmail(c.req.param('email'))); return user ? c.json(user) : c.notFound(); }); app.post('/users', async (c) => { const { email, name } = await c.req.json(); const [user] = await queryAll(c.env.DB, q.insertUser(email, name)); return c.json(user, 201); });
Foreign keys
Declare foreign keys in the table options. PRAGMA foreign_keys = ON is automatically prepended to any migration that includes FK constraints.
export const categories = defineTable('categories', { name: v.string() }); export const posts = defineTable('posts', { title: v.string(), categoryId: v.pipe(v.number(), v.integer()), }, { foreignKeys: [ { columns: ['categoryId'], references: categories, onDelete: 'CASCADE' }, ], });
Generates in CREATE TABLE:
FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE
Adding a FK column to an existing table
Use a nullable column — SQLite allows ALTER TABLE ADD COLUMN ... REFERENCES only when the column is nullable (existing rows get NULL):
// v2: add optional deptId FK to existing employees table export const employees = defineTable('employees', { name: v.string(), deptId: v.optional(v.pipe(v.number(), v.integer())), // nullable ✓ }, { foreignKeys: [{ columns: ['deptId'], references: departments }], });
Generates:
ALTER TABLE "employees" ADD COLUMN "deptId" INTEGER REFERENCES "departments"("id");
Adding a
NOT NULLFK column to an existing table is not possible without a table rebuild — a warning comment is emitted instead.
Supported onDelete / onUpdate actions
CASCADE | SET NULL | RESTRICT | NO ACTION
Partial indexes
defineIndex(users, ['email'], { unique: true, where: '"active" = 1', // raw SQL string })
Conventions
- Auto
id INTEGER PRIMARY KEY AUTOINCREMENT,createdAt TEXT,updatedAt TEXTon every table (all configurable/disableable) - Auto
AFTER UPDATEtrigger to keepupdatedAtcurrent - Index naming:
{table}_{cols}_idx/{table}_{cols}_uq - Trigger naming:
{table}_{col}_trg -
schema.snapshot.jsoncis the diff source of truth — always commit it alongside migration SQL files
API reference
d1-kyt/schema
| Export | Description |
|---|---|
defineTable(name, columns, opts?) |
Define a table; returns SchemaTable with $inferSelect / $inferInsert
|
defineIndex(table, columns, opts?) |
Define an index (columns are type-checked against the table) |
defineTrigger(name, opts) |
Define a custom trigger attached to a table |
sqlTypeFromSchema(schema) |
Inspect a Valibot schema → { type, notNull, default?, isJson }
|
TableOptions |
Options type for auto columns (re-exported) |
InferDB<Tables> |
Infer a Kysely-compatible DB type from a record of SchemaTable definitions |
d1-kyt (main)
| Export | Description |
|---|---|
createQueryBuilder<DB>() |
Kysely instance (compile-only, no execution) |
queryAll(db, query) |
Execute query, return all rows |
queryFirst(db, query) |
Execute query, return first row or null |
queryRun(db, query) |
Execute mutation, return run metadata |
queryBatch(db, queries) |
Execute multiple queries as a D1 batch |
d1-kyt/config
| Export | Description |
|---|---|
defineConfig(config) |
Define config.ts (typed helper) |
License
MIT