d1-kyt
v0.7.2
Published
Opinionated Cloudflare D1 + Kysely toolkit
Maintainers
Readme
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 valibotWorkflow
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> --localinit 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 pathinit
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 provideddb/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.tsConfig
// 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 CASCADEAdding 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
