
Drizzle Sqlite Scaffold
- 84 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
drizzle-sqlite-scaffold is a Claude Code skill for databases. It helps solo builders move faster with AI-assisted coding.
Key points
- drizzle-sqlite-scaffold
- Databases
- AI-coding skill
Drizzle Sqlite Scaffold by the numbers
- 84 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #348 of 911 Databases skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill drizzle-sqlite-scaffoldAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 84 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with databases tasks during ai-assisted development?
Helps with databases tasks during AI-assisted development.
Who is it for?
Best when you're working on databases and need structured help with drizzle-sqlite-scaffold.
Skip if: Teams with no databases needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with databases tasks during ai-assisted development, or when drizzle-sqlite-scaffold is a claude code skill for databases. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to drizzle-sqlite-scaffold: drizzle-sqlite-scaffold; Databases; AI-coding skill.
Files
Drizzle SQLite Scaffold
Parameterized templates for bootstrapping Drizzle + SQLite in a fresh project, or adding a new table/repository to an existing one. Every output bakes in the conventions documented in `references/conventions.md` — explicit primary keys, indexed foreign keys, relations() declarations, $inferSelect/$inferInsert exports, timestamp_ms dates, boolean-mode bools, WAL + foreign_keys=ON + busy_timeout pragmas, singleton client with HMR guard, and CRUD helpers using .returning() + inArray() + .onConflictDoUpdate().
When to Apply
Reach for these templates when:
- Starting a new project that will use Drizzle with SQLite (any driver)
- Adding a new table to an existing Drizzle project — the table file should match the existing patterns
- Adding a CRUD repository module for an existing table
- Refactoring a hand-rolled Drizzle setup that's missing pragmas, has no
relations(), or has hand-writtenUsertypes that drift from the schema - Migrating from another ORM (Prisma, Kysely) to Drizzle and wanting consistent shapes from the start
Setup
Required parameters (asked on first use, saved to config.json)
| Parameter | Required | Default | Values |
|---|---|---|---|
driver | yes | — | better-sqlite3 \ |
db_url_env | no | DATABASE_URL | env var name |
schema_dir | no | ./src/db/schema | per-table schema files |
repository_dir | no | ./src/db/repository | per-table CRUD modules |
validators_dir | no | ./src/db/validators | drizzle-zod schemas (when with_zod=true) |
client_path | no | ./src/db/client.ts | singleton client module |
migrations_dir | no | ./drizzle | drizzle-kit output |
If config.json already exists with values, this skill uses them; otherwise it asks via AskUserQuestion.
Per-table parameters (asked each time a new table is scaffolded)
| Parameter | Required | Default | Description |
|---|---|---|---|
name | yes | — | Kebab-case singular: user, order-item. Used for filenames and TS identifiers (name_camel, name_pascal derived). |
table_name | no | snake_case plural of name | SQL table name: users, order_items |
pk | no | serial-int | serial-int \ |
timestamps | no | true | adds createdAt/updatedAt columns |
soft_delete | no | false | adds nullable deletedAt + partial index |
relations | no | [] | list of related table names — expands relations() body |
with_zod | no | true | emits a drizzle-zod validators file |
Available Templates
Project-init templates (emit once)
| Template | Output File | When |
|---|---|---|
| `drizzle.config.local.ts.template` | drizzle.config.ts | driver is better-sqlite3, bun-sqlite, or libsql with a file: URL |
| `drizzle.config.turso.ts.template` | drizzle.config.ts | driver is libsql against Turso (remote libsql: URL) |
| `client.better-sqlite3.ts.template` | {{client_path}} | driver is better-sqlite3 |
| `client.libsql.ts.template` | {{client_path}} | driver is libsql |
| `client.bun-sqlite.ts.template` | {{client_path}} | driver is bun-sqlite |
| `schema-index.ts.template` | {{schema_dir}}/index.ts | Always (initially empty; append exports as tables are added) |
| `gitignore.template` | .gitignore (append) | Always |
Per-table templates (emit once per table)
| Template | Output File | When |
|---|---|---|
| `table.ts.template` | {{schema_dir}}/{{name}}.ts | Per table |
| `repository.ts.template` | {{repository_dir}}/{{name}}.ts | Per table |
| `validators.ts.template` | {{validators_dir}}/{{name}}.ts | Per table when with_zod=true |
How to Use
Flow A — Initialize a new project (run once)
1. Resolve project parameters. Read config.json. For any required field that's empty, ask the user via AskUserQuestion (driver is the only strictly required one; the rest have sensible defaults).
2. Install runtime + tooling first so the rendered files type-check immediately:
# Pick the driver-specific runtime package:
npm install drizzle-orm @libsql/client # for libsql
npm install drizzle-orm better-sqlite3 # for better-sqlite3
npm install drizzle-orm # bun:sqlite is built into Bun
# Dev tools (all drivers):
npm install -D drizzle-kit
npm install -D @types/better-sqlite3 # better-sqlite3 only
npm install -D drizzle-zod zod # if with_zod=true3. Pick the config and client variants for the resolved driver:
better-sqlite3→drizzle.config.local.ts.template+client.better-sqlite3.ts.templatelibsqlwithfile:URL →drizzle.config.local.ts.template+client.libsql.ts.templatelibsqlwith remote Turso URL →drizzle.config.turso.ts.template+client.libsql.ts.templatebun-sqlite→drizzle.config.local.ts.template+client.bun-sqlite.ts.template
4. Render and write the project-init files:
drizzle.config.ts{{client_path}}(typicallysrc/db/client.ts){{schema_dir}}/index.ts(empty barrel)- Append the
gitignore.templateblock to the project's.gitignore
5. For libsql: the client template uses top-level await migrate(...). Verify tsconfig.json has "module": "ESNext" (or "NodeNext") and "target": "ES2022"+ for top-level await support. If the runtime is CommonJS, replace the top-level await with an exported async function init() the app calls during startup.
6. Save resolved values to `config.json` so subsequent table runs don't re-prompt.
Flow B — Add a new table (run per table)
1. Resolve per-table parameters. Ask the user for name, then offer defaults for table_name (snake_case plural), pk, timestamps, soft_delete, relations, with_zod. Use AskUserQuestion for any non-default the user wants.
2. Compute derived identifiers:
name_camel— camelCase ofname(user,orderItem)name_pascal— PascalCase ofname(User,OrderItem)pk_field— the PK column name (idfor all 4pkmodes)pk_ts_type— TS type for the PK (numberforserial-int,stringforuuid/cuid2/text)pk_definition— the actual line, e.g.,id: integer().primaryKey({ autoIncrement: true }),(see PK Variants table below)
3. Render the table template: Read table.ts.template, substitute {{name}}, {{name_camel}}, {{name_pascal}}, {{table_name}}, {{pk_definition}}, {{pk_extra_imports}}, etc. Expand {{timestamps_block}} and {{soft_delete_block}} per the parameters (see "Block Expansions" below). Write to {{schema_dir}}/{{name}}.ts.
4. Render the repository template with the same parameters. Write to {{repository_dir}}/{{name}}.ts.
5. If `with_zod=true`, render the validators template. Write to {{validators_dir}}/{{name}}.ts.
6. Append to the schema barrel: Add export * from './{{name}}'; to {{schema_dir}}/index.ts.
7. Generate the migration: Tell the user to run npx drizzle-kit generate to produce the SQL file. Remind them to answer rename prompts explicitly if this scaffold replaces an existing differently-named table.
8. Apply the migration: Run npx drizzle-kit migrate against the dev database. The client templates also call migrate(...) on boot, but applying once in the dev loop confirms the SQL works before the next process restart.
Flow C — Add a CRUD module for an existing table (no schema change)
Same as Flow B steps 1-2, but skip the table.ts.template render and just emit the repository (and optionally validators) modules.
PK Variants
pk value | pk_definition | pk_ts_type | Extra imports |
|---|---|---|---|
serial-int (default) | id: integer().primaryKey({ autoIncrement: true }), | number | — |
uuid | id: text().primaryKey().$defaultFn(() => crypto.randomUUID()), | string | — (uses Web Crypto) |
cuid2 | id: text().primaryKey().$defaultFn(() => createId()), | string | import { createId } from '@paralleldrive/cuid2'; |
text | id: text().primaryKey(), | string | — (caller supplies the ID) |
Placeholder Reference
Every {{placeholder}} the templates use, with its derivation rule. Items marked simple sub are find-and-replace; items marked block require the agent to expand per the rules in the next section.
| Placeholder | Type | Source / derivation |
|---|---|---|
{{driver}} | simple sub | config.json:driver |
{{db_url_env}} | simple sub | config.json:db_url_env |
{{schema_dir}} | simple sub | config.json:schema_dir |
{{repository_dir}} | simple sub | config.json:repository_dir |
{{validators_dir}} | simple sub | config.json:validators_dir |
{{client_path}} | simple sub | config.json:client_path |
{{migrations_dir}} | simple sub | config.json:migrations_dir |
{{schema_index_import}} | simple sub | derived: client_path → relative path to {{schema_dir}}/index.ts (typically './schema') |
{{client_import}} | simple sub | derived: from a repository file, relative path back to client_path (typically '../client') |
{{schema_import}} | simple sub | derived: from a repository or validators file, relative path to the matching table file (typically '../schema/{{name}}') |
{{name}} | simple sub | per-table param — kebab-case singular (user) |
{{name_camel}} | simple sub | derived from name (user, orderItem) |
{{name_pascal}} | simple sub | derived from name (User, OrderItem) |
{{table_name}} | simple sub | per-table param, default = snake_case plural of name (users, order_items) |
{{pk}} | metadata | per-table param — serial-int \ |
{{pk_definition}} | block | the actual PK column line — see "PK Variants" table |
{{pk_field}} | simple sub | always id for the four PK variants this skill ships |
{{pk_ts_type}} | simple sub | number for serial-int, string for uuid / cuid2 / text |
{{pk_extra_imports}} | block | empty for serial-int / uuid / text; import { createId } from '@paralleldrive/cuid2'; for cuid2 |
{{relation_imports}} | block | for each table in relations[], emit import { {{relatedCamel}} } from './{{related-kebab}}'; |
{{domain_columns}} | block | the agent (or user) replaces this with the actual non-PK, non-timestamp columns for the entity. Leave as a TODO comment if the user hasn't provided them yet. |
{{timestamps_block}} | block | see expansion below — emit when timestamps=true, remove the line entirely when false |
{{soft_delete_block}} | block | see expansion below — emit in the columns block when soft_delete=true |
{{indexes}} | block | one index(...) line per foreign key column (and any composite (authorId, publishedAt)-style indexes the user wants) |
{{soft_delete_index}} | block | the partial index on deletedAt (see expansion below); emit only when soft_delete=true |
{{relations_body}} | block | one one(...) / many(...) line per related table; see expansion below |
{{insert_refinements}} | block | drizzle-zod refinement callbacks for the INSERT shape; leave the example comment if none provided |
{{update_refinements}} | block | same for the partial UPDATE shape |
{{exports}} | block | inside schema-index.ts.template: one export * from './{{name}}'; line per table; append on each new-table run |
If a template emits a {{placeholder}} not in this table, that's a bug — file it under gotchas.md.
Block Expansions
The table.ts.template uses placeholder blocks for variable-shaped sections — the agent must expand them per parameters, not just text-substitute.
{{timestamps_block}} (when timestamps=true)
createdAt: integer({ mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer({ mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
.$onUpdateFn(() => new Date()),When timestamps=false, remove the line entirely (don't leave the comment marker).
{{soft_delete_block}} (when soft_delete=true)
In the columns block:
deletedAt: integer({ mode: 'timestamp_ms' }),In the indexes block:
index('{{table_name}}_active_idx').on(table.deletedAt).where(sql`deleted_at IS NULL`),(Adjust the sql import accordingly.)
{{relations_body}} (when relations is non-empty)
For each related table in relations[], the agent decides whether it's a one or many based on whether the FK lives on the current table (then it's one) or on the related table (then it's many):
// FK on current table — `one`:
parent: one(parents, { fields: [users.parentId], references: [parents.id] }),
// FK on related table — `many`:
posts: many(posts),If the user can't easily tell, default to one example of each and leave a comment.
{{indexes}} block
For each FK column, emit:
index('{{table_name}}_{{column}}_idx').on(table.{{columnCamel}}),Cloudflare D1 note
D1 has a different lifecycle: the client is constructed per request from env.DB (the binding), not as a module-level singleton. This skill doesn't ship a D1 client template — follow the official D1 + Drizzle guide for the wiring. The table.ts.template, repository.ts.template, and validators.ts.template are still usable with D1 — only the client module differs.
Reference Files
| File | Description |
|---|---|
| references/conventions.md | The 11 conventions enforced, with WHY and rule cross-references |
| gotchas.md | Edge cases discovered over time |
| metadata.json | Version + driver references |
| config.json | Project-level parameter store |
Related Skills
- [`drizzle-sqlite`](../drizzle-sqlite/SKILL.md) — The library-reference rules these templates encode. The conventions doc cites specific rule filenames from it. Read it when you need to make an informed exception, debug a generated file, or scaffold something outside the templates' scope (custom migrations, complex queries, performance work).
- [`better-auth-scaffold`](../better-auth-scaffold/SKILL.md) — Scaffolds Better Auth on top of a Drizzle DB; can be run after this skill provides the client.
// {{client_path}} — singleton Drizzle client for better-sqlite3 (sync, Node)
// Generated by drizzle-sqlite-scaffold. Substitute {{placeholders}}.
//
// Enforces: WAL mode, foreign_keys=ON, busy_timeout, HMR-safe singleton.
// See drizzle-sqlite rules: conn-enable-wal, conn-foreign-keys-pragma,
// conn-set-busy-timeout, conn-singleton-client.
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import * as schema from '{{schema_index_import}}';
declare global {
// eslint-disable-next-line no-var
var __sqlite__: Database.Database | undefined;
}
function initSqlite(): Database.Database {
const sqlite = new Database(process.env.{{db_url_env}} ?? './local.db');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('synchronous = NORMAL');
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('busy_timeout = 5000');
return sqlite;
}
const sqlite = globalThis.__sqlite__ ?? initSqlite();
if (process.env.NODE_ENV !== 'production') {
globalThis.__sqlite__ = sqlite;
}
export const db = drizzle(sqlite, { schema });
export const rawSqlite = sqlite;
// Apply migrations on boot. For multi-instance deploys, prefer a dedicated
// pre-deploy job and remove this line.
migrate(db, { migrationsFolder: '{{migrations_dir}}' });
// {{client_path}} — singleton Drizzle client for bun:sqlite (sync, Bun runtime)
// Generated by drizzle-sqlite-scaffold. Substitute {{placeholders}}.
//
// Enforces: WAL mode, foreign_keys=ON, busy_timeout.
// See drizzle-sqlite rules: conn-enable-wal, conn-foreign-keys-pragma,
// conn-set-busy-timeout, conn-singleton-client.
import { Database } from 'bun:sqlite';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
import * as schema from '{{schema_index_import}}';
declare global {
// eslint-disable-next-line no-var
var __sqlite__: Database | undefined;
}
function initSqlite(): Database {
const sqlite = new Database(process.env.{{db_url_env}} ?? './local.db');
sqlite.exec('PRAGMA journal_mode = WAL;');
sqlite.exec('PRAGMA synchronous = NORMAL;');
sqlite.exec('PRAGMA foreign_keys = ON;');
sqlite.exec('PRAGMA busy_timeout = 5000;');
return sqlite;
}
const sqlite = globalThis.__sqlite__ ?? initSqlite();
if (process.env.NODE_ENV !== 'production') {
globalThis.__sqlite__ = sqlite;
}
export const db = drizzle(sqlite, { schema });
export const rawSqlite = sqlite;
migrate(db, { migrationsFolder: '{{migrations_dir}}' });
// {{client_path}} — singleton Drizzle client for libsql (Turso or local `file:`)
// Generated by drizzle-sqlite-scaffold. Substitute {{placeholders}}.
//
// libsql enables foreign keys by default. For local `file:` URLs, WAL is also
// the default. Remote Turso manages journaling server-side.
//
// See drizzle-sqlite rules: conn-singleton-client, conn-pick-driver-deliberately.
import { createClient, type Client } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { migrate } from 'drizzle-orm/libsql/migrator';
import * as schema from '{{schema_index_import}}';
declare global {
// eslint-disable-next-line no-var
var __libsql__: Client | undefined;
}
function initClient(): Client {
return createClient({
url: process.env.{{db_url_env}} ?? 'file:local.db',
authToken: process.env.{{db_url_env}}_AUTH_TOKEN,
});
}
const client = globalThis.__libsql__ ?? initClient();
if (process.env.NODE_ENV !== 'production') {
globalThis.__libsql__ = client;
}
export const db = drizzle(client, { schema });
export const rawClient = client;
// Apply migrations on boot. For multi-instance deploys, prefer a dedicated
// pre-deploy job and remove this top-level await.
await migrate(db, { migrationsFolder: '{{migrations_dir}}' });
// drizzle.config.ts — local SQLite (better-sqlite3, bun:sqlite, libsql `file:` URL)
// Generated by drizzle-sqlite-scaffold. Substitute {{placeholders}}.
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'sqlite',
schema: '{{schema_dir}}/*.ts',
out: '{{migrations_dir}}',
dbCredentials: {
url: process.env.{{db_url_env}} ?? 'file:local.db',
},
casing: 'snake_case',
verbose: true,
strict: true,
});
// drizzle.config.ts — libsql / Turso (remote)
// Generated by drizzle-sqlite-scaffold. Substitute {{placeholders}}.
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'turso',
schema: '{{schema_dir}}/*.ts',
out: '{{migrations_dir}}',
dbCredentials: {
url: process.env.{{db_url_env}}!,
authToken: process.env.{{db_url_env}}_AUTH_TOKEN!,
},
casing: 'snake_case',
verbose: true,
strict: true,
});
# Drizzle SQLite — gitignore patch
# Append these lines to your project's .gitignore.
# Generated by drizzle-sqlite-scaffold.
#
# DO commit:
# - {{migrations_dir}}/*.sql (the migrations themselves)
# - {{migrations_dir}}/meta/ (snapshots; required for the next generate)
# - drizzle.config.ts (so generate/migrate work without flags)
#
# See drizzle-sqlite rule migrate-commit-migrations-to-git for why.
# Local SQLite database files — never commit
*.db
*.db-journal
*.db-wal
*.db-shm
local.db*
# Drizzle Studio cache
.drizzle-studio/
// {{repository_dir}}/{{name}}.ts — CRUD repository for `{{table_name}}`
// Generated by drizzle-sqlite-scaffold. Substitute {{placeholders}}.
//
// Conventions baked in (see references/conventions.md):
// - `.returning()` on every write — no second SELECT round trip
// - `inArray()` for batch reads — no N+1
// - `.onConflictDoUpdate()` for upserts — atomic, no select-then-write race
// - Prepared statement for the by-id read (compile once, reuse)
// - Pagination is keyset, not offset
import { desc, eq, inArray, lt } from 'drizzle-orm';
import type { SQLiteColumn } from 'drizzle-orm/sqlite-core';
import { db } from '{{client_import}}';
import {
{{name_camel}},
type {{name_pascal}},
type New{{name_pascal}},
} from '{{schema_import}}';
// ---------------------------------------------------------------------------
// Reads
// ---------------------------------------------------------------------------
//
// For hot-path reads called on every request, promote `findById` to a
// prepared statement following drizzle-sqlite rule perf-prepare-hot-paths.
// Prepared statements are driver-specific (sync .get() on better-sqlite3 /
// bun:sqlite; async on libsql), so they're not in the default template.
export async function findById(id: {{pk_ts_type}}): Promise<{{name_pascal}} | undefined> {
const rows = await db
.select()
.from({{name_camel}})
.where(eq({{name_camel}}.{{pk_field}}, id))
.limit(1);
return rows[0];
}
export async function findManyByIds(ids: {{pk_ts_type}}[]): Promise<{{name_pascal}}[]> {
if (ids.length === 0) return [];
// Single round trip; see drizzle-sqlite rule query-avoid-n-plus-one-with-inarray.
return db.select().from({{name_camel}}).where(inArray({{name_camel}}.{{pk_field}}, ids));
}
export interface ListParams {
cursor?: {{pk_ts_type}};
pageSize?: number;
}
export async function list({ cursor, pageSize = 20 }: ListParams = {}): Promise<{
rows: {{name_pascal}}[];
nextCursor: {{pk_ts_type}} | null;
}> {
// Keyset pagination — constant cost regardless of page depth.
// See drizzle-sqlite rule perf-keyset-not-offset-for-deep-pages.
const rows = await db
.select()
.from({{name_camel}})
.where(cursor ? lt({{name_camel}}.{{pk_field}}, cursor) : undefined)
.orderBy(desc({{name_camel}}.{{pk_field}}))
.limit(pageSize + 1);
const hasMore = rows.length > pageSize;
const trimmed = hasMore ? rows.slice(0, pageSize) : rows;
const nextCursor = hasMore ? trimmed[trimmed.length - 1].{{pk_field}} : null;
return { rows: trimmed, nextCursor };
}
// ---------------------------------------------------------------------------
// Writes
// ---------------------------------------------------------------------------
// Every write uses `.returning()` so the caller gets the inserted/updated row
// without a second round trip. See drizzle-sqlite rule
// query-returning-instead-of-reselect.
export async function create(input: New{{name_pascal}}): Promise<{{name_pascal}}> {
const [row] = await db.insert({{name_camel}}).values(input).returning();
return row;
}
export async function createMany(inputs: New{{name_pascal}}[]): Promise<{{name_pascal}}[]> {
if (inputs.length === 0) return [];
// Multi-row VALUES — one statement, one plan, one commit.
// See drizzle-sqlite rule perf-bulk-insert-multi-row-values.
return db.insert({{name_camel}}).values(inputs).returning();
}
export interface UpsertParams {
/** Drizzle column reference(s) — pass `{{name_camel}}.email` for a single
* unique column, or `[{{name_camel}}.a, {{name_camel}}.b]` for a composite key. */
target: SQLiteColumn | SQLiteColumn[];
/** Columns to update when a conflict occurs. */
set: Partial<New{{name_pascal}}>;
}
export async function upsert(
input: New{{name_pascal}},
{ target, set }: UpsertParams,
): Promise<{{name_pascal}}> {
// Atomic — no select-then-write race. See drizzle-sqlite rule query-upsert-with-onconflict.
// Callers pass the column reference directly:
// upsert({ email, name }, { target: {{name_camel}}.email, set: { name } });
const [row] = await db
.insert({{name_camel}})
.values(input)
.onConflictDoUpdate({ target, set })
.returning();
return row;
}
export async function update(
id: {{pk_ts_type}},
changes: Partial<New{{name_pascal}}>,
): Promise<{{name_pascal}} | undefined> {
const [row] = await db
.update({{name_camel}})
.set(changes)
.where(eq({{name_camel}}.{{pk_field}}, id))
.returning();
return row;
}
export async function remove(id: {{pk_ts_type}}): Promise<{{name_pascal}} | undefined> {
// Hard delete. For soft delete (deletedAt column), call update({ deletedAt: new Date() }).
const [row] = await db
.delete({{name_camel}})
.where(eq({{name_camel}}.{{pk_field}}, id))
.returning();
return row;
}
// ---------------------------------------------------------------------------
// Transactional helpers
// ---------------------------------------------------------------------------
// Wrap multi-statement writes in db.transaction() — see drizzle-sqlite rule
// tx-wrap-multi-statement-writes. Use behavior: 'immediate' for read-then-write
// flows so contention surfaces at BEGIN, not mid-tx.
export function withTransaction<T>(
fn: (tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) => Promise<T>,
): Promise<T> {
return db.transaction(fn, { behavior: 'immediate' });
}
// {{schema_dir}}/index.ts — barrel re-export so drizzle({ schema }) sees every table
// Generated by drizzle-sqlite-scaffold.
//
// Append a new line for each table you add via the `table.ts.template` flow:
// export * from './<table-name>';
//
// The barrel lets the client construct with `drizzle(driver, { schema })` and
// pick up every table + relations automatically. See drizzle-sqlite rule
// rel-declare-relations-for-rqb for why this matters for `db.query.*`.
// {{exports}}
// {{schema_dir}}/{{name}}.ts — schema for the `{{table_name}}` table
// Generated by drizzle-sqlite-scaffold. Substitute {{placeholders}}.
//
// Conventions baked in (see references/conventions.md):
// - Explicit primary key — never relies on rowid
// - Timestamps as integer({ mode: 'timestamp_ms' }) with $defaultFn
// - Booleans as integer({ mode: 'boolean' })
// - Every foreign key has explicit onDelete/onUpdate
// - Every foreign key column is indexed
// - relations() declared when foreign keys exist
// - $inferSelect / $inferInsert types exported
import { relations } from 'drizzle-orm';
import {
index,
integer,
sqliteTable,
text,
// Uncomment when you need them — leaving unused imports breaks strict TS:
// primaryKey, // for composite-PK join tables — primaryKey({ columns: [a, b] })
// uniqueIndex, // for case-insensitive unique indexes, expression unique indexes
// blob, // for binary columns
} from 'drizzle-orm/sqlite-core';
// {{pk_extra_imports}} // e.g., `import { createId } from '@paralleldrive/cuid2';`
// {{relation_imports}} // e.g., `import { posts } from './post';`
// ---------------------------------------------------------------------------
// Table
// ---------------------------------------------------------------------------
export const {{name_camel}} = sqliteTable(
'{{table_name}}',
{
// Primary key — {{pk}}
{{pk_definition}}
// Domain columns — replace these with your own; the boilerplate above and
// below stays the same.
// Example shapes that follow the conventions:
//
// name: text().notNull(),
// email: text().notNull().unique(),
// active: integer({ mode: 'boolean' }).notNull().default(true),
// bio: text(),
// settings: text({ mode: 'json' }).$type<Settings>(),
//
// For a foreign key, use:
//
// parentId: integer()
// .notNull()
// .references(() => parents.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
{{domain_columns}}
// {{timestamps_block}}
// ↑ When timestamps=true, the agent expands this to:
// createdAt: integer({ mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()),
// updatedAt: integer({ mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()).$onUpdateFn(() => new Date()),
// {{soft_delete_block}}
// ↑ When soft_delete=true, the agent expands this to:
// deletedAt: integer({ mode: 'timestamp_ms' }),
},
(table) => [
// Index every foreign key column. Add composite indexes for hot WHERE+ORDER BY
// patterns. See drizzle-sqlite rules schema-index-foreign-keys-and-lookups
// and perf-covering-index-for-hot-queries.
{{indexes}}
// {{soft_delete_index}}
// ↑ When soft_delete=true, the agent appends:
// index('{{table_name}}_active_idx').on(table.deletedAt).where(sql`deleted_at IS NULL`),
],
);
// ---------------------------------------------------------------------------
// Relations
// ---------------------------------------------------------------------------
// Declared in this file (not the related table's file) so the relations object
// stays next to its column definitions. See drizzle-sqlite rule
// rel-declare-relations-for-rqb.
export const {{name_camel}}Relations = relations({{name_camel}}, ({ one, many }) => ({
{{relations_body}}
}));
// ---------------------------------------------------------------------------
// Inferred types
// ---------------------------------------------------------------------------
// Single source of truth — never hand-write parallel types. See
// drizzle-sqlite rule types-infer-select-insert.
export type {{name_pascal}} = typeof {{name_camel}}.$inferSelect;
export type New{{name_pascal}} = typeof {{name_camel}}.$inferInsert;
// {{validators_dir}}/{{name}}.ts — drizzle-zod validators for `{{table_name}}`
// Generated by drizzle-sqlite-scaffold. Substitute {{placeholders}}.
//
// Conventions baked in (see references/conventions.md):
// - Validators derived from the Drizzle schema — no hand-written parallel types
// - createInsertSchema / createUpdateSchema / createSelectSchema covered
// - Tighten constraints (email format, length, refinement) inside the
// refinement callback so the validator can be both stricter and aligned.
//
// See drizzle-sqlite rule types-drizzle-zod-for-runtime-validation.
import {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
} from 'drizzle-zod';
import { z } from 'zod';
import { {{name_camel}} } from '{{schema_import}}';
// INSERT — used for POST endpoints, server actions, queue payloads.
export const new{{name_pascal}}Schema = createInsertSchema({{name_camel}}, {
// Replace these example refinements with the real constraints for this table:
//
// email: (s) => s.email().toLowerCase(),
// name: (s) => s.min(1).max(120),
// bio: (s) => s.max(2000).nullish(),
//
{{insert_refinements}}
});
export type New{{name_pascal}}Input = z.infer<typeof new{{name_pascal}}Schema>;
// PARTIAL UPDATE — used for PATCH endpoints.
export const update{{name_pascal}}Schema = createUpdateSchema({{name_camel}}, {
{{update_refinements}}
});
export type Update{{name_pascal}}Input = z.infer<typeof update{{name_pascal}}Schema>;
// SELECT — useful when consuming an API response that should match the row shape.
export const {{name_camel}}Schema = createSelectSchema({{name_camel}});
export type {{name_pascal}}Output = z.infer<typeof {{name_camel}}Schema>;
{
"driver": "",
"db_url_env": "DATABASE_URL",
"schema_dir": "./src/db/schema",
"repository_dir": "./src/db/repository",
"validators_dir": "./src/db/validators",
"client_path": "./src/db/client.ts",
"migrations_dir": "./drizzle",
"_setup_instructions": {
"driver": "Which SQLite driver this project uses. One of: 'better-sqlite3' (sync, Node), 'libsql' (async, Turso or local file), 'bun-sqlite' (sync, Bun runtime), 'd1' (async, Cloudflare Workers).",
"db_url_env": "Environment variable holding the database URL. Defaults to DATABASE_URL.",
"schema_dir": "Where per-table schema files are emitted. Each table becomes {schema_dir}/{name}.ts.",
"repository_dir": "Where per-table repository (CRUD) modules are emitted.",
"validators_dir": "Where drizzle-zod validators are emitted (only used when with_zod=true).",
"client_path": "Path to the singleton Drizzle client module.",
"migrations_dir": "drizzle-kit output folder. Both SQL files and meta/ snapshots must be committed."
}
}
Gotchas
Accumulate template edge cases here as you find them — append-only, with dates.
No known gotchas yet
{
"version": "1.0.3",
"organization": "dot-skills",
"technology": "Drizzle ORM + SQLite",
"discipline": "extraction",
"type": "scaffolding",
"date": "May 2026",
"abstract": "Parameterized templates for scaffolding Drizzle ORM + SQLite boilerplate that bakes in the 45 rules from the `drizzle-sqlite` skill. Produces a drizzle.config.ts + singleton client with pragmas + per-table schema/repository/validator modules — every output has explicit primary keys, indexed foreign keys, declared relations(), boolean/timestamp_ms column modes, and CRUD helpers that use `.returning()`, `inArray()`, and `.onConflictDoUpdate()` by default.",
"references": [
"https://orm.drizzle.team/docs/get-started-sqlite",
"https://orm.drizzle.team/docs/sql-schema-declaration",
"https://orm.drizzle.team/docs/relations",
"https://orm.drizzle.team/docs/drizzle-config-file",
"https://orm.drizzle.team/docs/zod"
]
}
Conventions Enforced by drizzle-sqlite-scaffold
The templates encode 11 conventions. Each one corresponds to one or more rules in the `drizzle-sqlite` skill — read those when an exception is required so you understand the cascade effect you're trading off.
---
1. Files kebab-case, tables snake_case, columns camelCase in TS
Files: src/db/schema/user.ts. Table identifier in TS: user. SQL table name: users. Column identifier in TS: createdAt. Column name in SQL: created_at.
The bridge is casing: 'snake_case' in drizzle.config.ts — Drizzle converts camelCase TS identifiers to snake_case SQL automatically without per-column .column('snake_name') calls.
Why: Cross-OS filesystem case sensitivity for files (Mac/Windows are case-insensitive, Linux isn't — kebab-case avoids the trap). Idiomatic TS for identifiers. Idiomatic SQL for table/column names (most query tools and ORMs assume snake_case). One central knob means renaming a TS column ports automatically to its SQL name.
Related rule: `migrate-config-dialect-and-out`
---
2. Every table has an explicit primary key
The table.ts.template always emits a PK definition — single-column for entities, composite for join tables. The supported pk modes are:
serial-int→id: integer().primaryKey({ autoIncrement: true })uuid→id: text().primaryKey().$defaultFn(() => crypto.randomUUID())cuid2→id: text().primaryKey().$defaultFn(() => createId())(requires@paralleldrive/cuid2)text→id: text().primaryKey()(caller-supplied)
For join tables, the template uses primaryKey({ columns: [...] }) to declare the composite key.
Why: SQLite still has a hidden rowid for tables without a PK, but it can be reassigned by VACUUM, can't be referenced by foreign keys, and .onConflictDoUpdate() has no target to act on.
Related rule: `schema-always-primary-key`
---
3. Timestamps are integer({ mode: 'timestamp_ms' }) with $defaultFn
When timestamps: true (the default), the template emits:
createdAt: integer({ mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer({ mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
.$onUpdateFn(() => new Date()),$onUpdateFn runs on every .update() so updatedAt stays current without caller help.
Why: SQLite stores text dates lexicographically — one stray ISO offset breaks ORDER BY. Epoch milliseconds index correctly, range queries are integer comparisons, Drizzle returns Date objects so app code never sees the storage format.
Related rule: `schema-timestamp-mode-for-dates`
---
4. Booleans are integer({ mode: 'boolean' })
Never raw integer() for true/false flags. The template flags this with a code comment in the column-definitions block.
Why: Raw integer() infers as number and leaks 0 | 1 into every consumer, breaking === true checks and forcing manual conversions.
Related rule: `schema-integer-for-booleans`
---
5. Every foreign key has explicit onDelete and onUpdate
The template's column-definition block includes an example FK with { onDelete: 'cascade', onUpdate: 'cascade' } for users to copy.
parentId: integer()
.notNull()
.references(() => parents.id, { onDelete: 'cascade', onUpdate: 'cascade' }),Why: Without these, SQLite defaults to NO ACTION — and FK enforcement is only active when PRAGMA foreign_keys = ON is set on the connection (off by default in stock SQLite). The combination silently orphans rows.
Related rules: `schema-foreign-keys-with-actions`, `conn-foreign-keys-pragma`
---
6. Every foreign key column is indexed
The template's indexes block reserves a line per FK column:
(table) => [
index('{{table_name}}_parent_idx').on(table.parentId),
]Why: SQLite does not auto-index foreign-key columns (unlike MySQL/InnoDB). Every WHERE parentId = ? is a full table scan without an explicit index.
Related rule: `schema-index-foreign-keys-and-lookups`
---
7. relations() declared whenever a foreign key exists
Each table file ends with a relations(table, ({ one, many }) => ({ ... })) block. The block stays next to its column definitions so renaming the table or its columns updates the relation declarations in one place.
Why: Without relations(), db.query.tableName.findMany({ with: { ... } }) is not typed and fails at runtime. The relational query builder is one of Drizzle's biggest ergonomic wins; unlock it by default.
Related rule: `rel-declare-relations-for-rqb`
---
8. Repository helpers use .returning(), inArray(), .onConflictDoUpdate()
The repository.ts.template ships pre-wired CRUD methods that follow the query rules:
create(input)—db.insert(...).values(...).returning()(one round trip)createMany(inputs)— multi-rowvalues([...])(one statement)findById(id)— prepared statement withsql.placeholder('id')(one compile)findManyByIds(ids)—inArray(table.id, ids)(one statement)upsert(input, { target, set })—.onConflictDoUpdate(...)(atomic, race-free)list({ cursor, pageSize })— keyset pagination withlt(...)(constant cost)update(id, changes)/remove(id)— both with.returning()
Why: These patterns each fix a specific SQLite/Drizzle anti-pattern (N+1, select-then-write race, OFFSET degradation, repeated SQL compilation). Generating them by default means developers don't need to remember to reach for the right tool on each new entity.
Related rules: `query-returning-instead-of-reselect`, `query-upsert-with-onconflict`, `query-avoid-n-plus-one-with-inarray`, `perf-prepare-hot-paths`, `perf-keyset-not-offset-for-deep-pages`, `perf-bulk-insert-multi-row-values`
---
9. The client sets WAL + foreign_keys=ON + busy_timeout=5000 per connection
The client.*.template family applies these pragmas immediately after the SQLite handle is opened. Pragmas are per-connection — no shortcut, no skipping.
Why: Default journal_mode=DELETE blocks readers on writers (tail-latency spikes correlate with write traffic). Default foreign_keys=OFF makes every FK declaration in the schema purely decorative. Default busy_timeout=0 makes every transient lock contention surface as an error.
Related rules: `conn-enable-wal`, `conn-foreign-keys-pragma`, `conn-set-busy-timeout`
(libsql / Turso has FK on by default and manages journaling itself; the libsql client template omits redundant pragmas accordingly.)
---
10. The client is a globalThis-guarded module-scope singleton
const sqlite = globalThis.__sqlite__ ?? initSqlite();
if (process.env.NODE_ENV !== 'production') {
globalThis.__sqlite__ = sqlite;
}Why: Constructing a new client per request leaks file descriptors and discards the prepared-statement cache. The globalThis guard also prevents dev-server HMR (Next.js, Vite, Bun) from leaking new connections on every hot reload.
Related rule: `conn-singleton-client`
---
11. Migrations: drizzle-kit generate → review → drizzle-kit migrate
The drizzle.config.*.template is set up so:
npx drizzle-kit generatewrites SQL files into{{migrations_dir}}/npx drizzle-kit migrateapplies unapplied files- The client templates also call
migrate(db, { migrationsFolder: '{{migrations_dir}}' })on boot for serverless/container deploys
Never drizzle-kit push against production. The gitignore.template explicitly keeps {{migrations_dir}}/meta/ tracked so the next generate can compute correct diffs.
Why: push diffs against the live DB without an SQL artifact for review and can't tell renames from drop+add — destructive against production. The meta/ snapshots are how the next generate computes the diff; gitignoring them produces "recreate all tables" SQL.
Related rules: `migrate-generate-not-push-in-prod`, `migrate-commit-migrations-to-git`, `migrate-apply-with-migrator`
---
When to break a convention
These conventions are defaults, not laws. Reach for an exception when:
- Schema convention — you're migrating an existing database with an existing shape. Match the existing convention rather than rewriting; the `drizzle-sqlite` rules still apply.
- Repository convention — you have a query pattern that doesn't fit
findById/list/upsert(e.g., a cross-table aggregate). Write a custom function alongside the generated repository; don't bendlistto do something it wasn't designed for. - Client convention — you're targeting Cloudflare D1, where the client is per-request (instantiated from
env.DBinside the handler) and the singleton pattern doesn't apply. Follow the Cloudflare D1 + Drizzle guide for the right shape.
When you do break a convention, leave a comment naming the rule you're trading off. Future readers (human and AI) will thank you.
Related skills
FAQ
What does drizzle-sqlite-scaffold do?
drizzle-sqlite-scaffold is a Claude Code skill for databases. It helps developers move faster with AI-assisted coding.
When should I use drizzle-sqlite-scaffold?
When you need to helps with databases tasks during ai-assisted development, or when drizzle-sqlite-scaffold is a claude code skill for databases. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
drizzle-sqlite-scaffold; Databases; AI-coding skill.