Skip to content

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@latest makes one.

  • A D1 database bound as DB. Create one with npx 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 ​

sh
npm install @forestfuture/d1-record

Generating 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:

sh
npx d1-record g model User 'email:string!:unique' 'isActive:boolean!=true'
npx d1-record g model Post user:references 'title:string!' publishedAt:datetime

Each 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:

FileWhat it is
migrations/0001_create_users.sql, 0002_create_posts.sqlThe SQL that creates each table
src/db/schema.tsEvery table’s columns, generated from the migrations. Don’t edit it.
src/models/application-record.tsYour app’s base class. It’s yours to edit.
src/models/user.ts, post.tsOne Model per table

Here’s the migration for posts:

sql
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:

sh
npx wrangler d1 migrations apply my-app-db --local

Use --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:

ts
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:

ts
import { ApplicationRecord } from "./application-record";

export class User extends ApplicationRecord("users") {}

Give it posts, a scope for active users, and rules for saving:

ts
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, and dependent: "destroy" destroys them with the user.
  • User.active() finds active users.
  • validates and beforeSave run on every save. An invalid user isn’t saved, and user.errors says why.

Using the Models ​

Queries start from the Model. Each Model uses the DB binding, so there’s nothing to connect:

ts
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 to false when validation fails.

Trying it ​

Start the Worker:

sh
npx wrangler dev

In another terminal, sign up a user, then list the users:

sh
curl -X POST http://localhost:8787 -d '{"email":"Ada@Example.com"}'
curl http://localhost:8787

The 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 ​