better-drizzle

Changelog

Every change in better-drizzle 0.3.0 compared with 0.2.0, with links to the docs and commits.

0.3.0 - Unreleased

Upcoming release

0.3.0 is not published yet and has no release date. This page compares the current main branch with v0.2.0 and can change before the release. For step-by-step migration instructions, see the upgrading guide.

0.3.0 moves better-drizzle to Drizzle ORM 1.x (relational queries v2). It also adds prepared statements, two experimental plugins (cache and ata), atomic updates, PostgreSQL array and JSONB path support, and stricter types and runtime checks.

Each entry links to the commits that made it. Merged pull requests are linked as #n. Breaking changes are removed or renamed APIs, new compile errors, and changes the commits mark as breaking. Behavior changes compile unchanged but can change what your code sees at runtime.

Breaking changes

Drizzle ORM 1.x

  • Drizzle ORM 1.x only. The drizzle-orm peer range is >=1.0.0-rc.4 <1.0.0-rc.5 (was ^0.30.0). It is capped below 1.0.0-rc.5 because the typecheck fails against that snapshot. Projects on drizzle-orm 0.x must stay on better-drizzle@~0.2.0. See upgrading. (8b09683, 21cfd3e, #57)
  • better() no longer takes schema. Tables and relations come from the Drizzle instance (db._.relations, built by drizzle({ client, relations })). The options argument is optional, so better(db) works. A Drizzle instance without relations throws No tables found on the Drizzle instance. .... Only tables in the relations config get a delegate. See upgrading. (8b09683)
  • Type parameters are typeof relations. BetterDrizzleClient, BetterTableKey, WhereArg, explicit better<...>() calls, and every other exported helper type take the relations config instead of the schema module. See upgrading. (8b09683, 64af0c4)
  • Many-to-many comes only from .through(). The relations: { inferManyToMany, manyToMany } option on better() is removed, and junction tables are no longer inferred. Declare many-to-many relations with Drizzle's .through(). See relations. (8b09683)
  • Some relation shapes are unsupported. A relation with a relation-level where, or a one relation declared through a junction, throws Relation "x" on "t" cannot be loaded: ... (or ... cannot be filtered: ...) when used. Drizzle's own db.query still handles them. See limitations. (8b09683, 9614cdc)
  • Plugin ctx.schema is the relations config. In setup, hooks, transforms, and extensions it is keyed by table key, and each entry is { table, name, relations }. Plugin code that iterated the schema module with isTable(...) must read ctx.schema[key].table. (8b09683)
  • Column metadata follows Drizzle 1.x. dataType is now "<type> <constraint>" (for example number int32), so compare its first part. PostgreSQL arrays are the element column with dimensions > 0, and there is no PgArray class. See upgrading. (8b09683, 9614cdc)

API and types

  • Stricter delegate argument types. Unknown keys are compile errors at the top level and inside where, data, create, update, select, include, and orderBy. So is select combined with include at the same level. updateEach options get the same check. See upgrading. (64af0c4, 48df501)
  • OrderType was removed from better-drizzle and better-drizzle/zod. Write 'asc' / 'desc'. (ce1a297)
  • update() and delete() return ThrowingWriteResult. The type drops .explain(), which writes never supported at runtime. .throw() is unchanged. See throwing results. (ce1a297)
  • cursor() cursors are typed. after / before accept only a cursor object or null, not strings. CursorPaginationResult types nextCursor and previousCursor as that cursor object (or null), so they can be passed back without a cast. See cursor pagination. (fd84f34, ce1a297)
  • create({ skipDuplicates }) is typed nullable. When skipDuplicates is set and not false, the result type includes null, matching the runtime result for a skipped insert. See create. (35d4137)
  • lock is rejected inside nested relation args. Nested include and relation select args no longer accept lock in their types, and the Zod nested query schema drops it. The runtime always rejected it with LOCK_NOT_SUPPORTED, and that check stays for untyped callers. See locks. (c0013ae)
  • 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. { equals } and param() count as equalities. Any other where, including { id: undefined }, throws UNIQUE_WHERE_REQUIRED before SQL runs. This covers regular reads, .explain(), and .prepare(). Use findFirst for non-unique lookups. See findUnique. (74c6cf7, ba1d106)
  • Invalid arguments throw INVALID_ARGS (status 400). Input validation failures used OPERATION_ERROR (status 500). These include 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 parse failures. Dialect limits, schema limits, and wrapped failures keep OPERATION_ERROR. See error codes. (9b9b62b)

Plugins

  • better-drizzle/rules: mergeRules was removed. Use merge({ extends, rules }). extends takes one config or an array applied in order, rules holds overrides applied last, and undefined / null / false entries are skipped. The merge is shallow per rule. The MergeRulesOptions type is new. See rules. (a92a7ce)
  • config.requires.columns enforces optional and type. An optional column may be missing. A type that matches neither the column's columnType, its full dataType, nor one part of it fails bootstrap with PLUGIN_REQUIRED_COLUMN_TYPE. Before, type was informational and optional columns still failed. See setup and fail-fast requirements. (5fcee33)

Behavior changes

Reads

  • Reads are lazy. findMany, findFirst, findOne, findUnique, count, exists, paginate, and cursor return lazy thenables, like Drizzle's query builders. The query and its hooks run on the first then / catch / finally (or await, or Promise.all), and at most once. A read that is never awaited does not run. Wrap reads with Promise.resolve(...) for Bun's expect(...).resolves / .rejects. See testing. (9614cdc)
  • .explain() output. Cursor probes that need a row from the data query are reported under deferredProbes instead of running. A statement that only runs under a condition has a condition string. .explain() alone never runs the read. See explain. (9614cdc, 64af0c4)
  • cursor() without orderBy pages by primary key ascending. The default direction used to depend on the call, so the first page and later pages could walk different orders. See cursor pagination. (337cf06)
  • 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), including prepared param() patterns. PostgreSQL keeps ILIKE. See string filters. (25be62b)
  • mode: 'insensitive' applies to equals, in, notIn, and scalar not. These string operators used to ignore mode, even on PostgreSQL. They now lower both sides in SQL on every dialect: lower(column) = lower(value), lower(column) in (lower(...), ...), and not (...) for notIn and not. null and empty lists keep their previous semantics, and a nested not: { ... } still reads its own mode. Prepared param() values work, including PostgreSQL list params (lower(column) = any(select lower(v) from unnest($1::text[]) v)). PostgreSQL JSONB path filters and array element predicates (some / every / none) follow the same rule; insensitive array predicates skip the @> / && fast paths. See string filters. (a0808d3)
  • contains and startsWith escape wildcards. %, _, and the SQL escape character are matched as literal text. This also applies to case-insensitive filters, nested not, PostgreSQL JSONB paths and array elements, and prepared params. Use a Drizzle SQL fragment for wildcard patterns. See string filters. (784ac15)
  • Unknown select keys throw before SQL runs. This includes keys set to false or undefined and nested projections. Batch write projections (updateMany, deleteMany, updateEach, upsertMany) accept scalar columns only. See selecting fields. (fef4015)
  • JSON path filters require jsonb. Drizzle 1.x types do not tell json from jsonb, so a path filter on a json column type-checks but throws JSON path filters require a jsonb column; json columns only support whole-document filters. See JSONB. (9614cdc)

Writes

  • update() and delete() change at most one row. In 0.2.x they ran their where against every matching row and returned the first. A where that pins the primary key, a unique column, or every column of a composite unique key runs as a plain statement. Any other where is limited to one row: a keyed LIMIT 1 subquery on PostgreSQL and SQLite (ctid / rowid without a primary key), and SELECT ... FOR UPDATE plus a write by primary key on MySQL, in the active or an implicit transaction. Use updateMany / deleteMany for several rows. See update and delete. (191dbb3, 7e81d12, a306502, ba1d106)
  • Native upsert on unique keys. A where with only the columns of one unique key, and the same values in create, runs one INSERT ... ON CONFLICT (columns) DO UPDATE on PostgreSQL and SQLite. On MySQL it runs ON DUPLICATE KEY UPDATE 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. That path inserted without checking where; it now reads first. The read-then-write path updates the row it found. See upsert. (10179cd, 191dbb3)
  • MySQL upsert checks other unique keys. The native ON DUPLICATE KEY UPDATE path runs only when the table declares no unique key besides the target. MySQL fires that clause on any unique key, so other tables use the read-then-write path instead of possibly updating a different row. (9614cdc, 10179cd)
  • Text-backed timestamps are ISO strings. timestamps() and softDelete() write ISO 8601 strings to columns whose dataType is a string (such as SQLite text) and Date objects to native date columns. See timestamps. (c0ceef0, a0b6e6e)

Transactions, errors, and hooks

  • No rollback handling after COMMIT. When an afterCommit callback or an afterTransactionCommit hook throws, there is no ROLLBACK on SQLite and no afterRollback or onTransactionError hook. The error is rethrown as-is and the data stays committed. See transactions. (9614cdc)
  • All success callbacks run. Every afterCommit callback, afterTransactionCommit hook, and plugin afterRaw hook runs even when an earlier one throws. The first error is still rethrown. (3271b63)
  • Driver errors are unwrapped. Drizzle 1.x wraps driver errors in DrizzleQueryError with the driver error as cause. BetterDrizzleError.from(...), getDatabaseErrorInfo, isUniqueViolation / isForeignKeyViolation / isNotNullViolation / isCheckViolation, and transaction retries read the innermost driver error, so codes and constraint names still resolve. Code that reads raw driver errors around raw Drizzle calls should read error.cause. See errors. (2fddee2)
  • 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. (2fddee2)

Plugins

  • softDelete() filters every read and every write with a where. In 0.2.x only findMany, findFirst, count, and exists hid deleted rows. findUnique, findOne, paginate, cursor, update, updateMany, updateEach, delete, and deleteMany now do too, and each accepts deleted. deleteMany() is a soft delete and returns the count it marked. Writes with an empty where stay no-ops, mode: 'hard' still matches deleted rows, and upsert / upsertMany and relation loads stay unfiltered. See soft delete. (1dad322)
  • Hard-delete rules check deleteMany. requireTenantOnDelete, noHardDeleteOnSoftDeleteModel, and requireHardDeleteReason now also check deleteMany. See rules. (1dad322)
  • Raw SQL rules check $executeRaw. requireRawComment, requireRawTimeout, noRawMutation, and noRawWithoutTransaction used to check only $raw. See rules. (86667cb)
  • Zod validates numeric "number mode" as number. numeric(..., { mode: 'number' }) and decimal(..., { mode: 'number' }) columns validate as numbers in $zod (and $ata). The default string mode still validates as strings. See zod. (01eeb27)
  • Zod cursor schemas reject string cursors. $zod (and $ata) cursor args schemas accept only the cursor object or null, matching the types. (5d1843c)

New features

Querying

  • $where() on every delegate. It compiles a typed where, including logical operators and relation filters, into a Drizzle SQL condition for raw db.select() queries, joins, and subqueries. See reusing a filter in raw Drizzle. (309278a, #63)
  • NULL ordering. orderBy accepts { direction, nulls: 'first' | 'last' }. PostgreSQL and SQLite use native NULLS FIRST / NULLS LAST, MySQL emulates it with IS NULL, and unsupported dialects fail when the client is created. See ordering. (b129c9f)
  • PostgreSQL array filters. Native array columns get a typed ArrayFilter with has, hasEvery, hasSome, hasNone, containedBy, isEmpty, length (total cardinality), equals, and not. They compile to @>, &&, <@, and cardinality() with bound params, and throw ARRAY_QUERY_UNSUPPORTED outside PostgreSQL. See arrays. (17b91a7, f5b829c, 0e5ab13, #50)
  • Array element predicates. some, every, and none take the element's typed scalar filter. Simple predicates use GIN-compatible containment or overlap, lone comparisons use ANY / ALL, and the rest use unnest(). See arrays. (598d33f, 77bfb7b, #50)
  • JSONB dotted path filters. { metadata: { 'profile.age': { gte: 18 } } } works without the { json: ... } wrapper. The wrapper is still supported and no longer marked deprecated. See JSONB. (0000afe, ce1a297)

Writing

  • Atomic updates. Number columns accept set, increment, decrement, multiply, and divide, and boolean columns accept toggle: true, in update, updateMany, updateEach, upsert, and upsertMany. Invalid envelopes, non-finite operands, and division by zero fail before SQL runs. Post-write hooks receive the compiled expressions as compiled. See atomic updates. (3194d6d, 6c87e8a, 6b3c4c1, #53)
  • PostgreSQL array mutations. Array columns accept one of append, prepend, remove, replace, or addUnique in the same write methods. addUnique is one statement that keeps input order and skips existing values. They throw ARRAY_MUTATION_UNSUPPORTED outside PostgreSQL. See arrays. (0e5ab13, #50)
  • JSONB path mutations. Dotted paths and the { json: ... } wrapper update nested keys through chained jsonb_set(..., true) calls in update, updateMany, updateEach, upsert, and upsertMany. Paths and value types are checked for $type<T>() columns. Missing ancestors are created, and unrelated keys are kept. Conflicting paths throw, and dotted paths throw JSONB_MUTATION_UNSUPPORTED outside PostgreSQL. See JSONB path mutations. (134d156, 27678db, 15a81a7, #54)
  • updateMany and deleteMany return rows. Both return BatchResult<Payload> ({ count, data? }) and accept a scalar select. PostgreSQL and SQLite return the affected rows through RETURNING. Empty results omit data, and MySQL returns only { count }. See updateMany and deleteMany. (e4c9b8e, 187864f)
  • createMany batchSize. Splits large inserts into one statement per chunk, like upsertMany. count sums the rows actually inserted (including with skipDuplicates), returned rows keep input order, and hooks fire once per call. See createMany. (32688f4)
  • upsertMany on MySQL. Compiles to ON DUPLICATE KEY UPDATE. target must be the primary key or one unique key, rows that set (or defaults that fill) another unique key are rejected, where is rejected, and count is the number of rows sent. See upsertMany details. (ba58421, 5864e7f)
  • upsertMany structured where. where accepts a structured filter as well as a Drizzle SQL fragment. It decides whether the conflicting row is updated and does not filter incoming inserts. PostgreSQL and SQLite only. See upsertMany. (ba0dfba)

Prepared statements

  • Prepared reads. Mark values with param(name) (exported from better-drizzle), call .prepare(name?) on any read, and run it with execute(values). Plugins and beforeQuery run once at prepare time. Intercepts, afterQuery, plugin after hooks, and onError run on each execution. Statements support per-execution meta, .throw(), and explain(values). Writes cannot be prepared. See prepared statements. (a2100f6, f212662, #64)
  • Params in filters and pagination. param() works as any filter operand and as take, skip, page, perPage, limit, cursor, after, and before. Its value type is inferred from where it is used. in / notIn params bind one array and are PostgreSQL-only. Params inside relation include / select args are not supported. See where params can go. (d63fed4, 3fc71cb, 58aa710)
  • Prepared types. PreparedParam, PreparedParams<typeof statement>, PreparedResult<typeof statement>, and Bindable<T> are exported. (d63fed4, a2100f6)
  • Prepared error codes. PREPARED_PARAM_MISSING, PREPARED_PARAM_UNKNOWN, and PREPARED_UNSUPPORTED, all with status 400. See errors. (6393092)

Pagination

  • paginate() takes page and perPage. They are shorthand for skip + limit. page cannot be combined with skip. $zod (and $ata) pagination schemas accept them too. See offset pagination. (cd10154)
  • Cursor pagination over nullable keys. Cursor tokens include every orderBy field, so a nullable sort key works when you repeat the same order and add a unique, non-null tie-breaker. See cursor pagination. (63a924c)

Errors

New BetterDrizzleErrorCode values (see error codes):

CodeStatusThrown when
INVALID_ARGS400operation arguments are invalid (9b9b62b)
UNIQUE_WHERE_REQUIRED400a findUnique where does not pin one row (74c6cf7)
PREPARED_PARAM_MISSING400execute() misses a value for a param (6393092)
PREPARED_PARAM_UNKNOWN400execute() gets a value for an unknown param (6393092)
PREPARED_UNSUPPORTED400the query shape or dialect cannot be prepared (6393092)
ARRAY_QUERY_UNSUPPORTED400an array filter runs outside PostgreSQL (f5b829c)
ARRAY_MUTATION_UNSUPPORTED400an array mutation runs outside PostgreSQL (0e5ab13)
JSONB_MUTATION_UNSUPPORTED400a JSONB path mutation runs outside PostgreSQL (134d156)
PLUGIN_REQUIRED_COLUMN_TYPE500a plugin's required column has the wrong type (5fcee33)
CACHE_INVALID_OPTIONS500the cache plugin gets invalid options (03e2810)
CACHE_SERIALIZATION_ERROR500a cached value cannot be serialized or deserialized (03e2810)
CACHE_STORE_ERROR500the cache store fails to read, write, or delete (03e2810)
CACHE_VALUE_TOO_LARGE500a serialized value exceeds maxSize (03e2810)

OPERATION_ERROR now means an operation failed for a reason other than invalid arguments.

Transactions and raw SQL

  • Raw option extensions. Plugins can add typed raw SQL options by augmenting the RawOptionsExtensions interface. Raw hooks receive unknown options unchanged in rawOptions. The cache plugin's cache: { invalidate } raw option uses this. See writing plugins. (03e2810)

Types

  • Per-table model extensions as interfaces. Declare them with an interface extending ModelExtensionTypeResolver (HKT style). Generic function resolvers still work but can hit TS2589 with Drizzle 1.x types. better-drizzle/zod adds ZodModelExtensionResolver in this form. See writing plugins. (64af0c4, 606b068)
  • PluginModelInfo exposes keys and relations. primaryKey lists the primary key column keys, and relations maps each relation to { model, kind, foreignKey, through? }. (03e2810)
  • Typed batch results. updateMany and deleteMany return BatchResult<PayloadForArgs<...>> instead of BatchResult<never>. See typing results. (e4c9b8e)
  • JSDoc matches runtime behavior. Comments that contradicted the runtime were corrected, and examples no longer show the removed schema option. (ce1a297, f78e498)

Plugins

Cache (experimental)

  • New better-drizzle/cache plugin and better-drizzle/cache/redis store. Opt-in read caching with versioned keys. Every read accepts cache (true, a custom key, or { ttl, negativeTtl, tags, key, vary, refresh, afterHooks }), and models can opt in through models. Observed writes invalidate their dependencies (rows, entities, model epochs, and declared foreign-key relations). Inside a transaction, invalidation waits for the commit and is dropped on rollback. See cache. (d39942f, dba31b1, #60)
  • Manual invalidation. client.$cache.invalidate({ models, tags, keys }) and client.$cache.clear(), plus cache: { invalidate } hints on writes and raw calls. See invalidating by hand. (d39942f, 03e2810)
  • Custom stores. store takes any CacheStore. The Redis store is one implementation. See custom stores. (d39942f, eaa1cbd)
  • Prepared reads are cached by their values. Placeholders hash as named tokens, and the execution values join the key. (a7cf39f)
  • Experimental. Options, the $cache API, the store interface, and the entry format can change in a patch release during 0.3.x. See stability. (adc5bb8)

ATA (experimental)

  • New better-drizzle/ata plugin. JSON Schema validation of operation inputs and results with ata-validator, intended as a faster alternative to better-drizzle/zod. ata-validator ^1.30.1 is an optional peer. Schemas are on db.<table>.$ata as plain objects. They compile on first use, or up front with precompile: true. See ata. (9041ec1, e236826, 077dc0d, ecdb8b4, d9482b0, #51)
  • Columns JSON Schema cannot describe. Date, BigInt, and Buffer columns are checked by residue predicates after schema validation. Array columns and their filters are supported. See ata. (9041ec1, 3cb9a0a, ed05f2b)
  • Relation-aware results. Result envelopes are validated through afterCreate, afterQuery, and afterUpdate. (322fe81)
  • Small public surface. The entrypoint exports ata (default and named), its types, and version. The registry, column, row, where, and query-schema builders are internal. (0620970)
  • Experimental. Options, the $ata API, and the generated schemas can change in a patch release. See stability. (1f5e73e)

Zod

  • Drizzle 1.x column schemas. $zod schemas are built from Drizzle 1.x column metadata. The plugin needs a Zod version with Zod 4-compatible schema types (peer ^3.25.0 || ^4.0.0). See zod. (606b068)
  • New inputs validated. PostgreSQL array filters and element predicates, atomic update envelopes, page / perPage, and orderBy nulls. (c6b2b24, 0e5e9ab, d1916f2, cd10154, b129c9f)

Soft delete

  • deleted on every filtered operation. Each filtered read and write accepts deleted. deleteMany() soft deletes. See the behavior change above and soft delete. (1dad322)

Timestamps

  • ISO strings for text columns. See the behavior change above. (a0b6e6e)
  • Clock and per-model options. now?: () => Date sets the clock for every timestamp the plugin writes; it is called once per write, so createdAt and updatedAt match across all rows of the call. models?: Record<string, false | { createdAt?: string; updatedAt?: string }> overrides column names for one model or turns the plugin off for it (false); unknown model keys are ignored. A column named in models that the model lacks makes better() throw PLUGIN_REQUIRED_COLUMN_MISSING. TimestampModelOptions is exported from better-drizzle/timestamps. See timestamps. (18b8e31)

Rules

ESLint

  • No rule changes. The documentation now lists every rule id. See eslint. (d25e22f)

Plugin API

  • Intercepts. intercept(ctx) wraps execution after before hooks, transforms, and the client beforeQuery hook, so it sees the final args. Plugins compose outermost-first. next() runs the operation, annotate() passes values to after hooks as annotations, and skipAfterHooks() skips them. On prepared executions, ctx.params holds the values. .explain() never runs intercepts. Intercepts are stable API. See intercepts. (03e2810, ac02e78, eaa1cbd)
  • operationArgs in intercepts. The intercept context carries the same typed plugin operationArgs as before hooks. See typed operation args. (03e2810)
  • Raw option extensions. See transactions and raw SQL. (03e2810)
  • requires.columns checks. See breaking changes. (5fcee33)

Performance

Published overhead figures are on the benchmarks page.

  • One query per cursor page. Populated single-primary-key cursor() pages derive hasPrevious from an inline EXISTS check in the data query instead of a second probe. Empty pages and complex shapes keep the exact fallback probe. See benchmarks. (a7b38a5)
  • Native upsert on unique keys. See writes. (10179cd)
  • Plain single-row writes on composite unique keys. update() and delete() skip the LIMIT 1 subquery when where pins every column of a composite unique key. Unique constraints and non-partial unique indexes are precomputed per table. (a306502)
  • Less work per transaction. Read specs are built lazily, once per delegate and kind, and the delete spec is cached on first use. Transactions create delegates for every table and no longer pay for operations they never run. (f212662, 3809019)

Fixes

  • version was stale. Every entrypoint reported 0.1.1. All entrypoints now report the package version. (058dd28)
  • JSONB in, notIn, and mode. JSONB path filters accepted them in the types, but the compiler ignored them, so in matched every row. Lists now compile to a type-guarded IN per JSON type, and mode: 'insensitive' uses ILIKE. See JSONB. (eb0a633)
  • MySQL single-row writes. update() and delete() with a non-unique where read and wrote in separate statements, so a concurrent write could make the returned row differ from the changed one. They now lock the row first. (7e81d12)
  • MySQL upsertMany builder. onDuplicateKeyUpdate is called on its insert builder, which it needs as this. (5864e7f)
  • Soft delete on text columns. Deletion timestamps are ISO strings for text-backed columns. (c0ceef0)
  • Rules limit detection. Removed fallback keys that no current operation passes. (86667cb)
  • Query parity on Drizzle 1.x. Restored compiler, explain, and operation parity across PostgreSQL, SQLite, and MySQL after the migration, including quoted PostgreSQL enum array casts. (9614cdc, 64af0c4)
  • Security. Resolved CodeQL findings in error message parsing, the ata registry, and the rules plugin. (57f2b19)

Documentation and tooling

Contributors

Thanks to @mertcanaltin (#51), @flaxodotdev (#54), and @joaotonaco (#63).

On this page