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([...]) sends statements and records together, and D1 writes all of them or none. d1-record uses the same batches itself:
- A destroy’s
dependentstrategies are written in one batch with the owner’s DELETE. - A record autosaved 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, 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.
Read replicas
With read replication, a read can come from a replica that’s slightly behind. Open each request’s database with a Session, 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 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 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.