Relations and transactions

Make multiple writes atomic

Use a transaction so creating a user and their first note either completes together or leaves no partial state.

Two statements that each succeed are not one operation that succeeded. The process can crash between them. The second can fail after the first is already saved. If the product treats “create a user with a welcome note” as one thing, the database has to treat it as one thing too.

That’s what a transaction does. It groups statements so they all commit together, or none of them do.

The transaction

Node’s SQLite driver is synchronous, so the callback is synchronous too. Inside it we call .get() and .run() to execute statements, instead of await:

const result = db.transaction(tx => {
  const user = tx
    .insert(users)
    .values({ email, name })
    .returning()
    .get()

  const note = tx
    .insert(notes)
    .values({ authorId: user.id, title: 'Welcome' })
    .returning()
    .get()

  return { user, note }
})

db.transaction() opens the transaction and hands you tx. Use tx for every statement inside, not db. If the callback returns, Drizzle commits. If it throws, Drizzle rolls back and rethrows the error.

.returning().get() runs the insert and returns the first row. That’s the synchronous equivalent of the await ... returning() pattern we used outside transactions.

Why not an async callback

This is a trap I want you to avoid. With this driver, db.transaction(async tx => { await tx.insert(...) }) fails type checking with the message Sync drivers can't use async functions in transactions!. If you ignore that and run it anyway, it’s worse than useless. The callback returns a promise immediately, Drizzle sees a return and commits, and your awaited inserts run after the commit, outside the transaction. A failure in the second insert leaves the first row in the database. I tested this: the user stayed, the note didn’t, and no error mentioned the transaction.

With PostgreSQL drivers the callback is async and await is correct. Read the docs for your driver before assuming.

Prove the rollback

Force the second insert to fail. Pass authorId: 999 instead of user.id. The foreign key rejects it, the callback throws, and Drizzle rolls back.

Then look at the table:

console.log(await db.select().from(users))

The new user is not there. That’s the proof. Don’t stop at “an error was thrown”. Check the state, because a transaction that doesn’t roll back also throws.

Keep the network out

Never send an email or call an API inside the callback. The transaction holds locks while it waits. And if the email goes out and the commit then fails, you can’t un-send it.

Commit the database state first. Then queue the email, or do it in a follow-up step that can retry. The database can roll back a row. It cannot roll back the outside world.

Not every database is the same

SQLite transactions, PostgreSQL transactions, and Cloudflare D1 batches have different capabilities and APIs. The idea transfers. The exact code does not, so check the driver page when you change databases.

Lesson completed