Getting started
d1-record turns each table in your D1 database into a Model, so your Worker works with records instead of rows. In this tutorial you’ll build the start of a small blog: users, and the posts they write.
Version 0.x
d1-record is before 1.0. Its behavior is tested against real D1, but the API may still change. A breaking change ships in a new minor version, so ^0.1.0 never takes one.
Prerequisites
You’ll need:
A Worker project with Wrangler.
npm create cloudflare@latestmakes one.A D1 database bound as
DB. Create one withnpx wrangler d1 create my-app-db, then add the snippet it prints to your Wrangler config:jsonc{ "d1_databases": [ { "binding": "DB", "database_name": "my-app-db", "database_id": "<the id it printed>" }, ], }Node.js 22.13 or later, for the command line.
Installing d1-record
npm install @forestfuture/d1-recordGenerating the Models
The generator writes a table’s migration and its Model in one step, like rails generate model. Generate users first, since each post belongs to a user:
npx d1-record g model User 'email:string!:unique' 'isActive:boolean!=true'
npx d1-record g model Post user:references 'title:string!' publishedAt:datetimeEach column is name:type. A ! makes it required, =true sets a default, :unique adds a unique index, and user:references links each post to a user. The quotes stop your shell from reading the !.
The generator writes these files:
| File | What it is |
|---|---|
migrations/0001_create_users.sql, 0002_create_posts.sql | The SQL that creates each table |
src/db/schema.ts | Every table’s columns, generated from the migrations. Don’t edit it. |
src/models/application-record.ts | Your app’s base class. It’s yours to edit. |
src/models/user.ts, post.ts | One Model per table |
Here’s the migration for posts:
CREATE TABLE posts (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users (id),
title TEXT NOT NULL,
published_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX index_posts_on_user_id ON posts (user_id);Compared with Rails
Each id is a UUID generated in code, not an auto-incrementing integer, so a user and their posts save together atomically. Generators covers integer keys.
Creating the tables
Apply the migrations to your local database:
npx wrangler d1 migrations apply my-app-db --localUse --remote when you deploy.
Adding behavior
A Model’s attributes come from the generated schema, so a Model only declares behavior. The generated Post already belongs to a user:
import { belongsTo } from "@forestfuture/d1-record";
import { ApplicationRecord } from "./application-record";
import { User } from "./user";
export class Post extends ApplicationRecord("posts") {
user = belongsTo(User);
}The generated User is one line:
import { ApplicationRecord } from "./application-record";
export class User extends ApplicationRecord("users") {}Give it posts, a scope for active users, and rules for saving:
import { hasMany } from "@forestfuture/d1-record";
import { ApplicationRecord } from "./application-record";
import { Post } from "./post";
export class User extends ApplicationRecord("users") {
posts = hasMany(Post, { dependent: "destroy" }); // posts.user_id points here
static active = this.scope((q) => q.where({ isActive: true }));
static {
this.validates("email", { presence: true, uniqueness: true });
this.beforeSave((user) => {
user.email = user.email.toLowerCase();
});
}
}user.posts()finds the user’s posts, anddependent: "destroy"destroys them with the user.User.active()finds active users.validatesandbeforeSaverun on every save. An invalid user isn’t saved, anduser.errorssays why.
Using the Models
Queries start from the Model. Each Model uses the DB binding, so there’s nothing to connect:
import { User } from "./models/user";
export default {
async fetch(request: Request): Promise<Response> {
// GET: the 20 newest active users, each with their posts.
if (request.method === "GET") {
const users = await User.active()
.includes("posts") // loaded in one extra query, not one per user
.order({ createdAt: "desc" })
.limit(20)
.toArray();
return Response.json(users);
}
// POST: sign up a user with a first post, saved together.
const { email } = await request.json<{ email: string }>();
const user = User.build({ email });
user.posts().build({ title: "Hello, D1" });
// save() validates and runs callbacks; false means it wasn't saved, and why is in errors.
if (!(await user.save())) {
return Response.json({ errors: user.errors.fullMessages() }, { status: 422 });
}
return Response.json(user, { status: 201 });
},
} satisfies ExportedHandler<Env>;includes("posts")loads every user’s posts in one more query.- A post built with
user.posts().build(…)is saved with its user, in one atomic batch. save()resolves tofalsewhen validation fails.
Trying it
Start the Worker:
npx wrangler devIn another terminal, sign up a user, then list the users:
curl -X POST http://localhost:8787 -d '{"email":"Ada@Example.com"}'
curl http://localhost:8787The list includes Ada, with her email lowercased by beforeSave. Sign her up again, and you’ll get a 422 with ["Email has already been taken"].
Next steps
- Models: attributes, types, and overrides.
- Querying: conditions, ordering, scopes, and preloading.
- Persistence: saving and destroying records.
- Associations:
hasMany,belongsTo, andhasOne. - Generators: every way to write columns.
- Bulk writes and batches: writing several changes at once.