Models
A Model describes one table. Its attributes come from your migrations, through the generated schema, so the Model only declares behavior.
Defining Models
Extend your app’s ApplicationRecord, naming the table:
import { ApplicationRecord } from "./application-record";
export class Product extends ApplicationRecord("products") {
static newest = this.scope((q) => q.order({ createdAt: "desc" }));
}The table’s attributes, primary key, and types come from src/db/schema.ts, which d1-record schema generates. A table that isn’t in the schema is a compile error.
TIP
The table is an argument, rather than inferred from the class name, so a Model keeps working when a bundler renames its class.
Subclasses share the table: setting another tableName throws InvalidModel. You can still set static primaryKey, as long as it names an attribute.
Overriding attributes
Generated attributes are usually all you need. When a column holds something its type doesn’t say, such as a boolean in a plain INTEGER, override the attribute with a second argument:
import { ApplicationRecord } from "./application-record";
export class Product extends ApplicationRecord("products", {
available: "boolean", // the column is a plain INTEGER: read it as a boolean
internalNotes: false, // ignored: never read or written
priceCents: { default: () => 100 }, // replaces the column's default, 0
}) {
static inStock = this.scope((q) => q.where({ available: true }));
}Overrides are keyed by attribute name, so an unknown name is a compile error:
| Override | Does |
|---|---|
"boolean" (a type name) | Reads and writes the attribute as that type |
{ type?, null?, default?, generateId? } | Replaces those parts of the generated attribute; null: false makes it non-null in TypeScript |
false | Ignores the column: it’s never typed, read, or written, and queries name their columns instead of * |
An override can’t change a column, add an attribute, or ignore the primary key: each throws InvalidModel.
- Changing a
typedrops the generated default orgenerateId, so the column’s own default applies unless you give one. - A
defaultandgenerateIdreplace each other, so an attribute has one or the other.generateIdis only for strings. - A
defaultisn’t type-checked yet (#74). One that returns a value the type can’t cast throwsTypeErrorwhen a record is built. - Ignoring a
NOT NULLcolumn without a default makes inserts fail, so give it a default in a migration first.
Declaring attributes yourself
For a view, or a table your migrations don’t make, declare the attributes with Model({...}). TypeScript infers the record’s properties from the object:
import { column, Model, sql } from "@forestfuture/d1-record";
export class User extends Model({
id: { type: "string", null: false, generateId: true },
email: { type: "string", null: false },
role: "string",
isActive: "boolean", // column: is_active
htmlURL: "string", // column: html_url (a run of capitals is one word)
legacyId: { type: "integer", column: "person_identifier" },
settings: { type: "json", default: () => ({ theme: "light" }) },
createdAt: "datetime",
updatedAt: "datetime",
}) {
static primaryKey = "id"; // the default
static active = this.scope((q) => q.where({ isActive: true }));
static createdAfter = this.scope((q, since: Date) =>
q.where(sql`${column("createdAt")} > ${since}`),
);
}An attribute is a type name, or { type, column?, null?, default? }. ApplicationRecord({...}) does the same, with your base class’s shared behavior.
Naming tables
A Model declared with Model({...}) takes its table name from its class: snake_case, pluralized.
| Class | Table |
|---|---|
User | users |
BlogPost | blog_posts |
Category | categories |
Person | people |
Sheep | sheep |
To use another name, set static tableName:
import { Model } from "@forestfuture/d1-record";
export class Account extends Model({ id: "string" }) {
static tableName = "billing_accounts";
}A subclass shares its parent’s table. An anonymous class, such as export default class extends Model(...), must set static tableName.
TIP
Pluralization follows English rules, quirks included: Human becomes humen and Leaf becomes leafs. There’s no way to add your own rules, so set tableName instead.
Bundlers and class names
Wrangler keeps class names by default, even when minifying. If a bundler renames User to e, the first query throws InvalidModel, saying the table es doesn’t exist and that its name was inferred from the class name. Set static tableName, or keep class names in your bundler’s settings.
Mapping columns
Attributes are camelCase, and map to snake_case columns:
| Attribute | Column |
|---|---|
createdAt | created_at |
externalAccountId | external_account_id |
htmlURL | html_url |
userID | user_id |
sha256Hash | sha256_hash |
To map an attribute to a column with another name, give it { column: "..." }. Conditions, ordering, and association keys always name attributes; only SQL fragments name columns.
Attribute types
| Type | JavaScript | Stored in D1 as |
|---|---|---|
string | string | TEXT |
integer | number | INTEGER |
real | number | REAL |
boolean | boolean | INTEGER, 0 or 1 |
datetime | Date | TEXT, ISO-8601 |
json | any value | TEXT, as JSON |
An attribute can be null unless it’s declared null: false, and its TypeScript type includes | null to match.
TIP
Store json attributes in a TEXT column, as D1 recommends, and add CHECK (json_valid(settings)) to have D1 reject invalid JSON. A column declared JSON works too.
Casting assigned values
Every value is cast to its attribute’s type when it’s assigned, so text from JSON, a form, or a query string becomes the right type:
// Values from JSON, a form or a query string often arrive as text:
user.assign(JSON.parse('{ "isActive": "false", "legacyId": " 42 " }'));
console.log(user.isActive); // false, not the truthy string "false"
console.log(user.legacyId); // 42
user.assign(JSON.parse('{ "isActive": "no" }'));
// TypeError: User: "no" isn't a boolean for "isActive"Values are cast wherever they’re assigned: build, create, assign, update, a property setter, a default, permit, and the values given to insert, insertAll, upsert, upsertAll, and updateAll. null is always NULL. Only unambiguous values are accepted:
| Type | Accepts |
|---|---|
boolean | true, false, 1, 0, "1", "0", and "true", "false", "on", "off" in any case |
integer | a safe integer, or a string that’s wholly one, with an optional sign and spaces around it: "42", " -7 ", "+3" |
real | a finite number, or a string that’s wholly one, with an optional sign, point, and exponent: "1.5", ".5", "5.", "-2e3" |
string | a string |
datetime | a valid Date; a date, "2026-01-02" (midnight UTC); or a date and time with Z or a ±HH:MM offset: "2026-01-02T09:30:00+02:00" |
json | any value |
Anything else throws TypeError naming the Model and the attribute, and the attribute keeps its value. The error shows at most 60 characters of the value, and none of a filtered attribute’s. Among the values refused:
""for every type butstring.1.7,"12abc","1e3", and integers pastNumber.MAX_SAFE_INTEGER, for aninteger.NaNandInfinity.- A number, boolean, or
Datefor astring. - For a
datetime: a time without an offset, a lowercasetorz, a day that doesn’t exist ("2026-02-30"), other formats, and numbers.
Casting conditions and keys
Conditions are cast the same way, so where({ quantity: "42" }) finds 42. A condition value that can’t be cast matches nothing, so find("abc") on an integer key throws RecordNotFound.
- A condition reads a number as its digits for a
stringattribute, and a record built from that Relation starts with what it matched:where({ label: 7 }).build()has the label"7". - A key copied from another record through an association is cast too, so an integer key written into a text foreign key becomes its digits.
Beyond Rails
Rails casts leniently: "12abc" becomes 12, 1.7 becomes 1, any other text is true, and "" is nil. d1-record refuses each, so isAdmin=no from a form can’t save true, and an empty field can’t silently become null.
Building records
Build a record without touching the database with build:
const user = User.build({ email: "ada@example.com" });
console.log(user.id); // already generated, before saving: "3b241101-e2bb-…"
console.log(user.role); // null: not assigned yet
console.log(user.settings); // { theme: "light" }
user.assign({ role: "admin", isActive: true });
user.isChanged(); // true
user.changes(); // { email: [null, "ada@example.com"], role: [null, "admin"], … }
await user.save();- Defaults and generated ids run at
build(), so a key generated in code exists before the record is saved. That lets records that point at each other be saved together, in one atomic batch. - Unassigned attributes read as
null, and aren’t written on insert, so the column’s database default applies. After the insert, the record loads what D1 stored. - Unknown attributes throw
UnknownAttribute, andundefinedthrowsTypeError. Usenullto store NULL.
assign(attrs) sets several attributes without saving. update(attrs) assigns, then saves.
TIP
A record keeps the database it was built or loaded through, for its later saves and association loads. A record made with new User() has none, so build records with User.build(…).
Permitting fields
Keep only the attributes you name from untrusted input with permit. It casts each value as assigning would, and its result goes straight into build, create, or update:
const permitted = Account.permit(await request.json(), "email", "handle");
const account = await Account.create(permitted);It reads three kinds of input:
| Input | Read |
|---|---|
| a plain object | its own keys only, so __proto__ and inherited keys never count |
FormData | each field; a repeated key takes its last value |
URLSearchParams | each parameter; a repeated key takes its last value |
- Keys you don’t name are dropped. A named attribute the input doesn’t have is left out, and a
nullis kept. - A name that isn’t an attribute is a compile error, and throws
UnknownAttribute. TypeErroris thrown for a value that can’t be cast, a file from a form, and any other input: an array, aMap,Headers, a class instance,null, or a string.
Untrusted input shows permit in a Worker.
Generating ids
A TEXT primary key gets its value from the Model’s generateId when you don’t give one. By default, that’s a UUID:
const post = Post.build({ title: "Hello" });
post.id; // "3b241101-e2bb-4255-8caf-4136c566a962"To use another kind of id across your app, override generateId on your app’s base class:
import { Model } from "@forestfuture/d1-record";
import { uuidv7 } from "uuidv7";
class Base extends Model.Base {
static override generateId = () => uuidv7();
}generateId gets the attribute’s name, so it can prefix ids: `${attribute}_${uuidv7()}`.
To generate another string attribute the same way, turn it on with an override:
export class Post extends ApplicationRecord("posts", { publicId: { generateId: true } }) {}In a Model you declare yourself, write it on the attribute: id: { type: "string", null: false, generateId: true }.
TIP
For one attribute with its own format, use a default: shareToken: { default: () => `share_${uuidv7()}` }. A value made from other attributes, such as a slug, belongs in a callback, since generateId never sees the record.
Beyond Rails
Rails has no generateId. Its UUID keys come from the database (gen_random_uuid() in PostgreSQL), which D1 can’t do for a key a batch needs before the row is written. See Primary keys.
Using timestamps
Declare createdAt and updatedAt as datetime attributes, and they’re managed for you. Both are set when a record is created, and updatedAt whenever a save changes something. A save with nothing to change leaves updatedAt alone, and a timestamp you assign yourself is kept.
Tracking changes
Records know what’s changed since they were loaded or last saved:
user.email = "ada@lovelace.dev";
user.changed(); // ["email"]
user.attributeWas("email"); // "ada@example.com"
user.isAttributeChanged("email", { from: "ada@example.com" }); // true
await user.save();
user.savedChangeTo("email"); // ["ada@example.com", "ada@lovelace.dev"]
user.hasSavedChangeTo("email"); // trueisChanged(),changed(),changedAttributes(), andchanges()describe the unsaved changes.attributeWas,isAttributeChanged, andwillSaveChangeTolook at one attribute’s unsaved change. Use them in before-callbacks.savedChanges(),savedChangeTo,hasSavedChangeTo, andattributeBeforeLastSavelook at the last save. Use them in after-callbacks, where the record is already clean.restoreAttributes()undoes unsaved changes, to every attribute or the ones you name.
An update writes only the attributes that changed, and a save with nothing changed sends nothing.
TIP
Changes are found by comparing stored values, so editing a json value or a Date in place counts as a change.
Writing SQL fragments
For anything the query methods can’t express, write an sql tagged template. Values are always bound, and column() looks up an attribute’s column:
import { column, sql } from "@forestfuture/d1-record";
const recent = await User.where(sql`${column("createdAt")} > ${since}`).toArray();A plain string is rejected.
Serializing records
toJSON() gives a record’s attributes with dates as ISO strings, so Response.json(user) just works. attributes() gives the same object with JavaScript values:
const body = JSON.stringify(user); // dates as ISO strings, associations left out
const attributes = user.attributes(); // a plain object, dates as DateAssociations are never included, so a response never loads more than you asked for.
Hiding attributes
Keep an attribute out of toJSON() by naming it in static hiddenAttributes on your app’s base class:
import { Model } from "@forestfuture/d1-record";
// src/models/application-record.ts
export class Base extends Model.Base {
static override hiddenAttributes = ["passwordDigest"];
}Every Model built on the base class then leaves it out of Response.json(user). By default, nothing is hidden, and each name must match an attribute exactly.
A Model’s own list replaces the base class’s:
import { Model } from "@forestfuture/d1-record";
export class Account extends Model({
id: "integer",
passwordDigest: "string",
apiKey: "string",
}) {
// A Model's list replaces the base class's, so repeat what you still want hidden:
static override hiddenAttributes = [...Base.hiddenAttributes, "apiKey"];
}attributes() still returns every attribute, so you can send a hidden one on purpose.
TIP
A name a Model doesn’t have throws InvalidModel when the Model is first used, so a typo can’t leave an attribute in your responses. The base class’s names are the exception: a Model without that attribute ignores them.
Beyond Rails
Rails’ serializable_hash(except:) is per call, and filter_attributes only covers logs and inspection. d1-record keeps a class-level list, so Response.json(user) is safe without each call site remembering to strip the attribute.
Filtering attributes
onQuery listeners and error messages show a filtered attribute’s values as "[FILTERED]". D1 still gets the real values. Logging shows how to change the list.
- Which attributes:
static filterAttributes, a list of strings and RegExps, inherited from your app’s base class. By default, it’spassw,email,secret,token,_key,crypt,salt,certificate,otp,ssn,cvv,cvc. Setting it replaces the list;[]filters nothing. - Matching: a string matches when the attribute’s snake_case name contains it, in any case:
passwordDigest(password_digest) containspassw, andapiKey(api_key) contains_key. A RegExp matches the attribute or its snake_case name. - Hidden in
QueryEvent.params: the values bound for the attribute in inserts (in a multi-row insert’s JSON, its column in every row), updates,updateAll, conditions, lists and comparisons, and a filtered primary key in a record’sUPDATEandDELETE. - Hidden in errors: a cast error’s value, and a filtered primary key in
RecordNotFound. - Not hidden: values inside
sqlfragments, which have no attribute; andtoJSON()andattributes(), which are your data, not logs (Hiding attributes keeps names out oftoJSON()).
Compared with Rails
The default list is Rails’ own filter_parameters list, and the filtered values read as they do in Rails’ logs.
Printing records
console.log(user), a failed test, and the console show a record’s class and attributes:
User { id: 1, name: 'Johnny' (was 'Alice'), email: [FILTERED], role: 'admin' }- Filtered attributes print as
[FILTERED], without quotes so it can’t be mistaken for a string, and in a distinct color when the terminal shows colors. An attribute with no value still printsnull. - Unsaved changes print the new value and the old one:
'Johnny' (was 'Alice'). Once the record is saved, the flag goes. A changed filtered attribute prints[FILTERED] (changed), and so does a column a partial record never loaded:'Johnny' (changed). A new record flags nothing, since all of it is unsaved. - A partial record prints only the columns it loaded.
A Relation prints the SELECT it would send, without sending it:
Relation<User> {
sql: 'SELECT * FROM "users" WHERE (("role" = ?))',
params: [ 'admin' ]
}A record’s errors print each failure, so a failed save() shows why in the console:
Errors [
{ attribute: 'name', type: 'blank', message: "can't be blank" },
{ attribute: 'email', type: 'too_short', message: 'is too short' }
]Printing can’t wait for D1, so to see the records, await it or call toArray(). A filtered attribute’s value in params prints as [FILTERED].
TIP
Printing shows attributes only, so it never loads an association. Hidden attributes still print: they keep values out of toJSON(), not out of your console.
Compared with Rails
A record prints as Rails’ inspect does, with [FILTERED] for filtered attributes. A Rails Relation runs its query to print its records, which a synchronous console.log can’t do here.
Beyond Rails
Rails doesn’t mark unsaved changes when it prints a record. d1-record does, so a pending edit is visible in the console before you save it.
Sharing behavior
A subclass, such as class Admin extends User, inherits a copy of its parent’s attributes, validations, callbacks, scopes, and associations. Declaring more on the child never changes the parent. Single-table inheritance isn’t supported.
To share methods, callbacks, and scopes across every Model, write them on your app’s base class. See Shared behavior.
Checking declarations
The first time a Model is used, by its first query or db.model(X), its declaration is checked. It throws InvalidModel for:
- No table name: an empty
tableName, or a class with no name to infer one from. - A
primaryKeythat isn’t an attribute. - An attribute named like a record method, such as
saveorattributes. - A class field that hides an attribute, such as
email!: stringin the class body.
After that, a Model’s declarations are fixed, so request code can’t change them.