--- url: https://docs.forestfuture.dev/d1-record/guide/getting-started.md description: >- From an empty Worker to two related Models in about ten minutes: install d1-record, generate Models and their tables, add behavior, and use them in a Worker. --- # Getting started d1-record turns each table in your [D1](https://developers.cloudflare.com/d1/) database into a Model, so your [Worker](https://developers.cloudflare.com/workers/) works with records instead of rows. In this tutorial you’ll build the start of a small blog: users, and the posts they write. ::: warning 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](https://developers.cloudflare.com/workers/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 [wrangler.jsonc] { "d1_databases": [ { "binding": "DB", "database_name": "my-app-db", "database_id": "" }, ], } ``` * **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: | 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: ```sql \[migrations/0002_create_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); ``` ::: rails 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](./generators#adding-a-table) 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 \[src/models/post.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 [src/models/user.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 \[src/models/user.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 \[src/index.ts] import { User } from "./models/user"; export default { async fetch(request: Request): Promise { // 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; ``` * `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 * **[Models](./models):** attributes, types, and overrides. * **[Querying](./querying):** conditions, ordering, scopes, and preloading. * **[Persistence](./persistence):** saving and destroying records. * **[Associations](./associations):** `hasMany`, `belongsTo`, and `hasOne`. * **[Generators](./generators):** every way to write columns. * **[Bulk writes and batches](./batches):** writing several changes at once. --- --- url: https://docs.forestfuture.dev/d1-record/guide/coming-from-rails.md description: >- What's different from Rails' Active Record: queries start from the request, camelCase attributes, OrThrow methods, UUID keys, batches instead of transactions. --- # 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`](./connecting#using-the-request-s-database). A record keeps the database it came from. ## Naming things Most names are Rails’ own, in camelCase: | Rails | d1-record | | ---------------------------------------- | ------------------------------------------------- | | `created_at` (attribute) | `createdAt`, mapped to `created_at` | | `foreign_key: :user_id` | `foreignKey: "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:string` | `npx d1-record g model Post title:string` | | `db/schema.rb`, `rails db:schema:dump` | `src/db/schema.ts`, `npx d1-record schema` | | `class Post < ApplicationRecord` | `class Post extends ApplicationRecord("posts")` | | `scope :active, -> { … }` | `static active = this.scope((q) => …)` | | `has_many :posts, dependent: :destroy` | `posts = hasMany(Post, { dependent: "destroy" })` | | `post.author = user` | `post.author.set(user)` | | `build_profile` | `user.profile.build()` | | `reload_author` | `post.reloadAssociation("author")` | | `throw :abort` | `throw abort("reason")` | | `to_json` | `toJSON()` | 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([...])`](./batches) 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](./d1). ## Primary keys ::: warning 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](./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. --- --- url: https://docs.forestfuture.dev/d1-record/guide/ai-agents.md description: >- Point coding agents at d1-record's docs, install its agent skill, and add its rules to your project's AGENTS.md or CLAUDE.md. --- # AI agents Coding agents follow d1-record’s conventions best with its guide at hand and its rules in context. Use any of these, or all three. ## Giving your agent the docs | Resource | What it is | | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | [`llms.txt`](https://docs.forestfuture.dev/d1-record/llms.txt) | An index of the guide, each page linked as Markdown, following [llmstxt.org](https://llmstxt.org). Start here when an agent can fetch pages. | | [`llms-full.txt`](https://docs.forestfuture.dev/d1-record/llms-full.txt) | The whole guide in one Markdown file, about 20K tokens. | | Any page + `.md` | Every page as Markdown: [`/guide/associations.md`](https://docs.forestfuture.dev/d1-record/guide/associations.md). | | `node_modules/@forestfuture/d1-record/llms-full.txt` | The same guide, shipped in the package. It matches the version you’ve installed, and an agent can read it without fetching anything. | ## Installing the agent skill Install d1-record’s [agent skill](https://github.com/forestfuture/d1-record/blob/main/SKILL.md): its rules, the shapes of a Model and a Worker, and the mistakes to watch for, with pointers into the guide. Agents that support skills, such as Claude Code, Cursor, Codex, and [others](https://skills.sh), load it when they work on d1-record code: ```sh npx skills add forestfuture/d1-record ``` To install the copy shipped with your installed version, sync skills from `node_modules`: ```sh npx skills experimental_sync ``` ::: warning TIP The skills CLI marks `experimental_sync` as experimental, so its name may change. ::: ## Adding the rules to your project Agents read `AGENTS.md`, or `CLAUDE.md` for Claude Code, at the start of every task. Paste this block into yours: ```md d1-record (`@forestfuture/d1-record`) is a Rails-style Active Record for Cloudflare D1. Its guide for the installed version is `node_modules/@forestfuture/d1-record/llms-full.txt`: read the part for the task before writing code with it. - Create tables with the generators, never by hand-declaring attributes: `npx d1-record g model Post user:references 'title:string!'` writes the migration and the Model; `npx d1-record g migration AddSlugToPosts slug:string:unique` writes a change. Apply them with `npx wrangler d1 migrations apply --local`. After a hand-written migration, run `npx d1-record schema`. Never edit the generated `src/db/schema.ts`. - A Model extends the app's base class, naming its table: `class Post extends ApplicationRecord("posts")`. Its attributes come from the schema, so it declares only behavior. Change how a column is read with an override, `ApplicationRecord("posts", { metadata: "json" })`, never by editing the schema. - Start queries and new records from the Model, as in Rails: `User.find(id)`, `User.where(…)`, `User.build(…)`. Each Model queries its binding (`static override binding = "DB"`, usually on the app's base class) during a request. For a D1 Session, per-request `onQuery`, or tests and scripts outside a Worker, wrap the code once in `withDatabase(connect(env.DB, { … }), fn)`. Records keep the database they came from. - Never pass a request's whole body, form or query string to a Model: name the fields, with destructuring or `User.permit(await request.json(), "email", "name")` (it reads `FormData` and `URLSearchParams` too). Assigned values are checked against the attribute's type, but that doesn't stop a user setting `role` or `isAdmin`. Check a request value's type before querying with it: `findBy({ token: null })` matches rows whose token is NULL. - Name attributes in camelCase everywhere: `where`, `order`, `pluck`, `foreignKey`. Column names (snake_case) appear only inside `sql` fragments. - Write custom SQL as an `sql` tagged template, which binds every value: ``where(sql`lower(${column("email")}) = ${email}`)``. - Bulk writes run when called: `await posts.deleteAll()` and `updateAll(…)` resolve to how many rows; `insert(…)` to D1's `meta`. Make several writes atomic with one `batch([...])` of their `to…` forms (`toInsert`, `toUpdateAll`, `toDeleteAll`, …) and records' `toSave()`/`toDestroy()`. D1 has no interactive transactions, so a read-then-write can't be atomic. - Keep the generators' `TEXT` keys, generated in code, for records saved together (`--id integer` gives a key D1 generates instead). An owner whose key is known before its INSERT (generated in code, or already saved) is written in one atomic batch with the records built through it. A key D1 generates exists only after its own write, so each level is then written in a separate batch. - To change how those keys are made (UUIDv7, ULID, a prefix), override `static generateId` once on the app's base class; don't give each Model's key a `default`. `generateId` sees only the attribute's name, so a value made from other attributes (a slug) goes in a callback. - Declare associations as fields, passing the target class: `posts = hasMany(Post, { dependent: "destroy" })`, `author = belongsTo(User)`. The field's name is the association's name. - Check what writes return. `save()` and `update()` resolve to `false` when validation fails (reasons in `record.errors`) or a callback aborts (`record.abortReason`). `destroy()` resolves to `false` when a callback aborts, or a `restrictWithError` association refuses (reason in `record.errors`). The `…OrThrow` methods throw instead. Constraint errors, such as `RecordNotUnique`, always throw. ``` Copy it again after upgrading d1-record, since the rules can change. --- --- url: https://docs.forestfuture.dev/d1-record/guide/models.md description: >- Reference for Models: ApplicationRecord("table") and overrides, declaring attributes yourself with Model({...}), table names, attributes and types, casting assigned values, building records, permit, timestamps, change tracking, SQL fragments, serialization, and filtered attributes. --- # 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: ```ts 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`](./command-line#d1-record-schema) generates. A table that isn’t in the schema is a compile error. ::: warning 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: ```ts 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 `type`** drops the generated default or `generateId`, so the column’s own default applies unless you give one. * **A `default` and `generateId`** replace each other, so an attribute has one or the other. `generateId` is only for strings. * **A `default`** isn’t type-checked yet ([#74](https://github.com/forestfuture/d1-record/issues/74)). One that returns a value the type can’t cast throws `TypeError` when a record is built. * **Ignoring a `NOT NULL` column** 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: ```ts 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`: ```ts 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`. ::: warning 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. ::: ::: details 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. ::: warning TIP Store `json` attributes in a `TEXT` column, as [D1 recommends](https://developers.cloudflare.com/d1/sql-api/query-json/#types), 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: ```ts // 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`](#permitting-fields), 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](#filtering-attributes)’s. Among the values refused: * `""` for every type but `string`. * `1.7`, `"12abc"`, `"1e3"`, and integers past `Number.MAX_SAFE_INTEGER`, for an `integer`. * `NaN` and `Infinity`. * A number, boolean, or `Date` for a `string`. * For a `datetime`: a time without an offset, a lowercase `t` or `z`, 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 `string` attribute, 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. ::: rails 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`: ```ts 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](#generating-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`, and `undefined` throws `TypeError`. Use `null` to store NULL. `assign(attrs)` sets several attributes without saving. `update(attrs)` assigns, then saves. ::: warning 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`: ```ts 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 `null` is kept. * **A name that isn’t an attribute** is a compile error, and throws `UnknownAttribute`. * **`TypeError`** is thrown for a value that can’t be cast, a file from a form, and any other input: an array, a `Map`, `Headers`, a class instance, `null`, or a string. [Untrusted input](./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: ```ts 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: ```ts 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: ```ts 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 }`. ::: warning 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](./callbacks), since `generateId` never sees the record. ::: ::: rails 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](./coming-from-rails#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: ```ts 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"); // true ``` * `isChanged()`, `changed()`, `changedAttributes()`, and `changes()` describe the unsaved changes. * `attributeWas`, `isAttributeChanged`, and `willSaveChangeTo` look at one attribute’s unsaved change. Use them in before-callbacks. * `savedChanges()`, `savedChangeTo`, `hasSavedChangeTo`, and `attributeBeforeLastSave` look 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. ::: warning 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: ```ts 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: ```ts const body = JSON.stringify(user); // dates as ISO strings, associations left out const attributes = user.attributes(); // a plain object, dates as Date ``` Associations 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: ```ts 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: ```ts 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. ::: warning 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. ::: ::: rails 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](./logging#keeping-secrets-out-of-the-logs) 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’s `passw`, `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`) contains `passw`, and `apiKey` (`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’s `UPDATE` and `DELETE`. * **Hidden in errors:** a cast error’s value, and a filtered primary key in `RecordNotFound`. * **Not hidden:** values inside `sql` fragments, which have no attribute; and `toJSON()` and `attributes()`, which are your data, not logs ([Hiding attributes](#hiding-attributes) keeps names out of `toJSON()`). ::: rails 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: ```text 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 prints `null`. * **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: ```text Relation { sql: 'SELECT * FROM "users" WHERE (("role" = ?))', params: [ 'admin' ] } ``` A record’s `errors` print each failure, so a failed `save()` shows why in the console: ```text 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]`. ::: warning TIP Printing shows attributes only, so it never loads an association. [Hidden attributes](#hiding-attributes) still print: they keep values out of `toJSON()`, not out of your console. ::: ::: rails 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. ::: ::: rails 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](./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 `primaryKey` that isn’t an attribute. * An attribute named like a record method, such as `save` or `attributes`. * A class field that hides an attribute, such as `email!: string` in the class body. After that, a Model’s declarations are fixed, so request code can’t change them. --- --- url: https://docs.forestfuture.dev/d1-record/guide/querying.md description: >- Reference for querying: a Model's statics, Relations, where and comparisons, sql fragments, ordering and paging, paginate, find/findBy/first/count, partial records, none(), merge, scopes, and preloading. --- # Querying A query is a **Relation**. Each method that narrows, orders, or pages it returns a new Relation, and nothing runs until you ask for results: ```ts import { User } from "./models"; const admins = User.where({ role: "admin" }).order({ createdAt: "desc" }); const firstTen = admins.limit(10); // a new Relation; admins is unchanged const users = await firstTen.toArray(); // the query runs here ``` ::: warning TIP Relations have no `then`, so an `async` function that returns one never runs it by accident. Ask for results with a method such as `toArray()`, `first()`, or `count()`. ::: ## Starting a query Every Relation method is also a static of every Model: `User.where(…)`, `User.find(id)`, `User.count()`, `User.create(…)`. `User.all()` is the Relation itself, and scopes are statics too: `User.active()`. A query runs on the current database: the request’s, if it was opened with `withDatabase`, or the Model’s binding. To use another, start from `db.model(User)`. See [Connections](./connecting). ## Adding conditions Pass `where` an object of attributes that must all match: ```ts import { User } from "./models"; User.where({ role: "admin", isActive: true }); // role = ? AND is_active = ? User.where({ role: null }); // role IS NULL User.where({ role: ["admin", "editor"] }); // role IN (…), any number of values User.whereNot({ role: "admin" }); // NOT (role = ?) User.where({ role: "admin" }).or(User.where({ isActive: false })); ``` * `null` matches NULL, and an array matches any of its values. The list is bound as one value, so it can be any length. * `whereNot` negates the whole condition. As in SQL, `whereNot({ role: "admin" })` also leaves out users whose role is NULL. * `or` combines two Relations of the same Model that differ only in their conditions. * `undefined` throws `TypeError`. Leave the key out, or use `null`. Values are [cast to the attribute’s type](./models#casting-assigned-values), so `where({ quantity: "42" })` finds 42. A value that can’t be cast matches nothing: `findBy` resolves to `null`, and `find` throws `RecordNotFound`. ::: warning TIP In a list, values that can’t be cast are left out. So `whereNot({ id: "abc" })` on an integer key excludes nothing. ::: ### Comparing values Pass a comparison helper as a value: ```ts import { between, gte, lt } from "@forestfuture/d1-record"; import { Post, User } from "./models"; Post.where({ publishedAt: gte(since) }); // published_at >= since User.where({ createdAt: lt(since) }); // created_at < since User.where({ createdAt: between(since, until) }); // both ends included User.where({ createdAt: between(since, until, { excludeEnd: true }) }); ``` `gt`, `gte`, `lt`, `lte`, and `between` work on number, string, and datetime attributes, and dates compare by time. `between` includes both ends. Pass `{ excludeEnd: true }` to leave out the end. ### Writing SQL fragments For anything else, write an `sql` tagged template. Values are always bound, and `column()` looks up an attribute’s column: ```ts import { column, sql } from "@forestfuture/d1-record"; import { User } from "./models"; const email = "Ada@Example.com"; User.where(sql`lower(${column("email")}) = lower(${email})`); ``` ## Ordering and paging Sort by each key in turn with `order`, and page with `limit` and `offset`: ```ts import { column, sql } from "@forestfuture/d1-record"; import { User } from "./models"; User.order({ role: "asc", createdAt: "desc" }); User.order({ role: { direction: "asc", nulls: "last" } }); User.order(sql`lower(${column("email")})`); User.order({ role: "asc" }).reorder({ email: "asc" }); // replaces the order User.order({ createdAt: "desc" }).limit(20).offset(40); // page 3 ``` Calling `order` again adds more keys. `reorder` replaces the order so far, and `reorder()` removes it. To get a page with its total, use [`paginate`](#paginating). ::: warning TIP NULLs sort first in ascending order, as in SQLite. Place them with `nulls: "first"` or `nulls: "last"`. ::: ## Paginating `paginate` resolves to one page of records and the total they’re a page of, in a single round trip to D1: ```ts import { Post } from "./models"; export async function listPosts(request: Request): Promise { const page = new URL(request.url).searchParams.get("page"); const posts = await Post.order({ publishedAt: "desc" }).paginate({ page, perPage: 20 }); return Response.json(posts); // { records, page, perPage, total, totalPages, hasNext, hasPrevious } } ``` * `page` and `perPage` take numbers, or text from a query string. Missing, `null`, or empty, they mean the first page and the default size. Anything that isn’t a whole number from 1 throws `TypeError`. * Pages sort by the Relation’s order, then by the primary key, so records that tie keep their pages between requests. * A page past the last has no records, but still has the real `total`. * `includes()` preloads the page’s associations after it, with one more query per association, as for `toArray()`. * A Relation with its own `limit` or `offset` throws `IncompatibleRelation`. ### Skipping the count Counting reads every matching row, and D1 bills each row it reads. If you only need to know whether there’s a next page, pass `count: false`: ```ts import { Post } from "./models"; const posts = Post.order({ publishedAt: "desc" }); const { records, hasNext } = await posts.paginate({ page: 2, count: false }); ``` The page then has no `total` or `totalPages`. ### Setting the page size By default, a page holds 25 records, and none holds more than 100. To change both, set `perPage` and `maxPerPage` on your app’s base class: ```ts import { Model } from "@forestfuture/d1-record"; export class Base extends Model.Base { static override perPage = 20; // when paginate() isn't given a perPage static override maxPerPage = 50; // a larger perPage is capped at this } ``` ::: warning TIP D1 limits neither the rows a query returns nor the size of its response. `maxPerPage` keeps one request from reading, and paying for, too many rows, within a Worker’s 128 MB of memory and a query’s 30 seconds. ::: ::: rails Beyond Rails Rails leaves pagination to gems such as Kaminari and Pagy. d1-record builds it in, and sends the count and the page to D1 together. ::: ## Getting results Run a query with one of these methods: ```ts import { User } from "./models"; const all = await User.toArray(); const user = await User.find("3b241101-e2bb-4255-8caf-4136c566a962"); // or RecordNotFound const two = await User.find(["id-1", "id-2"]); // in the ids' order const byEmail = await User.findBy({ email: "ada@example.com" }); // or null const oldest = await User.first(); // by primary key, unless ordered const newest = await User.order({ createdAt: "asc" }).last(); // order reversed const any = await User.take(); // no ordering at all const emails = await User.pluck("email"); // string[] const pairs = await User.pluck("id", "email"); // [string, string][] const admins2 = await User.where({ role: "admin" }).count(); const hasAdmins = await User.exists({ role: "admin" }); ``` * `find` throws `RecordNotFound` if the id is missing. Given several ids, it returns them in order, from one query, and throws if any are missing. * `findBy` resolves to `null` when nothing matches, and `findByOrThrow` throws `RecordNotFound`. * `first` and `last` sort by the primary key unless the Relation has an order, and `last` reverses that order. An `sql` fragment can’t be reversed, so `last` throws `IrreversibleOrder` there. * `count()` always agrees with what `toArray()` returns, even with `limit`, `offset`, or `distinct`. * `size()`, `isEmpty()`, and `hasAny()` count records, or check for any. On a loaded association, they answer without a query. ::: rails Compared with Rails `size()`, `isEmpty()`, and `hasAny()` are Rails’ `size`, `empty?`, and `any?`. ::: ## Selecting attributes Load only some attributes with `select`. The primary key is always loaded too, so the records can still be saved: ```ts import { User } from "./models"; const partial = await User.select("email").toArray(); console.log(partial[0]?.email); // loaded console.log(partial[0]?.id); // the primary key is always loaded too // partial[0]?.role would throw MissingAttribute: it wasn't selected const roles = await User.select("role").distinct().pluck("role"); ``` * Reading an attribute that wasn’t loaded throws `MissingAttribute`, including from a validator or callback. * Saving a partial record writes only what changed. `reload()` loads the whole row. * After `distinct()`, the primary key isn’t added, since it would make every row distinct, so those records can’t be saved. ## Matching nothing `none()` matches nothing without asking D1, so it needs no database to answer. Chaining more onto it keeps it empty: ```ts import { User } from "./models"; const nothing = await User.none().where({ role: "admin" }).toArray(); // [], no query ``` ## Seeing a query’s SQL See the SQL a Relation’s `toArray()` would send, without sending it, with `toSql()`: ```ts import { sql } from "@forestfuture/d1-record"; import { User } from "./models"; const { sql: text, params } = User.where({ role: "admin" }).limit(10).toSql(); console.log(text); // SELECT * FROM "users" WHERE ("role" = ?) LIMIT ? console.log(params); // ["admin", 10] ``` The values come back in `params`, apart from the SQL. A Statement from `toInsert`, `toUpdateAll`, `toDeleteAll`, and the other `to…` methods has `toSql()` too. A `none()` Relation shows `1 = 0`, the condition that matches nothing. ::: warning TIP `toSql()` returns the real values, filtered attributes included. To see what a query sent, with those filtered, use [`onQuery`](./logging). ::: A record’s `toSave()` and `toDestroy()` have no `toSql()`. Their SQL depends on validations and callbacks that only run inside `db.batch`. ::: rails Compared with Rails `toSql()` is Rails’ `to_sql`, except that Rails writes the values into the SQL and `toSql()` keeps them in `params`. ::: ## Defining scopes A scope is a named, reusable part of a query. Declare one as a static that receives the Relation, plus any arguments, and returns a narrower one: ```ts 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}`), ); } ``` Call it as a static, or on any Relation of the Model: ```ts const admins = await User.active().where({ role: "admin" }).toArray(); const recent = await User.createdAfter(new Date(Date.now() - 86_400_000)).count(); ``` * A scope’s arguments, and anything it computes such as `new Date()`, are evaluated when you call it. * Scopes are inherited, including those on your app’s base class ([Shared behavior](./shared-behavior)). A subclass can replace one with `static override active = this.scope(…)`. * A scope can’t reuse a Relation method’s name (`where`, `count`) or a Model static’s (`validates`, `scope`, `all`). * Inside a scope, `q` has every Relation method but not the Model’s other scopes. Share logic between scopes with a plain function. ::: warning TIP A scope whose body names its own class needs its type written out: `static roots: ScopeStatic<[]> = this.scope(…)`. ::: ## Merging Relations Combine two Relations of the same Model with `merge`. Their conditions are joined with AND, and anything the argument sets, such as an order, limit, or select, replaces the receiver’s: ```ts const recent = User.order({ createdAt: "desc" }).limit(10); const admins = await User.where({ role: "admin" }).merge(recent).toArray(); ``` ::: rails Compared with Rails Rails adds the argument’s order to the receiver’s. d1-record replaces it. ::: ## Preloading associations `includes` loads associations for every record, with one query per association. See [Associations](./associations#preloading-associations). --- --- url: https://docs.forestfuture.dev/d1-record/guide/persistence.md description: >- save, create, update, and destroy, their OrThrow variants, what each returns or throws, lifecycle state, and reload. --- # Persistence Records are saved, updated, and destroyed with `save`, `create`, `update`, and `destroy`. ## Saving records Save a record with `save`. It validates the record, runs its callbacks, and resolves to `true` or `false`: ```ts import { User } from "./models"; const user = User.build({ email: "ada@example.com" }); if (await user.save()) { // saved: user.isPersisted() is true } else { console.log(user.errors.fullMessages()); // ["Email has already been taken"] } const created = await User.create({ email: "grace@example.com" }); // saved, or not await created.update({ isActive: false }); // true or false ``` A new record is inserted, and a persisted one updates only the attributes that changed. `save` resolves to `false` for one of two reasons: * **Validation failed.** The reasons are in `record.errors`. * **A callback aborted** with [`abort()`](./callbacks#stopping-a-save). The reason is in `record.abortReason`. `create(attrs)` builds and saves a record, and resolves to it whether it saved or not. Check `isPersisted()` or `errors`. `update(attrs)` assigns, then saves. ## Throwing on failure When a failure is exceptional, use the `OrThrow` version of each method: ```ts import { RecordInvalid } from "@forestfuture/d1-record"; import { User } from "./models"; try { const grace = await User.createOrThrow({ email: "grace@example.com" }); await grace.updateOrThrow({ isActive: true }); } catch (error) { if (error instanceof RecordInvalid) { return Response.json({ errors: error.record.errors.fullMessages() }, { status: 422 }); } throw error; // anything else is a real failure } ``` Instead of returning `false`, they throw: * `RecordInvalid` when validation failed, with the record on `error.record`. * `RecordNotSaved` when a callback aborted a save, with the abort’s reason as its message. * `RecordNotDestroyed` from `destroyOrThrow()`, when a callback aborted the destroy or a `restrictWithError` association refused it. ::: rails Compared with Rails The `OrThrow` methods are Rails’ `save!`, `create!`, `update!`, and `destroy!`. ::: ### What each method returns | Method | Resolves to | Throws | | ---------------------------------------- | ------------------------------------- | ----------------------------------------------------- | | `save()` / `update(attrs)` | `true` / `false` | constraint errors, `RecordDestroyed` | | `saveOrThrow()` / `updateOrThrow(attrs)` | `true` | same as above, plus `RecordInvalid`, `RecordNotSaved` | | `create(attrs)` | record, persisted or not | constraint errors | | `createOrThrow(attrs)` | persisted record | same as above, plus `RecordInvalid`, `RecordNotSaved` | | `destroy()` | destroyed record, or `false` on abort | constraint errors, `RecordDestroyed` | | `destroyOrThrow()` | destroyed record | same as above, plus `RecordNotDestroyed` | | `find(id)` | record | `RecordNotFound` | | `findBy(conds)` | record or `null` | none | | `findByOrThrow(conds)` | record | `RecordNotFound` | `false` only ever means validation failed or a callback aborted. Every method throws for anything else: * **Constraint errors**, such as `RecordNotUnique`, since the database refused the write. See [Errors](./errors#constraint-errors). * **An error thrown by a callback**, other than `abort()`, even from an after-callback once the record is saved. * **D1’s own errors.** ## Destroying records Destroy a record with `destroy`: ```ts const destroyed = await created.destroy(); // the record, or false if a callback aborted if (destroyed) { destroyed.isDestroyed(); // true: readable, but can't be changed or saved } ``` It runs the destroy callbacks and the associations’ [`dependent` options](./associations#destroying-dependent-records), then deletes the row. A destroyed record’s attributes stay readable, but setting one, or calling `save`, `update`, `destroy`, or `reload`, throws `RecordDestroyed`. ::: warning TIP If another request already deleted the row, `save` and `destroy` still succeed. ::: ## Checking a record’s state | State | `isNewRecord()` | `isPersisted()` | `isDestroyed()` | | ------------------------------------------------ | --------------- | --------------- | --------------- | | New (from `build()`, or after a failed `create`) | `true` | `false` | `false` | | Persisted (loaded, or after a successful save) | `false` | `true` | `false` | | Destroyed (after a successful `destroy()`) | `false` | `false` | `true` | ## Reloading records Load a record’s row again with `reload`, discarding unsaved changes and loaded associations: ```ts import { User } from "./models"; const fresh = await User.find(user.id); fresh.email = "changed@example.com"; await fresh.reload(); // back to what D1 holds ``` It throws `RecordNotFound` if the row is gone, or the record was never saved. ## Writing many rows To insert, update, or delete many rows at once, without validations and callbacks, use `insertAll`, `upsertAll`, `updateAll`, and `deleteAll`. See [Bulk writes and batches](./batches). --- --- url: https://docs.forestfuture.dev/d1-record/guide/validations.md description: >- validates with presence, length, format, inclusion, numericality, and uniqueness, their options, the errors collection, and adding errors with errors.add in a callback. --- # Validations Validations check a record before it’s saved. An invalid record isn’t saved, and its `errors` say why. ## Declaring validations Declare validations in a Model’s `static {}` block with `this.validates`: ```ts import { Model } from "@forestfuture/d1-record"; export class Account extends Model({ id: { type: "integer", null: false }, email: { type: "string", null: false }, handle: { type: "string", null: false }, plan: "string", seats: "integer", website: "string", isCompany: "boolean", companyName: "string", }) { static { this.validates("email", { presence: true, uniqueness: { caseSensitive: false } }); this.validates("handle", { length: { minimum: 3, maximum: 30 }, format: /^[a-z0-9_]+$/ }); this.validates("plan", { inclusion: ["free", "team", "enterprise"] }); this.validates("seats", { numericality: { onlyInteger: true, greaterThan: 0 } }); this.validates("website", { format: /^https:\/\//, allowNull: true }); this.validates("companyName", { presence: true, if: (account) => account.isCompany === true, message: "is required for a company", }); } } ``` Attribute names are typed, so a misspelled attribute is a compile error. `save()` validates first, and resolves to `false` when a record is invalid. To validate without saving, call `isValid()`: ```ts const account = Account.build(permitted); const valid = await account.isValid(); ``` ::: warning TIP `isValid()` is async, since `uniqueness` asks D1. ::: ## Available validators | Rule | Options | Error type | | -------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------- | | `presence` | `true` | `blank` | | `length` | `{ minimum, maximum, is }` | `tooShort`, `tooLong`, `wrongLength` | | `format` | `{ with: /re/ }`, or a regex | `invalid` | | `inclusion` | `{ in: [...] }`, or an array | `inclusion` | | `numericality` | `{ onlyInteger, greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo }`, or `true` | `notANumber`, `notAnInteger`, `greaterThan`, … | | `uniqueness` | `{ scope: [...], caseSensitive }`, or `true` | `taken` | Every validation runs, in the order declared, so one attribute can collect several errors. Every rule also accepts these options: * **`allowNull`** skips the rule when the value is `null`. Otherwise `null` fails `presence`, `format`, `inclusion`, and `numericality`, counts as length 0, and is never taken. * **`message`** replaces the default messages from that call. * **`if: (record) => boolean`** validates only when it returns `true`. The default messages are in English: “can’t be blank”, “is too short (minimum is 3 characters)”, “has already been taken”, and so on. ### Validator details * **`presence`** fails a string that’s only whitespace. For a boolean, use `inclusion: [true, false]`, since `false` is a value. * **`numericality`** only accepts numbers, so a numeric string such as `"12"` is `notANumber`. * **`uniqueness`** leaves the record itself out when updating, and never treats `null` as taken, as SQLite’s UNIQUE does. `caseSensitive: false` compares with SQLite’s `lower()`, which only lowercases ASCII letters. ::: warning Uniqueness isn’t a guarantee Two requests can both check that an email is free, then both insert it. `uniqueness` gives a friendly error early, but only a UNIQUE index in the table prevents duplicates, so declare both. When the index catches a duplicate, the save throws [`RecordNotUnique`](./errors#constraint-errors). ::: ## Reading errors A record’s `errors` hold each failed rule: ```ts const account = Account.build({ email: "", handle: "a!" }); await account.isValid(); // false account.errors.get("email"); // ["can't be blank"] account.errors.fullMessages(); // ["Email can't be blank", "Handle is too short (minimum is 3 characters)", …] account.errors.toArray(); // [{ attribute: "email", type: "blank", message: "can't be blank" }, …] console.log(account.errors.size); // 5 ``` Printing `errors`, in the console or a log, lists each failure’s attribute, type, and message. `fullMessages()` puts the attribute’s name in front, so `createdAt` reads as “Created at”. Errors on the record as a whole go on `"base"`, and read without a prefix. ## Adding errors For a rule the validators don’t cover, add errors in a `beforeValidation` or `afterValidation` callback. A record with any errors is invalid: ```ts import { Model } from "@forestfuture/d1-record"; export class Booking extends Model({ id: { type: "integer", null: false }, startsAt: { type: "datetime", null: false }, endsAt: { type: "datetime", null: false }, }) { static { this.afterValidation((booking) => { if (booking.endsAt <= booking.startsAt) { booking.errors.add("endsAt", "must be after the start", { type: "afterStart" }); } if (booking.startsAt.getUTCDay() === 0) { booking.errors.add("base", "The room is closed on Sundays"); } }); } } ``` `errors.add` takes the attribute, or `"base"`, then the message that follows the attribute’s name. Pass a `type` when code needs to tell the error apart from others. It defaults to `"invalid"`: ```ts const sunday = new Date("2026-05-03T10:00:00Z"); const booking = Booking.build({ startsAt: sunday, endsAt: sunday }); await booking.isValid(); // false booking.errors.toArray(); // [{ attribute: "endsAt", type: "afterStart", message: "must be after the start" }, // { attribute: "base", type: "invalid", message: "The room is closed on Sundays" }] booking.errors.fullMessages(); // ["Ends at must be after the start", "The room is closed on Sundays"] ``` Each validation run, from `isValid()`, `save()`, or `saveOrThrow()`, starts by clearing `errors`. So an error added outside a validation callback is gone by the next save. ::: rails Compared with Rails Rails’ `errors.add(:ends_at, :after_start)` takes a type and looks up its message with I18n. d1-record has no I18n yet, so the message is always yours to give, and the type is an option. When Rails is given a message instead, that text is the type too; here the type is still `"invalid"`. Rails’ `message:` and `strict:` options and its procs aren’t here. ::: --- --- url: https://docs.forestfuture.dev/d1-record/guide/callbacks.md description: >- Registering callbacks, the order they run in, abort() to stop a save or destroy, and the callbacks not built yet. --- # Callbacks Callbacks run your code at points in a record’s lifecycle, such as before it’s validated or after it’s saved. ## Registering callbacks Register callbacks in a Model’s `static {}` block. Each receives the record, and may be async: ```ts import { abort, Model } from "@forestfuture/d1-record"; export class Order extends Model({ id: { type: "integer", null: false }, status: { type: "string", null: false, default: () => "pending" }, email: { type: "string", null: false }, totalCents: { type: "integer", null: false }, suspended: "boolean", placedAt: "datetime", }) { static { this.beforeValidation((order) => { order.email = order.email.trim().toLowerCase(); }); this.beforeCreate((order) => { order.placedAt = new Date(); }); this.beforeSave((order) => { if (order.suspended) throw abort("Orders can't change while suspended"); }); this.afterSave(async (order) => { if (order.hasSavedChangeTo("status", { to: "shipped" })) { await notifyShipped(order.email); } }); } } ``` Callbacks of one kind run in the order declared. A subclass runs its parent’s callbacks, then its own. ::: warning TIP Once a Model has been used, registering another callback throws `InvalidModel`, so request code can’t add one that leaks into later requests. ::: ## Callback order | Order | Callback | Runs | `abort()` | | -------------- | ------------------ | -------------------------------------------------------- | --------------------------------- | | **Saving** | | | | | 1 | `beforeValidation` | Before the validators, on every save, and on `isValid()` | Stops the save | | 2 | `afterValidation` | After the validators | Stops the save | | 3 | `beforeSave` | Before every write, new or persisted | Stops the save | | 4 | `beforeCreate` | Before inserting a new record | Stops the save | | 4 | `beforeUpdate` | Before updating a persisted record | Stops the save | | | *The write to D1* | Skipped when a persisted record has no changes | | | 5 | `afterCreate` | After inserting a new record | Too late: `AbortAfterPersistence` | | 5 | `afterUpdate` | After updating a persisted record | Too late: `AbortAfterPersistence` | | 6 | `afterSave` | After every successful save | Too late: `AbortAfterPersistence` | | **Destroying** | | | | | 1 | `beforeDestroy` | Before the `dependent` strategies and the DELETE | Stops the destroy | | | *The delete in D1* | With the dependents, in one batch | | | 2 | `afterDestroy` | After the record is destroyed | Too late: `AbortAfterPersistence` | A save runs either the create pair or the update pair, never both. * **After-callbacks** run after every successful save, even one with no changes to write. * **A before-callback that changes an attribute** makes the record dirty, and the change is written. * **An after-callback that changes an attribute** leaves the record dirty. Nothing is written again. ::: warning Nothing is rolled back D1 has committed a save’s writes before any after-callback runs. An error thrown by an after-callback reaches the caller, but the record stays saved. ::: ## Stopping a save Throw `abort(reason)` from a before-callback to stop a save or destroy: ```ts import { abort } from "@forestfuture/d1-record"; this.beforeSave((order) => { if (order.suspended) throw abort("Orders can't change while suspended"); }); ``` * **`save()`** resolves to `false` and writes nothing. The record keeps its changes, and `record.abortReason` holds the reason. * **`saveOrThrow()`** throws `RecordNotSaved` with the reason. * **`destroy()`** resolves to `false`, and **`destroyOrThrow()`** throws `RecordNotDestroyed`. Only `abort()` stops a save. Returning `false` does nothing, and any other error reaches the caller as it is. ::: warning TIP In an after-callback, the write has already happened, so `abort()` throws `AbortAfterPersistence`, naming the callback. ::: ## Not built yet These callbacks aren’t in d1-record yet. The code shows the shape they’re likely to take. * **Around callbacks** ([#48](https://github.com/forestfuture/d1-record/issues/48)): `this.aroundSave(async (record, proceed) => { …; await proceed(); … })`, and `aroundCreate`, `aroundUpdate`, and `aroundDestroy`. * **`afterInitialize` and `afterFind`** ([#49](https://github.com/forestfuture/d1-record/issues/49)): for every record built or loaded, and every record loaded. * **`record.touch()` and `afterTouch`** ([#50](https://github.com/forestfuture/d1-record/issues/50)): sets `updatedAt` without a full save. * **`on: "create"` and `on: "update"`** ([#51](https://github.com/forestfuture/d1-record/issues/51)): `this.validates("password", { presence: true, on: "create" })`. Until then, check `record.isNewRecord()` in an `if` option or a callback. ::: rails Compared with Rails There’s no `after_commit` or `after_rollback`. In Rails, `after_save` runs before the save’s transaction commits. Here, D1 has already committed before any after-callback runs, so every after-callback is already “after commit”. In a [batch](./batches#batching-records), they run once the whole batch is written. When D1 refuses a write, nothing was written, and the save throws the [constraint error](./errors#constraint-errors). Rails’ `if:` and `unless:` options for callbacks aren’t needed: write the condition inside the callback. ::: --- --- url: https://docs.forestfuture.dev/d1-record/guide/associations.md description: >- belongsTo, hasMany, and hasOne: inferred foreign keys, reading, preloading with includes, autosave, writing through associations, scopes, and dependent strategies. --- # Associations Associations connect Models: `belongsTo`, `hasMany`, and `hasOne`. ## Defining associations Declare each association as a field, passing the target Model’s class. The field’s name is the association’s name: ```ts import { belongsTo, hasMany, hasOne, Model } from "@forestfuture/d1-record"; export class Author extends Model({ id: { type: "string", null: false, generateId: true }, name: { type: "string", null: false }, }) { articles = hasMany(Article, { dependent: "destroy" }); // Article's authorId profile = hasOne(Profile, { dependent: "delete" }); // Profile's authorId } export class Article extends Model({ id: { type: "string", null: false, generateId: true }, authorId: { type: "string", null: false }, title: { type: "string", null: false }, }) { author = belongsTo(Author); // its own authorId comments = hasMany(Comment, { dependent: "deleteAll" }); // Comment's articleId } export class Profile extends Model({ id: { type: "integer", null: false }, authorId: { type: "string", null: false }, bio: "string", }) {} export class Comment extends Model({ id: { type: "integer", null: false }, articleId: { type: "string", null: false }, body: { type: "string", null: false }, }) {} ``` Models can refer to each other in any order. A subclass inherits its parent’s associations, and can redeclare one to change it. ## belongsTo Use `belongsTo` on the Model whose table holds the foreign key. A post belongs to its author: ```ts const article = await Article.find(articleId); const author = await article.author(); // an Author, or null article.author.set(editor); // points it at another author: written when the article is saved await article.save(); ``` * **`await record.author()`** reads the associated record, or `null`. * **`record.author.set(user)`** points the record at another record, and `set(null)` clears it. It’s written when the record is saved. * **`record.author.build(attrs)`** builds a record to point at. It’s validated with the record, and saved before it, so the foreign key can be filled in. * **`record.author.create(attrs)`** and **`createOrThrow(attrs)`** save a new record at once, and point at it. The record itself isn’t saved. | Option | Does | | ------------ | ------------------------------------------------------------------------------------------- | | `foreignKey` | The attribute on this record that holds the link. Defaults to the association’s name + `Id` | | `primaryKey` | The attribute on the target it points at. Defaults to the target’s primary key | | `scope` | Narrows the target, `(q) => q.where(…)`. See [Scoping associations](#scoping-associations) | ## hasMany Use `hasMany` on the Model the other table’s foreign key points at. A user has many posts: ```ts const author = await Author.find(authorId); const articles = await author.articles().toArray(); // a Relation of the author's articles author.articles().build({ title: "Untitled" }); // saved with the author, with its key await author.save(); await author.articles().where({ title: "Untitled" }).deleteAll(); // bulk writes, scoped to the author ``` * **`record.posts()`** is a Relation of the associated records, with every Relation method and the target’s scopes. * **`record.posts().build(attrs)`** builds a record with the owner’s key, saved with the owner. See [Autosaving](#autosaving). * **`record.posts().create(attrs)`** and **`createOrThrow(attrs)`** save a new record at once. The owner must already be saved. * **`insert`, `updateAll`, and `deleteAll`** write to the owner’s records. See [Writing many rows](#writing-many-rows). | Option | Does | | ------------ | ---------------------------------------------------------------------------------------------------------------- | | `foreignKey` | The attribute on the target’s records that holds the link. Defaults to this Model’s class name + `Id` | | `primaryKey` | The attribute on this record the foreign key points at. Defaults to the primary key | | `scope` | Narrows or orders the records, `(q) => q.order(…)`. See [Scoping associations](#scoping-associations) | | `dependent` | What destroying the owner does to the records. See [Destroying dependent records](#destroying-dependent-records) | ## hasOne Use `hasOne` like `hasMany`, when there’s at most one record. A user has one profile: ```ts const author = await Author.find(authorId); const profile = await author.profile(); // a Profile, or null if (profile === null) await author.profile.create({ bio: "Writer" }); // saved at once ``` * **`await record.profile()`** reads the associated record, or `null`. If several records point at the owner, it’s the first by the scope’s order, then the primary key. * **`record.profile.build(attrs)`** builds a record with the owner’s key, saved with the owner. * **`record.profile.create(attrs)`** and **`createOrThrow(attrs)`** save a new record at once. The owner must already be saved. `hasOne` takes the same options as `hasMany`. ## Naming foreign keys A foreign key names an attribute, such as `authorId`, rather than a column. By default, it’s inferred: * **`belongsTo`** uses the association’s name + `Id`. `author = belongsTo(Author)` uses the record’s own `authorId`. * **`hasMany` and `hasOne`** use the Model’s class name + `Id`, on the target. `Author`’s associations use `authorId`, `BlogPost`’s use `blogPostId`, and `APIKey`’s use `apiKeyId`. If the name doesn’t follow the convention, pass `foreignKey`: ```ts import { belongsTo } from "@forestfuture/d1-record"; export class Post extends ApplicationRecord("posts") { author = belongsTo(User, { foreignKey: "userId" }); } ``` Keys are checked the first time a Model is used, and a missing attribute throws `InvalidModel`. ::: details Bundlers and class names Wrangler keeps class names by default, even when minifying. If a bundler renames `Author` to `e`, the inferred `eId` doesn’t exist, and the first use of the Model throws `InvalidModel`, saying where the key came from. Pass `foreignKey`, or keep class names in your bundler’s settings. ::: ## Caching loaded records A loaded association is cached on the record. Reading `author()` again doesn’t query, and neither does `articles().toArray()` once the collection is loaded: ```ts const article = await Article.find(articleId); const author = await article.author(); // an Author, or null const profile = await author?.profile(); // a Profile, or null const latest = await author?.articles().first(); ``` * Changing a foreign key makes the next read query again. * `reload()` clears the cache, and `await record.reloadAssociation("articles")` reloads one association. ::: warning TIP A `belongsTo` whose foreign key is `null`, or an association on an unsaved owner, reads without a query. ::: ## Preloading associations Load an association for every record at once with `includes`, with one query per association however many records there are: ```ts const authors = await Author.includes("profile", { articles: "comments" }) // 4 queries, however many authors .toArray(); for (const each of authors) { const articles = await each.articles().toArray(); // no query: already loaded console.log(each.name, articles.length, each.loaded("profile")?.bio); } ``` Nest with an object: `includes({ articles: "comments" })` or `includes({ articles: ["comments", "tags"] })`. `record.loaded("profile")` reads a preloaded association synchronously, and throws `AssociationNotLoaded` if it wasn’t loaded, so a template never sends a query. `record.isLoaded("profile")` checks first. ## Creating related records Records built or created through a `hasMany` get the owner’s key: ```ts const comment = await article.comments().create({ body: "Lovely." }); // articleId filled in ``` ### Autosaving A record built through an association is saved with its owner. Build a whole graph, then save the owner: ```ts const ada = Author.build({ name: "Ada" }); ada.articles().build({ title: "Notes on the Analytical Engine" }); ada.articles().build({ title: "Sketch of the Engine" }); ada.profile.build({ bio: "Mathematician" }); await ada.saveOrThrow(); // the author, both articles and the profile, in one batch ``` The built records are validated with the owner. An invalid article makes the author invalid (“Articles is invalid”), with its own errors on the article. When the owner’s key is generated in code, as here, everything is written in one atomic batch. ::: warning Keys D1 generates When D1 generates the key, with an `INTEGER PRIMARY KEY`, the key doesn’t exist until the owner is written. So the owner is written first, and its records follow in the next batch, a level at a time. The batches aren’t atomic together: if a record fails at the second level, the save throws with the owner already saved. Generate keys in code wherever a graph must save all at once. ::: ## Scoping associations Narrow or order an association’s records with `scope`: ```ts import { hasMany, Model } from "@forestfuture/d1-record"; export class Blog extends Model({ id: { type: "integer", null: false } }) { // A scope for the association, as Rails' `has_many :posts, -> { … }`. recentPosts = hasMany(BlogPost, { scope: (q) => q.order({ createdAt: "desc" }).where({ draft: false }), }); } ``` The scope applies to reads and preloads alike, and records created through the association take its equality values. `q` is the target’s Relation, with its attributes and scopes. ::: warning TIP A scope that uses `limit`, `offset`, or `distinct` can’t be preloaded, since it would apply to every owner’s records together. `includes` throws `IncompatibleRelation`. ::: ::: details A Model’s association to itself, scoped with its own scopes TypeScript can’t infer this one case: `children = hasMany(Category, { scope: (q) => q.top() })` inside `Category`, where `top` is one of `Category`’s scopes. Write the field’s type out: ```ts import { belongsTo, hasMany, Model, type HasMany } from "@forestfuture/d1-record"; export class Category extends Model({ id: { type: "integer", null: false }, parentId: "integer", }) { static top = this.scope((q) => q.where({ parentId: null })); // Written out: TypeScript can't infer a field whose scope uses its own Model's scopes. children: HasMany = hasMany(Category, { foreignKey: "parentId", scope: (q) => q.top(), }); parent = belongsTo(Category, { foreignKey: "parentId" }); } ``` ::: ## Destroying dependent records Set what destroying the owner does to its records with `dependent`: | Strategy | What happens | | -------------------------- | --------------------------------------------------------------------------------------- | | `"destroy"` | Each record is destroyed with its own callbacks and dependents | | `"deleteAll"` / `"delete"` | The records are deleted in one statement, without callbacks (`"delete"` for a `hasOne`) | | `"nullify"` | Their foreign keys are set to `NULL` | | `"restrictWithException"` | The destroy throws `DeleteRestriction` while there are records | | `"restrictWithError"` | The destroy returns `false`, with an error on the owner’s `base` | Everything a destroy does, the owner’s DELETE included, is written in one atomic batch. Strategies run after the owner’s `beforeDestroy` callbacks, so an `abort()` leaves the records alone. ::: warning TIP Without a strategy, it’s up to the database. If the table declares the foreign key with `REFERENCES`, destroying an owner whose records still point at it throws `InvalidForeignKey`. ::: ## Writing many rows `insert`, `updateAll`, and `deleteAll` work through a `hasMany`, scoped to the owner’s records, and so do their `to…` forms for a batch: ```ts await article.comments().deleteAll(); // one DELETE for the article's comments ``` `deleteAll()` on the association follows its `dependent` strategy: it deletes the records for `"destroy"` or `"deleteAll"`, and otherwise sets their foreign keys to `NULL`. See [Bulk writes and batches](./batches). ## Not built yet These features aren’t in d1-record yet. The code shows the shape they’re likely to take. * **Associations through another** ([#59](https://github.com/forestfuture/d1-record/issues/59)): `projects = hasMany(Project, { through: "assignments" })`, a user’s projects reached through their assignments. * **Polymorphic associations** ([#60](https://github.com/forestfuture/d1-record/issues/60)): `commentable = belongsTo([Post, Photo])`, with `comments = hasMany(Comment, { as: "commentable" })` on each. * **Counter caches** ([#61](https://github.com/forestfuture/d1-record/issues/61)): `post = belongsTo(Post, { counterCache: true })`, keeping a `commentsCount` on each post, updated in the same atomic batch as the write. * **Inverse associations** ([#62](https://github.com/forestfuture/d1-record/issues/62)): after `await post.comments().toArray()`, each `comment.post()` returns that same post, without a query. --- --- url: https://docs.forestfuture.dev/d1-record/guide/batches.md description: >- Reference for bulk writes (insert, insertAll, upsert, updateAll, deleteAll) and atomic batch([...]) of their to… forms and records' toSave()/toDestroy(). --- # Bulk writes and batches Bulk writes change many rows in one SQL statement. A batch sends several writes to D1 at once, and they’re all written, or none of them. ## Writing many rows Call a bulk write on a Relation. It runs straight away, without validations or callbacks: ```ts import { column, sql } from "@forestfuture/d1-record"; import { Article, Comment } from "./models"; const deleted = await Comment.where({ articleId }).deleteAll(); // how many await Article.where({ id: articleId }).updateAll({ title: "Edited" }); await Article.where({ id: articleId }).updateAll( sql`${column("title")} = upper(${column("title")})`, ); const meta = await Comment.insert({ articleId, body: "First!" }); console.log(deleted, meta.last_row_id); ``` * **`updateAll(changes)`** takes attributes or an `sql` fragment, and resolves to the number of rows updated. It leaves `updatedAt` alone. With a `limit` or `offset`, the Relation’s order picks which rows are written. * **`deleteAll()`** resolves to the number of rows deleted. On `none()`, both send nothing and resolve to `0`. * **`insert(attrs)`** writes one row, and resolves to D1’s `meta`. Declared defaults, timestamps, and a Relation’s equality conditions fill what isn’t given. ### Inserting many rows Insert many rows in one statement with `insertAll`: ```ts import { Author, Comment } from "./models"; await Comment.insertAll([ { articleId, body: "One" }, { articleId, body: "Two" }, ]); await Author.upsert({ id: "author-1", name: "Ada" }); // insert, or update the name await Author.insert({ id: "author-1", name: "Ada" }, { onDuplicate: "skip" }); ``` * **`insertAll(rows)`** inserts every row, however many, up to D1’s 2 MB per bound value. Every row must have the same attributes. A duplicate throws and writes nothing. * **`{ onDuplicate: "skip" }`** skips rows that clash with a unique index. * **`upsert(attrs)`** and **`upsertAll(rows)`** update a row that clashes with the primary key, or with the unique index named by `uniqueBy`. Only the attributes you give are updated, and `updatedAt` is set. ::: rails Compared with Rails These are Rails’ `insert_all`, `upsert_all`, `update_all`, and `delete_all`, with two differences. Declared defaults fill what an insert isn’t given, since on D1 defaults are where keys are generated. And skipping duplicates is something you opt in to, so a duplicate is never silently lost. ::: ## Batching writes Each bulk write has a `to…` form that prepares it instead of running it: `toInsert`, `toInsertAll`, `toUpsert`, `toUpsertAll`, `toUpdateAll`, and `toDeleteAll`. Pass them to `batch` to write them atomically: ```ts import { batch } from "@forestfuture/d1-record"; import { Article, Author } from "./models"; const authorId = crypto.randomUUID(); // generated in code, so rows can point at it await batch([ Author.toInsert({ id: authorId, name: "Grace" }), Article.toInsert({ authorId, title: "Hello" }), Article.where({ authorId: "author-1" }).toDeleteAll(), ]); ``` `batch` resolves to each entry’s result, in order. * **Keys:** rows in one batch can only point at each other through keys that exist before it’s sent, so generate keys in code, with [`generateId`](./models#generating-ids). * **Inspecting:** a prepared write’s `toSql()` shows the SQL and values it will send, [without sending them](./querying#seeing-a-query-s-sql). * **Databases:** a batch runs on the database its entries were built on. Entries from two databases throw `IncompatibleRelation`. ::: warning TIP D1 can’t keep a transaction open across `await`s, so there’s no `transaction()` block. A batch is how you make writes atomic. ::: ## Batching records Add a record’s save or destroy to a batch with `toSave()` and `toDestroy()`. Its validations and callbacks still run: ```ts import { batch } from "@forestfuture/d1-record"; import { Article, Author, Comment } from "./models"; const article = await Article.find(articleId); article.title = "Final"; const author = Author.build({ name: "Hedy" }); const old = await Author.find(oldAuthorId); const [savedArticle, savedAuthor] = await batch([ article.toSave(), author.toSave(), old.toDestroy(), // with its dependents Comment.where({ articleId }).toDeleteAll(), ]); ``` When the batch runs: 1. Each record, in order, is validated and runs its before-callbacks. The first that’s invalid or aborts throws `RecordInvalid`, `RecordNotSaved`, or `RecordNotDestroyed`, and nothing is sent. 2. Every write goes to D1 as one atomic batch. If D1 refuses it with a constraint error, nothing is written, and every record keeps its unsaved state. 3. Each record takes its saved state, then its after-callbacks run. A record’s autosaved associations and `dependent` strategies join the same batch. A record listed twice, or already saved by its owner’s autosave, is written once. ::: rails Compared with Rails In a Rails transaction, one record’s `after_save` runs before the next record’s `before_save`. In a batch, every before-callback runs first. ::: Statements and records mix in one batch. From the [Getting started](./getting-started) project, this publishes every draft and destroys a user with their posts: ```ts import { batch } from "@forestfuture/d1-record"; import { Post } from "./models"; await batch([ Post.where({ publishedAt: null }).toUpdateAll({ publishedAt: new Date() }), user.toDestroy(), // and the user's posts, since posts are dependent: "destroy" ]); ``` --- --- url: https://docs.forestfuture.dev/d1-record/guide/connecting.md description: >- How a query finds its database: a Model's binding with no setup (User.find(id)), withDatabase for a request's Session or onQuery, or an explicit connect(env.DB). Read replicas, bookmarks, batches, and watching queries. --- # Connections A query finds its D1 database in one of three ways. Most apps only use the first. | | How | Use it for | | -------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------- | | **The binding** | `User.find(id)`, with nothing to set up | Everyday queries | | **The request’s database** | `withDatabase(connect(env.DB, …), fn)`, once per request | Read replicas with a Session, per-request `onQuery` logging, tests, and scripts | | **An explicit database** | `connect(env.DB).model(User).find(id)` | Code that passes its database around, or uses two at once | [Database resolution](./query-databases) explains why there are three, and [which one you need](./query-databases#which-do-i-need). ## Using the binding Each Model names the D1 binding it queries, so a Model queries by itself during any request: ```ts import { Post, User } from "./models"; // No setup: each Model queries its binding (static binding = "DB") during the request. const user = await User.findByOrThrow({ email: "ada@example.com" }); const posts = await Post.where({ userId: user.id }).order({ id: "desc" }).toArray(); ``` `d1-record schema` sets the binding on your app’s base class from your Wrangler config’s `d1_databases`, as `static override binding = "DB"`. Records remember their database, so `user.save()` and `user.posts()` use the same one later. Build records with `User.build(…)`: a record made with `new User()` has no database, and throws `NoDatabase` when it needs one. ::: warning TIP The binding is only there during a request, inside a Worker. Tests and scripts that run in Node get their database from `withDatabase`. See [Testing](./testing). ::: ## Using the request’s database Some things belong to one request, such as a D1 Session that reads its own writes, or logging tagged with the request’s ID. Open a database for the request, and run its code inside `withDatabase`: ```ts import { connect, withDatabase } from "@forestfuture/d1-record"; export default { fetch(request, env) { const db = connect(env.DB, { onQuery: log }); return withDatabase(db, () => app.fetch(request, env)); }, }; ``` Every query started from a Model inside it, and everything it awaits, uses that database instead of the binding. ::: warning Node.js compatibility `withDatabase` uses Workers’ `AsyncLocalStorage`. It’s on by default for compatibility dates from 2026-08-04. For an earlier date, add `"compatibility_flags": ["nodejs_compat"]` to your Wrangler config, or `withDatabase` throws `NoDatabase`. The rest of d1-record doesn’t need it. ::: These pages use a request’s database: * **[Read replicas](./read-your-own-writes):** a D1 Session per request. * **[Logging](./logging):** one request’s queries, tagged with its ID. * **[Testing](./testing):** a database for Models outside a Worker. * **[Multiple databases](./several-databases):** `withDatabase([...])`, matched to each Model’s binding. ## Using an explicit database Start a query from a database you hold with `db.model`. It ignores `withDatabase`: ```ts import { connect } from "@forestfuture/d1-record"; import { User } from "./models"; const db = connect(env.DB); const user = await db.model(User).findByOrThrow({ email: "ada@example.com" }); ``` To name your Models once, use `db.models`: ```ts import { Post, User } from "./models"; const models = db.models({ User, Post }); const posts = await models.Post.where({ userId: user.id }).toArray(); ``` `connect(env.DB, { session, onQuery })` takes the same options as a request’s database. ## Batching writes `batch` writes statements and records atomically, on the database they were built on: ```ts import { batch } from "@forestfuture/d1-record"; import { Post, User } from "./models"; const user = User.build({ email: "grace@example.com" }); const post = Post.build({ userId: user.id, title: "Hello" }); // Atomic: all written, or none. await batch([user.toSave(), post.toSave(), Post.where({ userId: "old" }).toDeleteAll()]); ``` Entries from a Model’s statics use the current database. `db.batch([...])` uses an explicit one. See [Bulk writes and batches](./batches). --- --- url: https://docs.forestfuture.dev/d1-record/guide/command-line.md description: >- Reference for d1-record's command line: d1-record schema and its options, d1-record console, the conventions from columns to attributes, d1-record g model and g migration, the column syntax, and what each command refuses. --- # Command line The command line generates `src/db/schema.ts` from your migrations, and writes new migrations and Models. Run it from the folder with your Wrangler config: ```sh npx d1-record --help ``` It needs Node.js 22.13 or later, for Node’s built-in SQLite. [Generators](./generators) shows it step by step. ## `d1-record schema` Generate the schema file from your migrations: ```sh npx d1-record schema ``` It applies your migrations, in order, to an in-memory SQLite database, and writes each table’s columns to `src/db/schema.ts`. | Option | | | -------------------- | -------------------------------------------------------------------------------------------------- | | `--check` | Write nothing: fail (exit 1) if the schema file is out of date. For CI. | | `--watch` | Regenerate whenever a migration changes. | | `--out ` | Write the schema here instead. With several databases, a folder for one file each. | | `--migrations ` | Read migrations from here instead of the Wrangler config’s folder (one database only). | | `--models ` | Where `application-record.ts` goes (`src/models/` by default). | | `--config ` | Use this Wrangler config instead of finding `wrangler.jsonc`, `wrangler.json`, or `wrangler.toml`. | **What it reads:** each entry of the Wrangler config’s `d1_databases`, with its `binding`, `migrations_dir` (default `migrations/`), `migrations_pattern` (for migrations in folders), and `migrations_table` (left out of the schema). Migrations apply in file-name order, as Wrangler applies them. **What it writes:** * **The schema file:** each table’s attributes, as a document, with a header naming the migrations and a fingerprint of the schema. `--check` compares fingerprints, so formatting the file doesn’t make it stale. * **The base class**, `application-record.ts`, when it’s missing: `ApplicationRecord = modelFor(tables, Base)`, with the binding set on `Base`. It’s never written over, so it’s yours to edit. * **With several databases:** one schema file per binding (`src/db/DB.ts`, `src/db/LOGS.ts`), and one base class each, named after it (`DB` → `DbRecord`, `LOGS` → `LogsRecord`). ::: rails Compared with Rails The schema file plays the part of Rails’ `schema.rb`, and each base class is named as Rails names a second database’s. ::: **What it refuses**, writing nothing: * A file it didn’t write (one without its “Generated by” header), such as a Drizzle schema. * A file beside a folder of the same name (`src/db/schema.ts` next to `src/db/schema/`), which would take over that folder’s imports. * Two bindings whose names would give the same base class (`DB` and `db`). * A migration SQLite rejects: the error names the file, and the last good schema is kept. ::: warning Coming from Drizzle or Prisma? Their schemas often live at `src/db/schema.ts` or `src/db/schema/`. Write d1-record’s somewhere else, with `--out src/db/d1-record.ts`, and, if `application-record.ts` already exists, point its `import { tables }` at it (a new one is written importing the right file). ::: ## `d1-record console` Open your database with your Models in a console, or `d1-record c` for short ([Console](./console) shows a session). It opens the local one unless you pass `--remote`: ```sh npx d1-record console ``` | Option | | | ------------------- | ------------------------------------------------------------------------------------------ | | `--database ` | The D1 binding to open, when the config has several. | | `--models ` | Where the Models are: a folder (`src/models`) or one file (`src/models.ts`). | | `--config ` | Use this Wrangler config instead of finding `wrangler.jsonc`, `wrangler.json`, or `.toml`. | | `--no-history` | Don’t keep what you type in `.wrangler/d1-record-history`. | | `--verbose` | Print each statement a line runs. `.verbose` turns it on and off in the console. | | `-e, --eval ` | Run this code, print its result (alone, on stdout), and exit; a failure exits 1. | | `--remote` | Open your remote database instead, after you type its name. | | `--yes` | With `--remote`, don’t ask first (also how to open it without a terminal). | | `--history` | Keep what you type in a remote session, which isn’t kept unless you ask. | It reads TypeScript Models and uses your project’s own `wrangler` and `@forestfuture/d1-record`. It refuses, printing why, when there’s no Wrangler config, with several databases and no `--database`, with no Models, with `wrangler` or the library not installed, or with `--remote` when the binding has no `database_id`, or when you don’t confirm it (or aren’t on a terminal and didn’t pass `--yes`). `--yes` only goes with `--remote`, `--history` can’t be combined with `--no-history`, and `-e` takes one piece of code, once. ## Mapping columns to attributes Each column becomes an attribute by the first of these that applies: 1. **An override in the Model:** `ApplicationRecord("products", { available: "boolean" })`. 2. **The column’s declared type:** | Declared as | Attribute | | --------------------------------------------------- | -------------------------------------------------------------- | | `INTEGER`, `INT`, `BIGINT`, … (anything with `INT`) | `integer` | | `TEXT`, `VARCHAR(…)`, `CHAR(…)`, `CLOB` | `string` | | `REAL`, `FLOAT`, `DOUBLE` | `real` | | `BOOLEAN` | `boolean` | | `DATETIME`, `TIMESTAMP`, `DATE` | `datetime` | | `TEXT` with `CHECK (json_valid(column))` | `json` | | `JSON` | `json` | | `NUMERIC`, `DECIMAL` | An error: store money as integer cents (`price_cents INTEGER`) | | `BLOB` | An error: not supported yet | | anything else | `string` | 3. **The column’s name**, only for a column declared exactly `TEXT` or `INTEGER`: `…_at` `TEXT` is a `datetime`, and `is_…` or `has_…` `INTEGER` is a `boolean` (with a note, since it’s a guess). Also: * **Names:** `price_cents` becomes `priceCents`. A column whose name doesn’t come back the same from camelCase gets `column: "…"`. * **`NOT NULL`** becomes `null: false`. A primary key is never null. * **Primary keys:** the key column is the Model’s `primaryKey`. A `TEXT` key gets `generateId: true`, so the Model’s [`generateId`](./models#generating-ids) makes it; an `INTEGER PRIMARY KEY` is left to D1. A table with no key, or a key of several columns, is an error. * **Defaults:** literal defaults (numbers, strings, `TRUE`/`FALSE`, JSON) are copied, so a new record shows them before it’s saved. Expressions such as `CURRENT_TIMESTAMP` are left to D1. * **Foreign keys** take the referenced column’s type. They don’t create associations, which are behavior you declare. * **Left out:** SQLite’s own tables, Cloudflare’s (`_cf_…`), the migrations table, and virtual tables. **Notes it prints** when the schema is written: * an `INTEGER` read as a `boolean` from its name; * for migrations written by Drizzle or Prisma (recognized by their config files or the lines they write): a note naming the tool, then one for each column worth checking, such as an `INTEGER` named `…_at` that may hold a Unix timestamp, a `TEXT` named like JSON without a `json_valid` check, or, for Prisma, each `datetime`; * migrations in folders with no `migrations_pattern` to find them. ## `d1-record g model` and `g migration` ```sh npx d1-record g model [columns] [options] npx d1-record g migration [columns] [options] ``` `generate` is the same as `g`. | Option | | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `--id integer\|text` | `integer`: an `INTEGER PRIMARY KEY` that D1 generates. `text` (the default): a `TEXT` key generated in code. | | `--no-timestamps` | Leave out `created_at` and `updated_at`. | | `--models ` | Write Models in this folder (importing `application-record.ts` from it), or append them to one file (`src/models.ts`). | | `--database ` | The D1 database to use, when the Wrangler config has several. | | `--out ` | The schema file to regenerate, as for `d1-record schema` (for a Drizzle project’s `src/db/d1-record.ts`, say). | | `--config ` | Use this Wrangler config. | ### Column syntax `name[:type][!][=value][:unique|:index]` | Type | SQL | | ---------------------- | ------------------------------------------------------------------------------------------- | | `string` (the default) | `TEXT` | | `integer` | `INTEGER` | | `real` | `REAL` | | `boolean` | `BOOLEAN` | | `datetime` | `DATETIME` | | `json` | `TEXT CHECK (json_valid(column))` | | `references` | `_id`, typed like the referenced table’s key, `REFERENCES (id)`, and indexed | * **`!`** makes the column `NOT NULL`. A `references` column is `NOT NULL` in a new table, and can be `NULL` when added to an existing one. * **`=value`** is a literal default, for `string`, `integer`, `real`, and `boolean` columns (`=draft`, `=0`, `=1.5`, `=true`). * **`:unique`** adds a unique index, and **`:index`** an index, named like `index_products_on_sku`. * **Names** can be written `priceCents` or `price_cents`; columns are always snake_case. Names SQLite reserves (`order`, `group`) are quoted. ::: rails Beyond Rails `=value` is d1-record’s own addition: Rails’ generators can’t give a column a default. ::: ### Migration names | Name | Writes | | ----------------------------- | ---------------------------------------------------------------------------- | | `Create` | `CREATE TABLE`, as `g model` does | | `AddTo
` | `ADD COLUMN` for each column, then their indexes | | `RemoveFrom
` | `DROP INDEX IF EXISTS` for each column’s generated index, then `DROP COLUMN` | | anything else | An empty migration, to write yourself | A name can be written `AddSkuToProducts` or `add_sku_to_products`, and the table in it is made plural (`CreateWidget` creates `widgets`). A Model’s name is made singular, with a note (`g model line_items` writes `LineItem`). Names are letters, digits, and `_`, starting with a letter. **File names** are numbered as Wrangler numbers them: the highest number in the migrations folder plus one, in four digits (`0008_add_sku_to_products.sql`). ### What’s checked first Nothing is written unless all of these hold: * the new migration applies after the existing ones (in memory, as `d1-record schema` applies them); * the Model’s file doesn’t exist yet (or, with one models file, doesn’t have the class yet); * the config’s `migrations_pattern`, if it has one, would match the new file, so Wrangler would apply it; * the schema file can be written (see [What it refuses](#d1-record-schema)); * it isn’t a required column added without a default. Then the generator writes the files, regenerates the schema, and prints the next step: `npx wrangler d1 migrations apply --local`. ## Exit codes Every command exits with `0` on success, and `1` when anything was refused or failed, or, with `--check`, when the schema is out of date. Messages go to standard output, and say what to do next. --- --- url: https://docs.forestfuture.dev/d1-record/guide/errors.md description: >- Every error d1-record throws: lifecycle, usage, and constraint errors, and when each is raised. --- # Errors Every error d1-record throws extends `D1RecordError`, so one check separates them from anything else: ```ts import { D1RecordError } from "@forestfuture/d1-record"; if (error instanceof D1RecordError) { // one of d1-record's own } ``` ## Lifecycle errors | Error | When | | ----------------------- | ------------------------------------------------------------------------ | | `RecordNotFound` | `find`, `findByOrThrow`, or `reload` found nothing | | `RecordInvalid` | an `OrThrow` save found the record invalid; the record is on `.record` | | `RecordNotSaved` | a save was aborted by a callback, or needs an owner that isn’t saved | | `RecordNotDestroyed` | a destroy was aborted, or restricted by `restrictWithError` | | `RecordDestroyed` | writing to a record that’s already destroyed | | `CallbackAbort` | what `abort()` makes; caught by the lifecycle | | `AbortAfterPersistence` | `abort()` in an after-callback, too late to stop the write (`.callback`) | ## Usage errors A usage error means the code needs to change: | Error | When | | ---------------------- | ------------------------------------------------------------------------------- | | `InvalidModel` | a Model’s declaration is wrong, detected the first time it’s used | | `UnknownAttribute` | an attribute that isn’t declared, in `build`, `where`, `update`, … | | `MissingAttribute` | reading an attribute that `select` didn’t load | | `UnknownAssociation` | an association that isn’t declared | | `AssociationNotLoaded` | `loaded()` on an association that wasn’t loaded | | `IncompatibleRelation` | two Relations or writes can’t be used together (below) | | `IrreversibleOrder` | `last()` on a Relation ordered by an `sql` fragment | | `DeleteRestriction` | destroying an owner whose `restrictWithException` association still has records | | `NoDatabase` | using a record made with `new` rather than `build` | | `NoSession` | `db.bookmark()` on a Database opened without a session | ::: warning TIP A name from a request, such as `?include=` or a JSON key, only ever matches the Model’s own attributes and associations; `constructor` and `__proto__` are unknown like any other. Return a 400 for these errors. ::: ### When Relations are incompatible `IncompatibleRelation` is thrown for combinations that can’t make a correct query or write: * **`or()`, `merge()`, or a scope** with a Relation of another Model, or one that resolves to another database when it runs. `or()` also needs both sides to have the same `select`, order, `limit`, `offset`, `distinct`, and `includes`, since only their conditions can be combined. * **`batch([...])`, `db.batch([...])`, `save()`, or `destroy()`** with statements or records from two databases, such as a `belongsTo` target built on another `connect()`. A write runs on one database. The message names where each database came from. * **`includes`** with `select(...).distinct()`. Preloading needs the keys, and adding them would undo the `distinct`. * **Preloading an association scope** that uses `limit`, `offset`, or `distinct`. One preload query serves every owner, so the limit would apply to all of their records together. * **`updateAll` or `deleteAll`** after `distinct()`: they reject with it, and `toUpdateAll`/`toDeleteAll` throw it. ## Constraint errors When D1 refuses a write because of a constraint, it throws a subclass of `ConstraintViolation`: | Error | Constraint | | ------------------- | ----------------------- | | `RecordNotUnique` | `UNIQUE`, `PRIMARY KEY` | | `InvalidForeignKey` | `FOREIGN KEY` | | `NotNullViolation` | `NOT NULL` | | `CheckViolation` | `CHECK` | Each has D1’s original error as `cause`, the `table`, and, where D1’s message names them, the `columns` and `attributes`: ```ts import { RecordNotUnique } from "@forestfuture/d1-record"; try { await user.save(); } catch (error) { if (error instanceof RecordNotUnique && error.attributes.includes("email")) { user.errors.add("email", "has already been taken", { type: "taken" }); } else { throw error; } } ``` Every method throws them, `save()` as well as `saveOrThrow()`. The record keeps its changes, so you can fix them and save again. --- --- url: https://docs.forestfuture.dev/d1-record/guide/generators.md description: >- Add a table with its Model, change a table, write columns, reference the same table, keep Models in another folder, and keep the generated schema current. --- # Generators Generators write a table’s migration and its Model from a short description, then regenerate `src/db/schema.ts`. The [Command line](./command-line) reference lists every option. ## Adding a table Name the Model, and list its columns, with `g model`: ```sh npx d1-record g model Product 'name:string!' 'priceCents:integer!=0' user:references ``` ``` Wrote migrations/0003_create_products.sql Wrote src/models/product.ts Wrote src/db/schema.ts (3 tables) from migrations/ Next: npx wrangler d1 migrations apply my-app-db --local ``` Then apply the migration to your local database: ```sh npx wrangler d1 migrations apply my-app-db --local ``` The table is `products`, with an `id` generated in code, `created_at`, and `updated_at`. * **`--id integer`** gives it a key D1 generates instead. * **`--no-timestamps`** leaves out the timestamps. ::: rails Compared with Rails Keys are UUIDs, not auto-incrementing integers. With `--id integer`, a record and the records built through it no longer save atomically. See [Autosaving](./associations#autosaving). ::: Use `--remote` instead of `--local` when you deploy. ## Writing columns Each column is `name:type`, with optional marks: | Column | Means | | ---------------------- | -------------------------------------------------------------------------------- | | `title` | A `string`, the default type | | `email:string!:unique` | Required (`NOT NULL`), with a unique index | | `count:integer!=0` | Required, with a default of `0` | | `settings:json` | JSON, checked by D1 (`CHECK (json_valid(settings))`) | | `user:references` | `user_id`, pointing at `users`, indexed; the Model gets `user = belongsTo(User)` | The [column syntax](./command-line#column-syntax) lists every type. ::: warning TIP Quote columns that contain `!`, so bash and zsh leave it alone. ::: ::: rails Beyond Rails Rails’ generators have no syntax for a column’s default. d1-record adds `=value` (`=0`, `=true`, `=draft`), because SQLite can’t add a required (`NOT NULL`) column to an existing table without one. ::: ## Changing a table Write a migration with `g migration`. It reads what to do from the migration’s name: ```sh npx d1-record g migration AddSlugToPosts slug:string:unique # ADD COLUMN, with a unique index npx d1-record g migration RemoveSlugFromPosts slug # DROP COLUMN npx d1-record g migration BackfillSlugs # an empty migration to write yourself ``` The name can be written `AddSlugToPosts` or `add_slug_to_posts`. Apply each migration afterwards, as above. SQLite can’t add a required column without a default, so the generator refuses one. Give it a default, `slug:string!=draft`, or leave out the `!`. ::: warning A default and a unique index don’t mix on a table with rows `g migration AddSlugToPosts 'slug:string!=draft:unique'` is accepted, but applying it to a table with more than one row fails: every row gets `draft`, which the unique index refuses. Add the column without the index, fill it in, then add the index in a later migration. ::: ## Referencing the same table `g model Category parent:references` points `parent_id` at a `parents` table and writes `parent = belongsTo(Parent)`, since it can’t know the parent is another category. Edit both files before applying the migration: 1. In the migration, point the column at `categories`, and make it optional, since top-level categories have no parent: ```sql parent_id TEXT REFERENCES categories (id), ``` 2. In the Model, use `Category` itself, and add the other direction if you want it. ```ts import { belongsTo, hasMany } from "@forestfuture/d1-record"; export class Category extends ApplicationRecord("categories") { parent = belongsTo(Category); // foreign key: parentId, from the association's name children = hasMany(Category, { foreignKey: "parentId" }); // not the default, categoryId } ``` ::: rails Compared with Rails Rails writes `belongs_to :parent, class_name: "Category", optional: true`. Here the association takes the class itself, so there’s no `class_name`, and whether a parent is required comes from the column: `parentId` can be `null` because the migration allows it. ::: ## Keeping Models in another folder By default, Models and `application-record.ts` go in `src/models/`. To keep them elsewhere, pass `--models` to every command: ```sh npx d1-record schema --models src/app/models npx d1-record g model Product 'name:string!' --models src/app/models ``` ::: warning TIP `d1-record schema` writes `application-record.ts` there, and the generators write Models that import it, so both need `--models`. A `package.json` script saves retyping it: `"db:schema": "d1-record schema --models src/app/models"`. ::: ## Keeping the schema current The generators regenerate `src/db/schema.ts` themselves. After writing or editing a migration by hand, regenerate it: ```sh npx d1-record schema ``` * **`--watch`** regenerates it whenever a migration changes. * **`--check`** fails when the committed schema is out of date. Run it in CI. --- --- url: https://docs.forestfuture.dev/d1-record/guide/console.md description: >- How to open a console on your D1 database with your Models loaded: d1-record console, what is in scope, its options, opening the remote database with --remote, and where its data and history live. --- # Console Open a console on your local database, with your Models loaded, to try a query or fix a record by hand, or on your remote one with [`--remote`](#opening-your-remote-database). Run it from the folder with your Wrangler config: ```sh npx d1-record console ``` It prints which database and Models it opened, then waits for a line: ```text Local database DB (.wrangler/state) Models: Post, User local> const user = await User.find("c5c765b4-b7e1-428e-aa00-88d58f622c71") #=> undefined local> user #=> User { id: 'c5c765b4-b7e1-428e-aa00-88d58f622c71', name: 'Alice', email: [FILTERED] } local> await user.update({ name: "Johnny" }) #=> true local> const admins = await User.where({ role: "admin" }).toArray() #=> undefined ``` Leave with `quit`, `exit`, or Ctrl-D. Ctrl-C stops a line that’s still running. `.help` lists the console’s own commands, and `npx d1-record c` is the short form. ## What you can use * **Your Models,** by class name, from every file in `src/models`. They query your database, local unless you pass `--remote`, with no `db` to pass. * **`db`,** your connected database, for `db.batch([...])` and `db.model(...)`. * **Top-level `await`,** and `const` and `let`, which last until you leave. A record prints as it does in a log: its class and attributes, with a filtered attribute as `[FILTERED]` and an unsaved change next to the value it had. [Printing records](./models#printing-records) has the details. An error prints and the console carries on. ## Reloading your Models After you edit a Model, or a file it imports, read them again without leaving: ```text > reload! Reloaded: Post, User ``` `.reload` does the same. A Model you added appears, and one whose file you removed goes. Records and values you already hold keep their old class, so run the lines that made them again. If a file fails to load, say a syntax error in the middle of an edit, the console names the file and keeps the Models you had. ::: warning TIP Only files in your project’s folder are read again. Your database connection and `@forestfuture/d1-record` stay as they are. ::: ## Seeing the SQL Start with `--verbose` to see each statement a line runs, or turn it on and off while you work with `.verbose`: ```text > .verbose SQL logging on > await User.find(1) User 1.2ms SELECT * FROM "users" WHERE (("id" = ?)) LIMIT ? [ 1, 1 ] #=> User { id: 1, name: 'Alice', email: [FILTERED] } ``` Each line shows the Model, the time, the SQL, and the values it bound. Filtered attributes show as `[FILTERED]`, as they do in `onQuery`. A statement that fails prints `failed` and the reason in place of the time. `verbose!` toggles it too, and `.verbose on` and `.verbose off` set it explicitly. ::: warning TIP The SQL comes from the database’s own `onQuery`, so a listener you set on your base class still runs as well. ::: ## Choosing what to open | Option | | | ------------------- | ------------------------------------------------------------------------------------------------------- | | `--database ` | The D1 binding to open, when your Wrangler config has several. | | `--models ` | Where your Models are: a folder (`src/models` by default) or one file (`src/models.ts`). | | `--config ` | Use this Wrangler config instead of finding `wrangler.jsonc`, `wrangler.json`, or `.toml`. | | `--no-history` | Don’t keep what you type in the history file. | | `--verbose` | Print each statement a line runs, to begin with. `.verbose` turns it on and off. | | `-e, --eval ` | Run this code, print its result, and exit. See [Running one piece of code](#running-one-piece-of-code). | | `--remote` | Open your remote database instead, after you type its name. See below. | | `--yes` | With `--remote`, don’t ask first. It’s also how to open it without a terminal. | | `--history` | Keep what you type in a remote session, which isn’t kept unless you ask. | ## Where your data lives The console opens the local database in `.wrangler/state`, beside your Wrangler config. It’s the same one `wrangler dev` and `wrangler d1 migrations apply --local` use, so apply your migrations first, and what you change in the console is what your dev server sees. It opens your remote database only when you ask with `--remote`, and never for a binding your config marks `remote: true` by itself. It starts only that D1 binding. The rest of your Wrangler config (Durable Objects, services, `vars`, and the secrets in `.dev.vars`) isn’t started, so it can’t warn or fail on them, and your Models can’t reach them either. On a terminal, what you type is kept in `.wrangler/d1-record-history`, which Wrangler projects already ignore. A line can hold a token or an email, so add `--no-history` when you’d rather it wasn’t kept. ## Opening your remote database `--remote` opens the real database your binding points at, to look at production data or fix one record by hand: ```sh npx d1-record console --remote ``` It names the database and waits for you to type its name. Anything else, including `y`, cancels and opens nothing. Anything you type or paste after the name is dropped, so a pasted block can’t run on the real database before you’ve seen the prompt: ```text Remote database DB (shop, id 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d) What you change in the console is changed in this database, not a copy. Type its name to continue, anything else cancels: shop ``` Once it’s open, the prompt says `remote>` (in red on a terminal) where a local session says `local>`, so the two can’t be mistaken for each other: ```text Remote database DB (shop, id 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d) Models: Post, User remote> await User.count() #=> 42 ``` ::: warning Changes can’t be undone A change is made to the real database, for good. `await user.destroy()` deletes that row from your remote database. ::: The console signs in with your Wrangler login, or with `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` in the environment, and takes the account from `account_id` in your Wrangler config when it names one. When Cloudflare refuses, the console prints its reason and how to sign in. There’s no one to type the name unless both your input and output are a terminal, so a piped session refuses unless you add `--yes`, which is how a script opens it: ```sh echo 'await User.count()' | npx d1-record console --remote --yes ``` What you type isn’t kept unless you add `--history`, and then it goes in `.wrangler/d1-record-history-remote`, a file of its own, so the up arrow never offers a local session a line you typed against the real database. ::: warning TIP The binding needs a `database_id`. If it also has a `preview_database_id`, Wrangler opens that one in place of `database_id`, and the console says so: `Remote preview database DB (…)`. ::: ::: warning TIP A token needs **D1: Edit** and **Workers Scripts: Edit**, and the account needs a `workers.dev` subdomain, as `wrangler dev` needs for a remote binding. ::: ## Running one piece of code `-e` runs one piece of code with your Models and `db` in scope, prints its result, and exits, which is how to use the console from a script: ```sh npx d1-record console -e 'await User.count()' ``` ```text 42 ``` * **The result is on stdout.** A string prints as it is, without quotes, so `$(…)` captures it, and anything else prints as the console prints it, without `#=> `. `undefined` prints nothing. What your code logs with `console.log` comes first, and `console.error` goes to stderr, as in `node -e`. * **Everything else is on stderr:** which database and Models it opened, the SQL with `--verbose`, and a refusal to start. * **A failure exits 1.** Its stack goes to stderr, no result goes to stdout, and no statement after it runs. Success exits 0. * **Join statements with `;`.** The last value is the result: `-e 'await User.create({ name: "Dan" }); await User.count()'`. * **It keeps no history,** ignores what is piped to it, and has no time limit: stop it with Ctrl-C, or run it under `timeout`. ::: warning TIP Await every call. `User.find(1)` without `await` prints `Promise { }`, and a promise you don’t await may not finish before the console exits, and its failure isn’t reported. ::: ::: warning TIP Put the code in single quotes, so your shell leaves `$`, `!`, and backticks alone. With `--remote`, `$(…)` makes stdout a pipe, so there is no terminal to ask: add `--yes`, as in `npx d1-record console --remote --yes -e 'await User.count()'`. ::: ## Running a few lines at once Lines you paste or pipe run in order, each after the one before, and the console finishes them before it exits: ```sh echo 'await User.count()' | npx d1-record console ``` That prints the same transcript a session does (the database, a prompt before each line, `#=> ` before each result) and exits 0 even when a line fails. For a script that checks the result or the exit code, use `-e`. ::: warning TIP The console reads your TypeScript Models directly, including extensionless imports and `tsconfig.json` paths, so it works in TypeScript projects. It needs Node.js 22.13 or later, and `wrangler` and `@forestfuture/d1-record` installed in your project. It uses your copies, so your Models and the console share one library. ::: ::: warning Only in projects you trust The console runs your project’s code: your Models, and the `wrangler` and `@forestfuture/d1-record` in its `node_modules`. Run it in a project you’d run `wrangler dev` in. ::: ::: rails Compared with Rails This is `rails console` for D1: your Models in scope, and records printed as `inspect` prints them. `-e` is `rails runner` with the last value printed, which `rails runner` leaves to you. ::: ::: rails Beyond Rails `rails console` opens whichever environment you start it in, without asking. `--remote` asks you to type the database’s name first, and its prompt says `remote>`. ::: --- --- url: https://docs.forestfuture.dev/d1-record/guide/change-a-table.md description: >- Add, rename, and remove columns with migrations, deploy them in the right order, and change a column's type by rebuilding the table. --- # Schema changes Change a table with a new migration. Wrangler applies each migration once, in order. The schema is regenerated after each change, so a Model’s attributes follow its table. ## Adding a column Generate the migration, then apply it: ```sh npx d1-record g migration AddBioToUsers bio:string npx wrangler d1 migrations apply my-app-db --local ``` ```sql [migrations/0003_add_bio_to_users.sql] ALTER TABLE users ADD COLUMN bio TEXT; ``` `user.bio` is there straight away, typed as `string | null`. A required column needs a default for the existing rows: `'role:string!=member'`. To deploy, apply the migration with `--remote` first, then deploy the code that uses the column. The code already deployed keeps working, since it only writes the attributes it knows. ## Renaming a column Generate an empty migration, and write the rename: ```sh npx d1-record g migration RenameBioToAbout ``` ```sql [migrations/0004_rename_bio_to_about.sql] ALTER TABLE users RENAME COLUMN bio TO about; ``` The attribute follows the column, so `bio` becomes `about`. Update the code that uses it, then apply the migration and deploy together, since the old code reads a column that’s gone. ::: warning TIP Keeping the old attribute name for a renamed column is planned in [#65](https://github.com/forestfuture/d1-record/issues/65). ::: ## Removing a column Remove a column in the opposite order to adding one, so the code stops using it before it goes: 1. Ignore the column in the Model, remove the code that uses it, and deploy: ```ts export class User extends ApplicationRecord("users", { bio: false }) {} ``` 2. Generate the migration, and apply it with `--remote`: ```sh npx d1-record g migration RemoveBioFromUsers bio ``` 3. Remove `{ bio: false }`, now that the schema no longer has the column. Remove a `references` column the same way, with `RemoveUserFromPosts user:references`. Its index is dropped first, then the column. ## Changing a column’s type SQLite can’t change a column’s type in place. Write a migration that builds a new table, copies the rows, and swaps the tables, as [SQLite describes](https://www.sqlite.org/lang_altertable.html#otheralter): ```sql [migrations/0005_make_price_cents_an_integer.sql] PRAGMA defer_foreign_keys = true; CREATE TABLE products_new ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, price_cents INTEGER NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); INSERT INTO products_new SELECT id, name, CAST(price_cents AS INTEGER), created_at, updated_at FROM products; DROP TABLE products; ALTER TABLE products_new RENAME TO products; ``` * **`PRAGMA defer_foreign_keys = true`** lets the swap happen while other tables point at this one, [as Cloudflare recommends](https://developers.cloudflare.com/d1/reference/migrations/). * **Indexes** are dropped with the old table, so recreate them after the swap. * **The schema** needs regenerating with `npx d1-record schema`, since you wrote this migration by hand. ::: warning TIP Wrangler backs up the database before applying migrations to it. ::: --- --- url: https://docs.forestfuture.dev/d1-record/guide/adopt-a-database.md description: >- Use d1-record with a D1 database that Drizzle or Prisma manages, or one with no migrations at all: point it at the migrations, read its notes, and fix columns with overrides. --- # Existing databases d1-record reads a database’s tables from its migrations. If your database already has migrations, from Drizzle, Prisma, or Wrangler, point d1-record at them. If it doesn’t, start from a snapshot of its schema. ## Using Drizzle’s migrations Drizzle writes its migrations to `drizzle/`, its `out` setting. Tell Wrangler where they are: ```jsonc [wrangler.jsonc] { "d1_databases": [ // Migrations as drizzle/0000_name.sql: { "binding": "DB", "database_name": "my-app-db", "migrations_dir": "drizzle" }, // Or, as drizzle/_name/migration.sql: // { …, "migrations_pattern": "drizzle/*/migration.sql" }, ], } ``` Drizzle’s own schema often lives at `src/db/schema.ts` or in `src/db/schema/`, where d1-record writes its schema by default. Generate d1-record’s elsewhere: ```sh npx d1-record schema --out src/db/d1-record.ts ``` If `src/models/application-record.ts` already exists, point it at the new file with `import { tables } from "../db/d1-record";`. A new one imports it already. Pass `--out` to every later `schema` and `g` command, or keep it in a `package.json` script. ::: warning TIP Without `--out`, d1-record refuses to write over a file it didn’t write, or beside a folder of the same name, so Drizzle’s schema is safe. ::: ## Using Prisma’s migrations With D1, Prisma’s migrations are usually Wrangler’s own: files in `migrations/`, written by `prisma migrate diff`. `npx d1-record schema` reads them as they are. Prisma names tables after its models, such as `User`, and columns in camelCase, such as `createdAt`. d1-record keeps both names, so the Model is `ApplicationRecord("User")`, with attributes such as `createdAt`. ## Starting with no migrations For a database built by hand, or by `drizzle-kit push`, export a snapshot of its schema as the first migration, and mark it as applied: ```sh # The tables, without their data, as a first migration: npx wrangler d1 export my-app-db --remote --no-data --output migrations/0001_baseline.sql # Create Wrangler's record of applied migrations, and mark the snapshot as applied, # so Wrangler never runs it against the database it came from: npx wrangler d1 migrations list my-app-db --remote npx wrangler d1 execute my-app-db --remote --command "INSERT INTO d1_migrations (name) VALUES ('0001_baseline.sql')" ``` From then on, change tables with new migrations. See [Schema changes](./change-a-table). ## Checking the generated attributes Other tools can store values differently, so a column can become the wrong attribute type without an error. When `d1-record schema` recognizes Drizzle’s or Prisma’s migrations, it prints a note for each column worth checking: ``` Note: these migrations look like Drizzle's. Some columns may convert to the wrong type without an error, especially numeric timestamps: check the generated types (…) Note: posts.metadata: read as a string; if it holds JSON, override it as "json" in its Model ``` Fix a column’s type with an override in its Model. It changes how the column is read and written, never the table: ```ts export class Post extends ApplicationRecord("posts", { metadata: "json", // text({ mode: "json" }): JSON in a plain TEXT column archived: "boolean", // integer({ mode: "boolean" }): 0 or 1 legacyNotes: false, // a column this app no longer uses: never read or written }) {} ``` ::: warning Numeric timestamps Drizzle’s `integer({ mode: "timestamp" })` stores Unix seconds, and Prisma may store a `DateTime` as a number. d1-record’s `datetime` reads and writes ISO-8601 text, so it can’t read those yet. Leave such columns as `integer`, and convert with `new Date(seconds * 1000)` where you need a `Date`. Reading numeric timestamps as dates is planned in [#73](https://github.com/forestfuture/d1-record/issues/73). ::: --- --- url: https://docs.forestfuture.dev/d1-record/guide/shared-behavior.md description: >- Write methods, callbacks, and scopes once, on your app's base class, and every Model has them. --- # Shared behavior Every Model extends your app’s `ApplicationRecord`, so whatever you write on its base class, every Model has. ## Sharing methods, callbacks, and scopes Write them on `Base`, in `src/models/application-record.ts`: ```ts import { Model, modelFor } from "@forestfuture/d1-record"; import { tables } from "../db/schema"; // What's written on Base, every Model has. class Base extends Model.Base { label() { return `${this.constructor.name} ${JSON.stringify(this.attributes())}`; } static { this.beforeSave((record) => { console.log(`Saving ${record.constructor.name}`); // before each Model's own callbacks }); } } export const ApplicationRecord = modelFor(tables, Base); // Every Model built on it has label(), and Base's beforeSave. export class Account extends ApplicationRecord("billing_accounts") {} ``` Each is shared like this: | On `Base` | Every Model | | ---------------------------------------- | ------------------------------------------------------ | | A method, such as `label()` | has it, on every record | | A static method | has it, on the Model | | A callback, in `static {}` | runs it, before the Model’s own callbacks of that kind | | A scope, `static newest = this.scope(…)` | has it, as `Product.newest()` and on its Relations | | `static override onQuery` | logs its queries (see [Logging](./logging)) | A Model can replace a shared method or scope by declaring one of the same name. ::: warning TIP `d1-record schema` writes `application-record.ts` with commented-out examples to start from. ::: ## Writing for every table Inside `Base`, `this` is a record of any Model. It has every record method, such as `save()` and `attributes()`, but no attributes, since those differ from table to table: * Read attributes through `this.attributes()`, as `label()` does above. * Write shared scopes with `sql` fragments, since they run on each Model’s own table: `static newest = this.scope((q) => q.order(sql\`created_at DESC\`))\`. * Validations name attributes, so they belong on each Model. Put shared checks in a callback instead. `Base` has no table of its own, so `db.model(Base)` throws `InvalidModel`. Once a Model has been used, its `Base` is sealed with it, so request code can’t add callbacks to every Model. ## Sharing across databases Each database has its own base class, such as `DbRecord` and `LogsRecord`. To share behavior across all of them, write it in one class extending `Model.Base`, and extend that class in each base class’s file. See [Multiple databases](./several-databases). --- --- url: https://docs.forestfuture.dev/d1-record/guide/untrusted-input.md description: >- Permit only the fields you want from a request, return a 400 for values that can't be used, and check request values before you query with them. --- # Untrusted input A request can send any fields it likes. Pick the ones you want before they reach a Model. ## Permitting fields Only permit the fields you want from a request: ```ts const permitted = Account.permit(await request.json(), "email", "handle"); const account = await Account.create(permitted); ``` ::: warning TIP `permit` can also read `FormData` or `URLSearchParams`. ::: Permitted values are cast to their attribute’s types, so `"42"` becomes `42` for an integer. [Models](./models#casting-assigned-values) lists what each type accepts. A page number from a query string can go straight to [`paginate`](./querying#paginating), which casts it too. ::: rails Beyond Rails Rails’ `params.permit` only filters keys. d1-record’s also casts each value, and checks that each name is an attribute. ::: ## Using a schema library If you validate requests with a schema library such as [Zod](https://zod.dev) or [Valibot](https://valibot.dev), pass the parsed result to the Model: ```ts import { z } from "zod"; const SignUp = z.object({ email: z.string(), handle: z.string().min(3) }); const fields = SignUp.parse(await request.json()); const account = await Account.create(fields); ``` ## Returning a 400 A value that can’t be cast throws a `TypeError`, and a body that isn’t JSON throws a `SyntaxError`. Return a 400 for either: ```ts export async function updateAccount(account: Account, request: Request): Promise { let permitted; try { permitted = Account.permit(await request.json(), "handle", "seats"); } catch (error) { if (error instanceof SyntaxError || error instanceof TypeError) { return new Response("Bad request", { status: 400 }); } throw error; } await account.update(permitted); return Response.json(account); } ``` ## Querying with request values Check that a value is a string before you query with it. JSON can send `null`, and `findBy({ token: null })` matches rows whose token is NULL: ```ts export async function accept(request: Request): Promise { const { token } = await request.json<{ token: string }>(); if (typeof token !== "string" || token === "") { return new Response("Send the invitation's token", { status: 400 }); } const invitation = await Invitation.findBy({ token }); if (invitation === null) return new Response("No such invitation", { status: 404 }); await invitation.update({ token: null, acceptedAt: new Date() }); return new Response(`Welcome, ${invitation.email}`); } ``` ::: warning TIP A value that can’t be cast matches nothing, so `whereNot({ id: "abc" })` excludes nothing. ::: --- --- url: https://docs.forestfuture.dev/d1-record/guide/logging.md description: >- See the SQL your Worker sends to D1: every query app-wide, one request's queries with its ID, only in development, in Workers Logs, and as Server-Timing. --- # Logging Log the SQL your Worker sends to D1 with an `onQuery` listener. It’s called once for every statement, writes and batches included, with: | Field | | | --------------- | ---------------------------------------------------------------- | | `model` | The Model’s class name, such as `"Product"` | | `sql`, `params` | The statement and its bound values | | `durationMs` | How long it took; in a batch, D1’s own timing for each statement | | `meta` | D1’s result metadata (rows read and written, …) | | `error` | D1’s error, when it failed | Send these wherever you like. A listener never changes a query’s outcome: one that throws is logged with `console.error`, and the query’s result stands. Set a listener in one of two places: | | Where | Knows the request? | | ------------------------- | ----------------------------------------------------- | ------------------ | | **Every query, app-wide** | `static override onQuery` on your app’s base class | No | | **One request’s queries** | `connect(env.DB, { onQuery })`, run in `withDatabase` | Yes | If both are set, the request’s listener runs first, then the base class’s. ## Logging every query Set `onQuery` on your app’s base class, in `application-record.ts`, to see every statement of every Model: ```ts import { Model, modelFor, type QueryListener } from "@forestfuture/d1-record"; import { tables } from "../db/schema"; // src/models/application-record.ts: every query, from every Model, whichever database runs it. class Base extends Model.Base { static override binding = "DB"; static override onQuery: QueryListener = ({ model, sql, durationMs, error }) => { console.log(`${model} ${durationMs.toFixed(1)}ms ${sql}`, error ?? ""); }; } export const ApplicationRecord = modelFor(tables, Base); ``` It can’t tell requests apart. A Model can set its own `static override onQuery`, which replaces the base class’s for that Model. ## Logging one request’s queries To group a request’s queries, open its database with a listener that knows the request, and run the request inside `withDatabase`: ```ts import { connect, withDatabase } from "@forestfuture/d1-record"; // src/worker.ts: one request's queries, tagged with its ID, as JSON lines for Workers Logs. // `app` stands for what your app exports as its Worker today: a framework's handler, or your own. export const logged = { fetch(request, env, ctx) { const requestId = request.headers.get("cf-ray") ?? crypto.randomUUID(); const db = connect(env.DB, { onQuery: ({ model, sql, params, durationMs, error }) => console.log( JSON.stringify({ requestId, model, ms: Math.round(durationMs), sql, params, error: error instanceof Error ? error.message : undefined, }), ), }); // Every query from a Model inside (Product.find, user.save(), …) goes through db. return withDatabase(db, () => app.fetch(request, env, ctx)); }, } satisfies ExportedHandler; ``` Logged as JSON, each line can be filtered by `requestId` in [Workers Logs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/). Turn them on with `"observability": { "enabled": true }` in your Wrangler config. ::: warning TIP `withDatabase` needs Node.js compatibility, which is on by default for compatibility dates from 2026-08-04. For an earlier date, add `"compatibility_flags": ["nodejs_compat"]`. ::: ## Logging only in development Check a variable that only `.dev.vars` sets, such as `LOG_SQL=1`. For one request’s listener: ```ts import { connect, withDatabase, type QueryListener } from "@forestfuture/d1-record"; // LOG_SQL=1 in .dev.vars: logging in development only, nothing in production. const onQuery: QueryListener | undefined = env.LOG_SQL ? ({ model, sql, durationMs }) => console.log(`${model} ${durationMs.toFixed(1)}ms ${sql}`) : undefined; return withDatabase(connect(env.DB, { onQuery }), () => app.fetch(request, env, ctx)); ``` The app-wide listener reads the variable from `cloudflare:workers` when a query runs: ```ts import { Model, type QueryListener } from "@forestfuture/d1-record"; import { env as workerEnv } from "cloudflare:workers"; // Logs only when LOG_SQL is set (in .dev.vars), read from the Worker's env when a query runs. class QuietInProduction extends Model.Base { static override onQuery: QueryListener = ({ model, sql, durationMs }) => { if ((workerEnv as { LOG_SQL?: string }).LOG_SQL) { console.log(`${model} ${durationMs.toFixed(1)}ms ${sql}`); } }; } ``` ## Timing queries in the browser To see a request’s database time in the browser’s developer tools, add it up, and send it as a `Server-Timing` header: ```ts import { connect, withDatabase } from "@forestfuture/d1-record"; import { User } from "./models"; let queries = 0; let total = 0; const db = connect(env.DB, { onQuery({ durationMs }) { queries += 1; total += durationMs; }, }); const response = await withDatabase(db, async () => Response.json(await User.active().toArray()), ); // Shown in the browser's developer tools, under the request's Timing. response.headers.append( "server-timing", `d1;dur=${total.toFixed(1)};desc="${queries} queries"`, ); return response; ``` ## Keeping secrets out of the logs Listeners see the values of passwords, tokens, keys, and emails as `"[FILTERED]"`, while D1 still gets the real values. [Models](./models#filtering-attributes) lists the attributes filtered by default, and everywhere their values are hidden. ### Filtering more attributes Name them on your app’s base class, spreading in the defaults: ```ts import { Model } from "@forestfuture/d1-record"; // src/models/application-record.ts export class Base extends Model.Base { // Rails' defaults, plus an IBAN and a date of birth: static override filterAttributes = [...Model.Base.filterAttributes, "iban", /^dateOfBirth$/]; } ``` A string matches any attribute whose snake_case name contains it, so `"iban"` also covers `ibanNumber`. Use a RegExp to match one name exactly. ### Seeing a filtered attribute To see one while debugging, leave it out of the list in development: ```ts import { Model } from "@forestfuture/d1-record"; class Base extends Model.Base { static override filterAttributes = Model.Base.filterAttributes.filter( (entry) => entry !== "email", ); } ``` ### Filtering `sql` fragments A value inside an `sql` fragment has no attribute, so it’s logged as given. Compare secrets with a condition, such as `where({ token })`, which is filtered, or leave `params` out of what you log. ::: rails Compared with Rails Rails filters by its app-wide `filter_parameters`, against parameter and column names. d1-record keeps the list on the Model (`static filterAttributes`, inherited from your base class), and matches each entry against the attribute’s snake_case name, so `"_key"` filters `apiKey`. A RegExp may match either form. It also hides filtered values in cast errors and in `RecordNotFound`, which Rails’ filter doesn’t cover. ::: --- --- url: https://docs.forestfuture.dev/d1-record/guide/read-your-own-writes.md description: >- With D1 read replication, keep each user's reads at least as new as their own writes: a D1 Session per request, opened in middleware from a bookmark cookie. --- # Read replicas With [D1 read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/), reads can come from a nearby replica that may be a moment behind. A D1 **Session** keeps each user’s reads at least as new as their own writes. ## Opening a Session for each request Open the request’s database with a Session, run the request inside `withDatabase`, and keep the Session’s latest bookmark in a cookie: ```ts import { connect, withDatabase } from "@forestfuture/d1-record"; // Middleware: each request reads at least as new as this browser's last write, from any replica. async function withSession(request: Request, env: Env, next: () => Promise) { const bookmark = cookie(request, "d1-bookmark") ?? "first-unconstrained"; const db = connect(env.DB, { session: bookmark }); const response = await withDatabase(db, next); // every query in it uses the Session const latest = db.bookmark() ?? bookmark; response.headers.append( "set-cookie", `d1-bookmark=${latest}; Path=/; HttpOnly; Secure; SameSite=Lax`, ); return response; } export default { fetch: (request, env) => withSession(request, env, () => handle(request)), } satisfies ExportedHandler; ``` Every query inside it goes through the Session, including uniqueness checks, association loads, preloads, and batches. Records loaded there keep using it. The bookmark carries the Session to the user’s next request. ::: warning TIP `withDatabase` needs Node.js compatibility, which is on by default for compatibility dates from 2026-08-04. For an earlier date, add `"compatibility_flags": ["nodejs_compat"]`. ::: ## Choosing where a Session starts `session` takes D1’s own values: | `session` | The first query goes to | | ----------------------- | -------------------------------------------------------------------------------------- | | `"first-unconstrained"` | The nearest instance, primary or replica. The fastest, but may read slightly old data. | | `"first-primary"` | The primary, so it sees every write made so far. | | A bookmark | An instance at least as new as the moment the bookmark was taken. | After the first query, each query reads data at least as new as the one before, including the request’s own writes. * **`db.bookmark()`** is the Session’s latest bookmark. It’s `null` before the first query, and throws `NoSession` on a database opened without a Session. * **To mix the primary and replicas**, open two databases, and use `db.model(…)` with each. --- --- url: https://docs.forestfuture.dev/d1-record/guide/testing.md description: >- Test Models and your Worker against a real D1 database with Vitest and Miniflare: create the tables from your migrations, and give the code a database with withDatabase. --- # Testing Test your Models and Worker against a real D1 database, with your migrations applied. [Miniflare](https://developers.cloudflare.com/workers/testing/miniflare/) runs Cloudflare’s runtime, D1 included, inside Node: ```sh npm install --save-dev vitest miniflare@4 ``` ## Creating a test database Start Miniflare with a D1 database, and apply your migrations to it, in order: ```ts [test/d1.ts] import { readdirSync, readFileSync } from "node:fs"; import { Miniflare } from "miniflare"; export async function testDatabase() { const mf = new Miniflare({ modules: true, script: "export default {}", d1Databases: ["DB"] }); const binding = await mf.getD1Database("DB"); for (const file of readdirSync("migrations").sort()) { // Each migration's statements, without its comments, applied together. const statements = readFileSync(`migrations/${file}`, "utf8") .replace(/^\s*--.*$/gm, "") .split(/;\s*$/m) .map((statement) => statement.trim()) .filter(Boolean); if (statements.length > 0) await binding.batch(statements.map((statement) => binding.prepare(statement))); } return { binding, dispose: () => mf.dispose() }; } ``` ## Testing Models Outside a Worker there’s no binding, so run the test’s code inside `withDatabase`: ```ts [test/users.test.ts] import { connect, withDatabase } from "@forestfuture/d1-record"; import { afterAll, beforeAll, expect, test } from "vitest"; import { User } from "../src/models/user"; import { testDatabase } from "./d1"; let database: Awaited>; beforeAll(async () => (database = await testDatabase())); afterAll(() => database.dispose()); test("emails are saved in lowercase", async () => { await withDatabase(connect(database.binding), async () => { const user = await User.create({ email: "Ada@Example.com" }); expect((await User.find(user.id)).email).toBe("ada@example.com"); }); }); ``` Outside a Worker, the database given to `withDatabase` serves every Model, whatever binding its base class names. For code that takes a database explicitly, pass it one: `connect(database.binding).model(User)`. ## Testing your Worker Call your Worker’s `fetch` handler inside `withDatabase` too, and its queries use the test’s database: ```ts [test/worker.test.ts] import { connect, withDatabase } from "@forestfuture/d1-record"; import { afterAll, beforeAll, expect, test } from "vitest"; import worker from "../src/index"; import { testDatabase } from "./d1"; let database: Awaited>; beforeAll(async () => (database = await testDatabase())); afterAll(() => database.dispose()); test("GET lists users", async () => { const response = await withDatabase(connect(database.binding), () => worker.fetch(new Request("https://example.com/")), ); expect(response.status).toBe(200); }); ``` Each test file gets its own database. ::: warning TIP To run tests inside the Workers runtime itself, use Cloudflare’s [Vitest integration](https://developers.cloudflare.com/workers/testing/vitest-integration/). ::: --- --- url: https://docs.forestfuture.dev/d1-record/guide/several-databases.md description: >- Use two or more D1 databases from one Worker: a schema and base class for each binding, generators that choose a database, and per-request options for several at once. --- # Multiple databases A Worker can bind several D1 databases. Each gets its own schema file and base class, and each Model queries the database its base class names. ## Binding the databases Add each database to your Wrangler config, with its own `migrations_dir`: ```jsonc [wrangler.jsonc] { "d1_databases": [ { "binding": "DB", "database_name": "my-app-db", "database_id": "…" }, { "binding": "LOGS", "database_name": "my-app-logs", "database_id": "…", "migrations_dir": "logs-migrations", }, ], } ``` Wrangler applies a folder’s migrations to one database, so each needs its own folder. ## Generating a schema for each Generate the schemas as usual: ```sh npx d1-record schema ``` With several databases, it writes one of each per binding: | Binding | Schema | Base class | | ------- | ---------------- | -------------------------------------------- | | `DB` | `src/db/DB.ts` | `DbRecord`, in `src/models/db-record.ts` | | `LOGS` | `src/db/LOGS.ts` | `LogsRecord`, in `src/models/logs-record.ts` | Each base class names its binding, so its Models query that database: ```ts import { LogsRecord } from "./logs-record"; export class Entry extends LogsRecord("entries") {} await Entry.where({ level: "error" }).count(); // on LOGS ``` ## Generating Models for one database Name the binding with `--database`: ```sh npx d1-record g model Entry 'level:string!' message --database LOGS npx wrangler d1 migrations apply my-app-logs --local ``` ## Opening several for a request To open a request’s databases with a Session or `onQuery`, pass them all to `withDatabase`. Each Model uses the one on its binding: ```ts import { connect, withDatabase, type Database } from "@forestfuture/d1-record"; const databases: Database[] = [connect(env.DB, { session: "first-primary" }), connect(env.LOGS)]; return withDatabase(databases, handle); // each Model uses the one on its binding ``` ::: warning TIP A batch writes to one database, so entries from two databases throw `IncompatibleRelation`. ::: --- --- url: https://docs.forestfuture.dev/d1-record/guide/query-databases.md description: >- How a query finds its D1 database (a Model's binding, the request's database, or an explicit one), why nothing about a request is ever stored on a Model class, and how this compares to Rails. --- # Database resolution `Product.find(id)` works with no setup, because a Model knows its D1 binding. But a Worker isn’t a long-running server, and that shapes how a query finds its database. ## One isolate, many requests A Worker runs in an **isolate** that serves many requests at the same time, and a Model class is shared by all of them. So anything stored on a class is shared by every request in flight. That rules out storing a connection on a class: some of what a connection carries belongs to one request only. * **A D1 Session** keeps one user’s reads at least as new as their own writes. Shared, one user’s Session would serve another’s queries. * **An `onQuery` listener** might tag each query with its request’s ID. Shared, it would mix requests. Rails doesn’t keep its connection on the class either: each thread checks one out from a pool, and gives it back afterwards. The JavaScript equivalent of “per request, without passing it around” is `AsyncLocalStorage`, which follows a request through every `await`. ## Three ways to find a database A query finds its database in one of three ways, in this order: 1. **The request’s database.** Inside `withDatabase(connect(env.DB, { session, onQuery }), fn)`, everything `fn` awaits uses that database, kept in the request’s `AsyncLocalStorage`, never on a class. It’s matched to Models by binding, so a request can open several. 2. **The Model’s binding.** Outside `withDatabase`, a Model uses the D1 binding its base class names (`static binding = "DB"`), read from `cloudflare:workers`. The binding is the same object for every request, and the database made for it holds nothing per request (no Session, no listener), so sharing it is safe. This is what makes `Product.find(id)` work with no setup. 3. **An explicit database.** `connect(env.DB).model(Product)` always uses that database, whatever `withDatabase` says. It suits code that passes its database around, or uses two at once. Most apps only ever use the second. The first exists for what belongs to a request: Sessions, per-request logging, and tests, which run outside a Worker and have no binding to find. ## Which do I need? | Your situation | What to do | | ------------------------------------------------------------ | -------------------------------------------------------------------- | | A Worker with one D1 database | Nothing: `User.find(id)` uses its binding | | D1 read replication, reading your own writes | [A Session per request](./read-your-own-writes) | | Logging queries, or `Server-Timing` | [`onQuery`](./logging), app-wide or per request | | Vitest tests, seed or migration scripts | [`withDatabase(connect(binding), …)`](./testing), or `db.model(...)` | | Several D1 databases | [One base class per binding](./several-databases) | | A compatibility date before 2026-08-04, using `withDatabase` | Add `nodejs_compat` to your Wrangler config’s `compatibility_flags` | ## What follows from it * **Records remember.** A record keeps the database it was loaded through, or first saved through, and so do the records saved with it (autosaved records, a `belongsTo` target), so `post.save()` and `post.user()` use it later, even after `withDatabase` has returned. * **One query or write, one database.** `merge`, `or`, scopes, batches, and saves each run on one database. What counts is the database each side resolves to when it runs, so inside `withDatabase` a loaded record’s `user.posts()` merges with `Post.published()`. Two databases are refused with `IncompatibleRelation`, before anything is written. * **Errors say where the database should come from.** A query outside a Worker with no `withDatabase`, a Model with no binding, or a query at a module’s top level (where Workers allow no I/O) each throws `NoDatabase`, saying what to do. For the options themselves, see [Connections](./connecting). --- --- url: https://docs.forestfuture.dev/d1-record/guide/schema.md description: >- Why d1-record generates a Model's attributes from your migrations, how the generated schema compares to Rails' schema.rb, and why Models extend your app's ApplicationRecord("table"). --- # Schema and `ApplicationRecord` In Rails, a migration describes a table, and the model never repeats it: Active Record reads the columns from the database when the app runs. d1-record keeps that idea, with one change TypeScript requires: it reads the columns when you **generate** the schema, not when your Worker runs. ## Migrations are the source of truth Tables are created and changed by **migrations**: numbered SQL files that Wrangler applies in order, recording each one in the database (in a `d1_migrations` table), so each runs once. Cloudflare’s [D1 migrations](https://developers.cloudflare.com/d1/reference/migrations/) guide covers Wrangler’s side. d1-record’s generators write migrations for you, but any migration Wrangler can apply works, however it was written. A Model never redeclares its columns. If it did, the migration and the Model would say the same thing twice (`price_cents INTEGER NOT NULL`, then `priceCents: { type: "integer", null: false }`), and could drift apart. ## The generated schema TypeScript needs to know a Model’s attributes before your code runs, to type `product.priceCents` and check `Product.where({ … })`. And a Worker shouldn’t ask D1 for its tables on every request. So instead of reading the database at runtime, `d1-record schema` applies your migrations to an in-memory SQLite database, reads each table’s columns, and writes them to `src/db/schema.ts`. That file is to d1-record what `db/schema.rb` is to Rails: a document of every table, regenerated after each migration, never edited by hand, and committed, so each schema change shows up as a readable diff next to its migration. Each table names the migration that last changed it. In CI, `d1-record schema --check` catches a schema someone forgot to regenerate. Columns become attributes by convention, as Rails’ do: the column’s declared type first (`INTEGER` is an `integer`, `DATETIME` a `datetime`), then, for plain `TEXT` and `INTEGER` columns, its name (`created_at` is a `datetime`). The [Command line](./command-line#mapping-columns-to-attributes) reference lists every rule. When a column’s type isn’t what your app means (JSON in a plain `TEXT` column, say), an override in the Model says so, without touching the table. ## Why `ApplicationRecord("table")` A Model extends your app’s base class, naming its table: ```ts export class Product extends ApplicationRecord("products") {} ``` This is Rails’ shape: `class Product < ApplicationRecord`. The table is in the call because TypeScript can’t derive a class’s types from its name: `ApplicationRecord("products")` is what gives `Product` the `products` table’s attributes. As a bonus, the table doesn’t depend on the class keeping its name when your Worker is bundled. `ApplicationRecord` itself is a file in your app, `src/models/application-record.ts`, which `d1-record schema` writes once. It builds the base class from the generated schema, `modelFor(tables, Base)`, and its `Base` is where behavior shared by every Model goes, as on Rails’ `ApplicationRecord`. Only that file imports the schema, so the schema stays a document. A Model can still declare its attributes itself, with `Model({...})`, for a view or a table your migrations don’t make. See [Models](./models#declaring-attributes-yourself). --- --- url: https://docs.forestfuture.dev/d1-record/guide/d1.md description: >- What's different on D1: no interactive transactions, keys generated in code, read replicas, uniqueness races, bound-parameter limits, foreign keys. --- # D1’s limits d1-record follows Rails wherever D1 allows. These are the places D1 works differently, worth knowing before you design around them. ## No interactive transactions D1 can’t hold a transaction open while your Worker awaits, so there’s no `transaction(async (tx) => …)`. You can’t read, decide, then write atomically. What you can make atomic is a set of writes prepared in advance: [`batch([...])`](./batches#batching-writes) sends statements and records together, and D1 writes all of them or none. d1-record uses the same batches itself: * A destroy’s [`dependent` strategies](./associations#destroying-dependent-records) are written in one batch with the owner’s DELETE. * A record [autosaved](./associations#autosaving) with its owner joins the owner’s batch when the owner’s key is known in advance. ## Generate keys in code Rows in a batch can only point at each other through keys that exist before it’s sent. Generated `TEXT` keys are made in code by [`generateId`](./models#generating-ids), so records have their key from `build()`. Then: * A graph of new records built through associations saves in one atomic batch. * A batch can insert rows that point at each other. When D1 generates the key (`INTEGER PRIMARY KEY`), it doesn’t exist until the row is written. An owner is written first, then the records built through it in the next batch, a level at a time, and the batches aren’t atomic together. See [Autosaving](./associations#autosaving). ## Read replicas With read replication, a read can come from a replica that’s slightly behind. Open each request’s database with a [Session](./read-your-own-writes), so a request reads its own writes, and pass its bookmark to the next request, so a user never sees data older than their last write. ## Uniqueness is checked twice The `uniqueness` validation asks D1 before saving, which gives a friendly error, but two requests can both pass it at once. Only a UNIQUE index guarantees uniqueness. Declare both, and handle [`RecordNotUnique`](./errors#constraint-errors) where a race matters. `caseSensitive: false` compares with SQLite’s `lower()`, which only lowercases ASCII letters, so `"Ä"` and `"ä"` are still different. ## Bound parameters D1 allows 100 bound values in one statement. d1-record binds lists as one JSON value (`json_each`), so `where({ id: [...] })`, `find([...])`, preloads, and `insertAll` work with any number of values. A single value can be up to 2 MB. ## Foreign keys are enforced D1 enforces the foreign keys a table declares with `REFERENCES`. Destroying a record whose rows still point at it throws `InvalidForeignKey`, unless the association has a [`dependent` strategy](./associations#destroying-dependent-records) that removes or updates those rows first. A foreign key that isn’t declared in the table isn’t checked at all. ## Nothing rolls back after a write Once D1 has accepted a write, it’s done. An error thrown by an after-callback reaches the caller, but the record stays saved. --- --- url: https://docs.forestfuture.dev/d1-record/guide/security.md description: >- What d1-record protects against and what's left to your app, the guarantees its tests check, how its supply chain is set up, and a log of the security reviews it has had. --- # Security Whatever a request sends can reach a query or a record through d1-record, so a mistake in it is a mistake in every app that uses it. Here’s what it protects against, what it leaves to your app, the tests that check each guarantee, how releases are built, and the security reviews it has had. These are the project’s own reviews, not an independent audit. Each says what it covered and what it didn’t. ## What d1-record protects against * **SQL injection.** Every value is bound as a parameter, never written into SQL text. Identifiers come only from a Model’s declared attributes, and are quoted. Raw SQL is only possible through the `sql` tagged template, which binds every value it’s given. * **Values that aren’t what TypeScript says.** A value from JSON, a form, or a query string is cast to its attribute’s type when it’s assigned or used in a condition, and anything ambiguous is refused: `"false"` can’t save `true`. See [Models](./models#casting-assigned-values). * **Prototype pollution and inherited names.** Attributes and associations are looked up only by the Model’s own names, so `__proto__`, `constructor`, and other names every object has are unknown names, and `Object.prototype` is never written to. * **Secrets in logs and errors.** Values of attributes named like passwords, tokens, keys, and emails are hidden from `onQuery` listeners and from error messages. See [Logging](./logging#keeping-secrets-out-of-the-logs). * **One request’s data reaching another.** Nothing about a request (a D1 Session, a listener) is ever stored on a Model class. See [Database resolution](./query-databases). * **Denial of service through parsing.** The patterns that read numbers and dates are linear, so a long value can’t use up a Worker’s CPU time. ## What’s left to your app * **Choosing which fields a request may set,** with `permit`, destructuring, or a schema. See [Untrusted input](./untrusted-input). * **An untrusted `null` in a condition.** `findBy({ token: null })` matches rows whose token is NULL. Check a value’s type before querying with it. * **Authorization.** Whether this user may read or change this record is your app’s decision. * **Formats and ranges.** Casting checks types, not meaning: use [validations](./validations). * **Secrets inside `sql` fragments.** A fragment’s values have no attribute, so they’re not hidden from listeners. ## Guarantees, and the tests that check them The library’s guarantees are tested against a real D1 database (through Miniflare), and CI’s by a test that reads its workflows. CI runs every test on every change. | Guarantee | Tests | | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | Values are bound, identifiers are quoted, and plain strings are refused as SQL | [`sql-safety`](https://github.com/forestfuture/d1-record/blob/main/tests/integration/sql-safety.test.ts) | | Assigned and condition values are cast, or refused, in linear time | [`attribute-casting`](https://github.com/forestfuture/d1-record/blob/main/tests/integration/attribute-casting.test.ts) | | Only a Model’s own names are attributes or associations | [`name-lookups`](https://github.com/forestfuture/d1-record/blob/main/tests/integration/name-lookups.test.ts) | | `permit` keeps only the named attributes, reading own keys only | [`permit`](https://github.com/forestfuture/d1-record/blob/main/tests/integration/permit.test.ts) | | Filtered attributes’ values never reach listeners or errors | [`filter-attributes`](https://github.com/forestfuture/d1-record/blob/main/tests/integration/filter-attributes.test.ts) | | A request’s database stays with that request | [`current-database`](https://github.com/forestfuture/d1-record/blob/main/tests/integration/current-database.test.ts) | | CI’s actions are pinned, and release jobs run no install scripts | [`ci-workflows`](https://github.com/forestfuture/d1-record/blob/main/tests/unit/ci-workflows.test.ts) | ## Supply chain * **No runtime dependencies in your Worker.** The library imports no other package, so no other package’s compromise can reach your Worker through it. The command line, a development tool, depends on `jiti` to load your Models; installing the package installs it, and your Worker doesn’t bundle it. * **Releases are built by CI.** The npm package is built and published by the repository’s release workflow, never from a laptop, so what’s on npm is what’s in the repository. Publishing uses npm’s trusted publishing, so no npm token is stored anywhere. Version 0.1.0 was published before that was set up, and has no provenance attestation linking it to its commit ([#109](https://github.com/forestfuture/d1-record/issues/109)). * **CI is pinned.** Every third-party GitHub Action is pinned to a commit, not a tag that could be moved, because a moved tag would run someone else’s code in the job that publishes. A test fails if one isn’t. * **Release jobs run no install scripts.** The jobs that decide, build, version, and publish a release install dependencies without running their install scripts and without a shared cache, so a compromised development dependency can’t change what’s built or published. * **Dependencies are watched.** Dependabot proposes updates weekly, waiting a week after each release, since a compromised release is usually caught within days. `pnpm audit` is part of each security review. ## Review log Each security review adds an entry here: what it covered, how, what it found, and what it didn’t cover. ### 2026-10-04: the library, its command line, and CI **Scope:** the library (`src/`), the command line (`cli/`), the CI and release workflows, and the development dependencies. **Method:** * a security review of the whole codebase with Sentry’s `security-review` skill (OWASP-based), briefed on a library’s risks: what happens when an app passes untrusted input into it; * untrusted inputs probed against a real D1 database: prototype keys, wrong types, `null`, long values; * a dependency audit with Trail of Bits’ `supply-chain-risk-auditor` and `pnpm audit`; * each fix then reviewed with Trail of Bits’ `differential-review`. **Findings, all fixed:** | Severity | Finding | Issue | | -------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | High | Assigned values weren’t checked at runtime: `"false"` saved as `true` for a boolean | [#81](https://github.com/forestfuture/d1-record/issues/81) | | High | The publish job used actions pinned by movable tags, and ran install scripts | [#80](https://github.com/forestfuture/d1-record/issues/80) | | High | Found while fixing #81: a pattern that backtracked on long numbers (ReDoS) | [#81](https://github.com/forestfuture/d1-record/issues/81) | | Medium | No protection against mass assignment, and no guidance on an untrusted `null` | [#82](https://github.com/forestfuture/d1-record/issues/82) | | Medium | Bound values, secrets included, reached `onQuery` listeners and errors | [#83](https://github.com/forestfuture/d1-record/issues/83) | | Low | Inherited names (`constructor`, `__proto__`) caused errors instead of being unknown names | [#84](https://github.com/forestfuture/d1-record/issues/84) | | Low | Development dependency advisories, and unescaped config values in generated code | [#85](https://github.com/forestfuture/d1-record/issues/85) | **Not covered:** the source of the third-party Actions at their pinned commits; D1 and workerd themselves; isolation between concurrent requests beyond the existing tests; apps’ own code. ## Reporting a vulnerability Please report a security problem privately, as the [security policy](https://github.com/forestfuture/.github/blob/main/SECURITY.md) describes, never in a public issue.