Skip to content

Coming from Rails ​

Models, validations, callbacks, associations, scopes, and dirty tracking all follow Active Record, down to the default error messages. Here’s what’s different.

Querying from the Model ​

ts
// Rails: User.where(active: true)
const users = await User.where({ isActive: true }).toArray();

A Model queries by itself. Its binding, static override binding = "DB" on your app’s base class, plays the part of connects_to.

A Worker serves many requests at once from one isolate, so nothing about a request is stored on the class. For a D1 Session or per-request logging, run the request in withDatabase. A record keeps the database it came from.

Naming things ​

Most names are Rails’ own, in camelCase:

Railsd1-record
created_at (attribute)createdAt, mapped to created_at
foreign_key: :user_idforeignKey: "userId"
save!, create!, find_by!saveOrThrow, createOrThrow, findByOrThrow
new_record?, persisted?isNewRecord(), isPersisted()
empty?, any?isEmpty(), hasAny()
changed?, saved_change_to_email?isChanged(), hasSavedChangeTo("email")
rails generate model Post title:stringnpx d1-record g model Post title:string
db/schema.rb, rails db:schema:dumpsrc/db/schema.ts, npx d1-record schema
class Post < ApplicationRecordclass Post extends ApplicationRecord("posts")
scope :active, -> { … }static active = this.scope((q) => …)
has_many :posts, dependent: :destroyposts = hasMany(Post, { dependent: "destroy" })
post.author = userpost.author.set(user)
build_profileuser.profile.build()
reload_authorpost.reloadAssociation("author")
throw :abortthrow abort("reason")
to_jsontoJSON()

Attributes, conditions, and association keys all name attributes, in camelCase. Column names only appear inside sql fragments. Foreign keys follow Rails’ conventions, so User’s has_many uses userId.

Declaring Models ​

Migrations define the columns, and a Model declares only behavior. Where Rails reads the columns when the app runs, d1-record reads them into src/db/schema.ts when you run d1-record schema or a generator, so TypeScript knows every attribute’s type.

A Model names its table, since TypeScript can’t derive a class’s types from its name. Scopes are statics, and validations and callbacks go in a static {} block:

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

// Rails:
//   class Product < ApplicationRecord
//     scope :in_stock, -> { where(available: true) }
//     validates :name, presence: true
//   end
export class Product extends ApplicationRecord("products", { available: "boolean" }) {
  static inStock = this.scope((q) => q.where({ available: true }));

  static {
    this.validates("name", { presence: true });
  }
}

Writing atomically ​

There’s no transaction do … end, since D1 can’t keep a transaction open across awaits. Instead, batch([...]) writes a prepared set of statements and records atomically, with records’ toSave() and toDestroy(), and bulk writes’ toUpdateAll(), toDeleteAll(), and so on. Generating keys in code makes whole graphs of new records atomic too. See D1’s limits.

Primary keys ​

Keys are UUIDs, not integers

g model gives each table a TEXT id generated in code, which is what makes those batches atomic. --id integer gives you integer keys that D1 assigns, without that guarantee.

Generating code ​

The generators follow rails generate model and rails generate migration. A column can also have a literal default, count:integer!=0, which Rails’ generators can’t express. See Generators.

Smaller differences ​

  • insert and insertAll raise on duplicates. Skipping them is opt-in, with { onDuplicate: "skip" }.
  • insert fills in declared defaults, since that’s where keys are generated.
  • merge replaces the order rather than appending to it.
  • last() can’t reverse an sql fragment order, and throws IrreversibleOrder, rather than trying to parse SQL.
  • find(1, 1) always returns an array, so its return type follows its arguments.
  • dependent: strategies run after the owner’s beforeDestroy callbacks, wherever they’re declared, so an abort() always protects the records.
  • Records in a batch run all their before-callbacks before any write, and their after-callbacks after all of them.
  • Scopes preloaded with limit throw, where Rails silently gives wrong results.

Not built yet ​

Default scopes, has_many :through, polymorphic associations, single-table inheritance, counter caches, destroy_async, and i18n of error messages.