better-drizzle

Changes in 0.3

Runtime behavior changes and new features in better-drizzle 0.3 that the typecheck does not catch.

Read this after the 0.3 upgrade steps. These changes compile unchanged but can change what your code observes at runtime.

Behavior changes

These do not show up in the typecheck, but they can change what your code observes at runtime:

  • softDelete() filters more operations. In 0.2.x only findMany, findFirst, count, and exists hid deleted rows. Now findUnique, findOne, paginate, cursor, update, updateMany, updateEach, delete, and deleteMany do too. An update({ where: { id } }) that hits a soft-deleted row now matches nothing; pass deleted: 'with' to keep the old behavior. deleteMany() is now a soft delete instead of a physical one, and returns the count of rows it marked. Writes with an empty where stay no-ops, and mode: 'hard' still matches deleted rows. The requireTenantOnDelete, noHardDeleteOnSoftDeleteModel, and requireHardDeleteReason rules now also check deleteMany. See soft delete.

  • Reads are lazy. findMany, findFirst, findOne, findUnique, count, exists, paginate, and cursor return lazy thenables, like Drizzle's own query builders. The query and its hooks run when the result is awaited (or passed to then, catch, finally, or Promise.all), and at most once. A read that is created but never awaited no longer runs. .explain() alone never runs the read. Bun's expect(...).resolves / .rejects only accept native promises, so wrap reads with Promise.resolve(...) there. See testing.

  • .explain() output. Cursor probes that depend on a row from the data query are reported under deferredProbes instead of being executed, and a statement that only runs under a condition carries a condition string. See explain.

  • Text-backed timestamps. timestamps() and softDelete() write ISO 8601 strings to columns whose dataType is a string (such as SQLite text columns) and Date objects to native date columns.

  • Failures after COMMIT. When an afterCommit callback or an afterTransactionCommit hook throws, better-drizzle no longer runs rollback handling. There is no ROLLBACK on SQLite and no afterRollback or onTransactionError hook. The original error is rethrown as-is, and the data stays committed.

  • mode: 'insensitive' on equality. equals, in, notIn, and a scalar not used to ignore mode and stayed case-sensitive. They now compare lower(column) with lower(value) on every dialect, including JSONB paths and array element predicates. A param() list in in / notIn stays PostgreSQL-only.

  • MySQL upsert. The native ON DUPLICATE KEY UPDATE path is used only when the table declares no unique key besides the primary key. MySQL fires that clause on any unique key, so other tables use a read-then-write path instead of possibly updating a different row.

  • Native upsert on unique keys. A where that holds only the columns of one unique key, with the same values in create, now runs one ON CONFLICT ... DO UPDATE statement on PostgreSQL and SQLite (and ON DUPLICATE KEY UPDATE on MySQL when that key is the only one that can match). A where without the primary key no longer takes the primary key path when create omits it too: it used to insert without checking where, and now reads first.

  • update() and delete() change at most one row. In 0.2.x they ran their where against every matching row and returned only the first. Now only one row changes, on every dialect: a where that pins the primary key or a unique key runs as a plain statement, and any other where is limited to one row (a LIMIT 1 subquery on PostgreSQL and SQLite; on MySQL, a SELECT ... FOR UPDATE and a write by primary key in the active or an implicit transaction). Code that relied on update / delete touching several rows must use updateMany / deleteMany. The upsert read-then-write path also updates the row it found. See update and delete.

  • cursor() without orderBy pages by primary key ascending. Before, the default direction depended on the call (descending, or ascending with before), so the first page and later pages could walk different orders. Pass an explicit orderBy to keep a descending order. See cursor pagination.

  • Zod and ATA cursor schemas reject string cursors. $zod and $ata cursor args schemas accept only the cursor object or null, matching the types, so a string after / before fails validation instead of reaching the runtime.

  • Zod and ATA type numeric "number mode" columns as number. numeric(..., { mode: 'number' }) and decimal(..., { mode: 'number' }) columns validate as numbers, not strings. Columns in the default string mode still validate as strings. See zod and ata.

  • mode: 'insensitive' works on SQLite and MySQL. It compiled to ILIKE everywhere, which is a syntax error outside PostgreSQL. SQLite and MySQL now compile to lower(column) LIKE lower(pattern); PostgreSQL keeps ILIKE. See string filters.

  • Raw SQL rules also check $executeRaw. requireRawComment, requireRawTimeout, noRawMutation, and noRawWithoutTransaction used to check only $raw. Existing $executeRaw calls can now report violations. See rules.

  • Library error codes are kept. Operation, raw, and transaction wrappers keep the code of a BetterDrizzleError thrown inside them (for example from a hook or a nested call) instead of replacing it with OPERATION_ERROR. Check catch blocks that matched OPERATION_ERROR for those cases. See errors.

  • Invalid arguments throw INVALID_ARGS. Input validation failures, such as unknown columns or relations, select with include, page with skip, before with after, an invalid batchSize, duplicate updateEach by values, malformed atomic, array, or JSONB mutations, relation command errors, and Zod plugin parse failures, used OPERATION_ERROR (status 500). They now use INVALID_ARGS (status 400). Update catch blocks that matched OPERATION_ERROR for them. See errors.

  • Success callbacks all run. Every afterCommit callback, every afterTransactionCommit hook, and every plugin afterRaw hook now runs even when an earlier one throws; the first error is still rethrown. Previously the first failure skipped the rest.

  • findUnique requires a unique where. The where must pin one row: an equality on every primary key column, on a unique column, or on every column of a composite unique key (a param() counts). Any other where, including one where an undefined value dropped the key, such as { id: undefined }, throws UNIQUE_WHERE_REQUIRED before any SQL. Use findFirst for lookups by non-unique columns.

New in 0.3.0

  • Prepared statements: mark values with param(name), call .prepare(name?) on findUnique, findFirst, findOne, findMany, count, exists, paginate, or cursor, and run it with execute(values). Param and result types come from the read (PreparedParams / PreparedResult). Plugins and beforeQuery run once at prepare time; result hooks and intercepts run on every execution.
  • better-drizzle/cache (experimental): versioned read caching with automatic invalidation and a Redis store at better-drizzle/cache/redis.
  • better-drizzle/ata (experimental): JSON Schema validation of operation inputs and results, built on ata-validator and intended as a faster alternative to better-drizzle/zod. The entrypoint exports ata (default and named), its types, and version; the registry, column, row, where, and query-schema builders it uses internally are not exported.
  • $where(): every delegate compiles a typed where into a Drizzle SQL condition, so the same filter works in raw db.select() queries, joins, and subqueries.
  • Atomic updates: increment, decrement, multiply, divide, and set on number columns, and toggle on boolean columns.
  • PostgreSQL arrays: typed array filters, some / every / none element predicates, and append, prepend, remove, replace, and addUnique mutations.
  • JSONB paths: dotted path filters such as { metadata: { 'profile.age': { gte: 18 } } }, in / notIn on string and number paths, mode: 'insensitive' on string paths (examples), and path mutations in update, updateMany, updateEach, upsert, and upsertMany that compile to jsonb_set(...).
  • NULL ordering: orderBy accepts { direction, nulls: 'first' | 'last' }. See reads.
  • paginate() accepts page and perPage.
  • createMany batchSize: splits large inserts into one statement per chunk, like upsertMany. See createMany.
  • upsertMany on MySQL through ON DUPLICATE KEY UPDATE. See upsertMany details.
  • Plugin intercepts: intercept(ctx) wraps execution after transforms, so a plugin can serve a result without SQL or annotate after hooks.
  • Timestamps now and models options: an injectable clock for deterministic tests, and per-model column names or false to turn the plugin off for a model.

On this page