HeySales and HelloRestaurant · 23 October 2026 · 5 min read

What changed when we moved two apps from Postgres to Cloudflare D1

The D1 and SQLite rules that broke our Postgres code when HeySales and HelloRestaurant moved to Cloudflare Workers, and how we handled each one.

In October 2026 we moved HeySales and HelloRestaurant off Neon Postgres and onto Cloudflare Workers with a D1 database. HeySales uses Drizzle ORM. HelloRestaurant uses Prisma with @prisma/adapter-d1. D1 is SQLite, so most queries carried over unchanged. The ones that didn't fell into a handful of groups, and the same rules caught both apps.

Transactions become batches

D1 has no interactive transactions. With Drizzle, db.transaction() fails as soon as it sends BEGIN. HeySales had eight transactions, and each one became a db.batch([...]) call, which D1 runs atomically.

A batch can't read one statement's result before running the next, so any id a later statement needs must exist before the batch starts. We generate it with createId().

const channelId = createId();
const [rows] = await ctx.db.batch([
  ctx.db
    .insert(schema.chatChannels)
    .values({ id: channelId, type: "DIRECT", companyId, createdById: user.id })
    .returning(),
  ctx.db.insert(schema.chatChannelMembers).values([
    { channelId, userId: user.id },
    { channelId, userId: input.otherUserId },
  ]),
]);

Reads that used to sit inside the transaction move after the batch. Our embedded form endpoint writes the contact, its custom field values, the deal and the submission record in one batch. The notification emails are looked up and sent later in after(), so nothing after the save can fail the response.

Inside a batch, stick to Drizzle's query builders. A raw db.run(sql`...`) with bound parameters crashes Drizzle's D1 batch.

Prisma behaves differently, and the difference is easy to miss. The D1 adapter doesn't throw on $transaction. It logs a warning once and runs the queries one by one, so nothing rolls back if a later query fails. That applies to both the interactive and the array form. In HelloRestaurant we removed the $transaction from the no-show mutation and run its two updates separately, with a comment saying why. Search for $transaction before you move a Prisma app, because that log line is the only sign.

The 100-parameter limit

D1 allows at most 100 bound parameters in one statement. Postgres code breaks this in two common places. A multi-row insert binds one parameter per column per row, and inArray binds one per value.

HeySales has two helpers in @/server/db for this. insertRows(table, rows) splits the rows into chunks of floor(100 / columns) and sends every chunk in one batch, so the insert stays atomic. inList(column, values) replaces inArray and binds the whole list as a single JSON parameter.

export function inList(column: Column | SQL, values: readonly string[]): SQL {
  if (values.length === 0) return sql`false`;
  return sql`${column} in (select value from json_each(${JSON.stringify(values)}))`;
}

It is meant for text columns such as ids and enum values.

Prisma handles most of this for you. The adapter tells Prisma to keep each query to 98 bound values, and Prisma splits long in lists for ordinary lookups. It doesn't split the query that loads a many-to-many relation. HelloRestaurant's month view loaded every booking's tables in one query, which failed once a restaurant had about 100 bookings in the period. We now load a booking's tables through a per-request loader. It collects the booking ids asked for in the same tick and fetches their tables in chunks of 90.

SQLite SQL and types

The schema and the raw SQL needed these changes.

  • ilike becomes like, because SQLite's LIKE already ignores case for ASCII letters. Prisma's string filters on SQLite have no mode option, so mode: 'insensitive' goes.
  • Postgres casts such as count(*)::int lose the ::int.
  • HeySales stores timestamps as integer milliseconds (Drizzle's timestamp_ms mode). TO_CHAR(col, 'YYYY-MM') becomes strftime('%Y-%m', col / 1000, 'unixepoch'). Raw SQL date comparisons became Drizzle's gte and lte, which pass the Date through the column's mapping.
  • Booleans are integers. jsonb and Postgres arrays become JSON text. In Prisma, a String[] field became Json @default("[]"), and the GraphQL resolvers cast it back to string[].
  • SQLite has no enum type. Our textEnum helper keeps the pgEnum shape the code relied on, but the database now stores plain text and only TypeScript checks the values.
  • Table names ignore case. Better Auth's user table would clash with our User table, so the auth tables became auth_user, auth_session and so on.

Features with no D1 equivalent

HeySales used Postgres LISTEN/NOTIFY for live deal updates on the Kanban board, new-contact notifications, presence and collaboration cursors, and the website live chat widget. D1 has nothing like it, so we switched those features off. The Kanban board now refreshes when you come back to the tab. Bringing the rest back means polling or a Durable Object.

Connecting from a Worker

HeySales creates one Drizzle db at module level from the DB binding, imported from cloudflare:workers. Unlike a Postgres socket, the binding is safe to share across requests.

HelloRestaurant runs on OpenNext and creates one Prisma client per request on the binding from getCloudflareContext(). Outside Cloudflare it falls back to a single client on a local SQLite file, which the integration tests and scripts use. The Workers build of Prisma Client also doesn't expose its datamodel at runtime, so Pothos reads one generated at build time with generateDatamodel = "true".

Both apps replaced their Postgres migration history with one initial SQLite migration in migrations/, applied with wrangler d1 migrations apply. Drizzle writes new migrations with drizzle-kit generate. For Prisma we diff the local D1 against the schema with prisma migrate diff --from-local-d1 --to-schema-datamodel schema.prisma --script.

Checklist

  • Search for db.transaction and $transaction. Turn each one into a batch with ids generated up front, or decide it can safely run as separate writes.
  • Keep raw db.run(sql`...`) statements out of Drizzle batches.
  • Find multi-row inserts and IN lists that can grow past 100 values. Chunk the inserts inside one batch and bind long lists as JSON.
  • With Prisma, test every page that loads a many-to-many relation for a large set of parent rows.
  • Replace ilike, mode: 'insensitive', :: casts and Postgres date functions.
  • Choose how dates are stored, and compare them with the ORM's gte and lte.
  • Move enums, arrays and jsonb to text or JSON, and validate enum values in code.
  • Check for table names that differ only in case.
  • List the features that rely on LISTEN/NOTIFY and decide what replaces each one.
  • Run the app against a local D1 inside workerd, as HeySales's yarn dev does. A plain SQLite file allows far more than 100 parameters, so tests against one won't catch the limit.

Tell us what you're building.

Email us about your project: what it needs to do, who it's for and when you'd like it live.