better-drizzle 0.3 now runs on Drizzle ORM 1.0 RC. Read the upgrade guide

Changelog

Every better-drizzle release, newest first. Also available as Markdown and RSS. To move between versions, see the upgrading guide.

0.4.0 adds tenant isolation: the experimental better-drizzle/tenant plugin, built on a new core primitive for plugin authors, model scopes. The soft-delete plugin moves onto model scopes, which changes what nested reads return.

Behavior changes

  • Soft-deleted rows are hidden in nested reads. better-drizzle/soft-delete used to filter only the root query, so deleted rows appeared in include, relation select, relation filters (some, every, none, is, isNot), include._count, and relation sorts. They are hidden there now. The deleted argument still controls the root rows only. connect, disconnect, and set no longer reach deleted rows. $withoutPlugins() still reads deleted rows at every level. See soft delete.
  • Transactions rethrow callback errors unwrapped. An error thrown inside the transaction(...) callback, including a nested one, now reaches the caller as the same object instead of a BetterDrizzleError with code OPERATION_ERROR and operation "transaction". onTransactionError hooks still receive the normalized error. See transactions. (#81)
  • Soft deletes and restores keep other scopes. The inner update of a soft delete() / deleteMany() and of restore() / restoreById() runs through $withoutPlugins({ keepScopes: true }) instead of $withoutPlugins(), so on a tenant-scoped client it only touches that tenant's rows.

Features

  • Tenant plugin (experimental). better-drizzle/tenant scopes every model that has a tenant column: root reads, relation loads and filters, _count, relation sorts, writes, upserts, and relation-write lookups. db.$tenant({ ... }) returns a scoped client, db.$withoutTenant({ reason }) an audited bypass. It stamps tenant columns on create (optional in create types and zod/ata schemas), rejects changes to them, fails closed without a tenant, denies raw SQL on scoped clients by default, and can set PostgreSQL RLS settings per transaction. Options: columns, global, models, missing, raw, rls, and strict. See tenant.
  • Model scopes for plugin authors. A plugin can declare scope: { key, model, root, missing, mismatch, bypass, session, rawSession }. The core ANDs each model's scope into every statement that references the model, stamps and protects scope columns on writes, and handles upsert conflicts per dialect. extendClient receives scope and withScope(state, meta?); hooks, transforms, intercepts, and raw hooks receive scopes. Clients without scope plugins keep their previous code paths. See model scopes.
  • $withoutPlugins({ keepScopes: true }). Skips plugin hooks and transforms but keeps model scopes. $withoutPlugins() without options drops every scope; on a tenant-scoped client its operations pass meta.tenantBypass = { reason: '$withoutPlugins' }.
  • New error codes. TENANT_REQUIRED (403), TENANT_MISMATCH (403), TENANT_RAW_DENIED (403), and TENANT_ALREADY_SCOPED (400). See error codes.
  • ClientExtensionTypeResolver. A type-level resolver for client extensions typed from the schema, passed as the seventh definePlugin generic. Its optional hidden removes table keys from the root client type.
  • better() returns BetterRootClient. It equals BetterDrizzleClient unless a plugin hides models (the tenant plugin's strict option), in which case the hidden delegates are removed from the root client and its $withContext(), extends(), and transaction clients.

Fixes

  • Empty updates are a no-op. When data has no column values after plugin transforms, update returns the current row, updateMany returns { count: 0 }, and upsert returns the existing row, instead of failing with Drizzle's No values to set. (#82)

Contributors

better-drizzle now runs on Drizzle ORM 1.0 RC (relational queries v2). This release also adds prepared statements, relation ordering, a UUID plugin, two experimental plugins (cache and ata), atomic updates, PostgreSQL array and JSONB path support, $where() for typed conditions in raw Drizzle SQL, and stricter types and runtime checks.

Highlights

  • Drizzle ORM 1.x. Tables and relations come from your drizzle({ relations }) instance, so better(db) needs no schema option. (#57)
  • Prepared reads. Mark values with param() and call .prepare() on any read. (#64)
  • Read cache (experimental). Opt-in Redis caching with automatic, relation-aware invalidation after writes and commits. (#60)
  • Atomic updates. increment, decrement, multiply, divide, and toggle without reading first. (#53)
  • PostgreSQL arrays and JSONB. Typed array filters and mutations, and typed JSONB path updates through jsonb_set. (#50, #54)
  • $where(). Compile typed better-drizzle conditions into raw Drizzle SQL. (#63)
  • Relation ordering. Sort by fields of one relations, at any depth, or by _count of to-many relations, with correlated subqueries. (#67)
  • UUIDs. better-drizzle/uuid fills UUIDv7 ids on create, makes them optional in create types, and buuid() stores them as 16 bytes on MySQL and SQLite. (#71)
  • ATA plugin (experimental). JSON Schema validation built on ata-validator, a faster alternative to the Zod plugin. (#51)
  • Also: createMany({ batchSize }), insensitive equals / in / notIn, a timestamps now clock with per-model overrides, and INVALID_ARGS (400) for invalid arguments.

Install

npm install better-drizzle@0.3.0 drizzle-orm@1.0.0-rc.4

The drizzle-orm peer range is >=1.0.0-rc.4 <1.0.0-rc.5. Install it with the explicit version: until Drizzle 1.0 is tagged latest on npm, a plain drizzle-orm install resolves to 0.x. Projects on Drizzle 0.x should stay on better-drizzle@~0.2.0.

This release has breaking changes. Follow the upgrading guide before you bump. The same notes live in the changelog.

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

  • Relation ordering. orderBy accepts fields of one relations, nested to any depth, and { _count: 'asc' | 'desc' } for many and .through() relations. Correlated subqueries keep the root row count and query count unchanged, including nested relation reads and paginate(). A missing one relation sorts as NULL; an empty to-many count is 0. The cache plugin tracks relation and junction dependencies and preserves nested sort priority; Zod and ATA validate relation sorts. See ordering by relations. (#67)
  • Relation-sort limits. cursor() and the cursor read argument reject relation sorts with INVALID_ARGS, including on the first page. Only _count is supported for to-many sorts, without a filter; sort subqueries do not apply soft-delete filters. Index the relation keys and check .explain() for the cost of correlated sorts. (#67)
  • $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

UUIDs
  • New better-drizzle/uuid plugin. uuids() fills UUIDv7 (default) or UUIDv4 ids on create, createMany, upsert, and upsertMany when they are undefined, and the id becomes optional in create input types. Ids are insert-only on upsertMany conflicts. uuidv7() and uuidv4() are exported. See UUIDs. (#71)
  • buuid() columns. better-drizzle/uuid/mysql (binary(16)) and better-drizzle/uuid/sqlite (blob) store UUIDs as 16 bytes, typed as string. Zod and ATA validate them as UUID strings. (#71)
  • Generated columns for plugin authors. Plugins declare generated: { column, models?, accepts? }; the column becomes optional in create inputs, is listed in model.generated, and is optional in Zod and ATA create schemas in any plugin order. See generated columns. (#71)
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

  • Binary filters. Bare Buffer values now work in where clauses, including lookups by unique binary keys. Both Buffer and Uint8Array values keep the simple read and cursor query paths. (#68)
  • Relation keys set to undefined in writes. create, update, and upsert threw INVALID_ARGS when a relation key was present but undefined, such as optional form data spread into data. They are now skipped like undefined columns; null still throws. (#69)
  • 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

Pull requests

New contributors

Thanks to all four for their work on this release.

All 174 commits since v0.2.0
  • ad48e9e docs: release 0.3.0
  • 967043f style(web): remove the duplicate search from the docs sidebar
  • 02bbce5 feat(web): redesign the landing page and logo
  • d5c1b7a docs: add the 0.3.0 changelog
  • 18b8e31 feat(timestamps): add a clock option and per-model overrides
  • a0808d3 feat(query): apply insensitive mode to equality and list filters
  • 32688f4 feat(createMany): support batchSize
  • 9b9b62b feat(errors)!: report invalid arguments as INVALID_ARGS
  • c0013ae refactor(types)!: reject lock inside nested relation args
  • 9865aa5 build: declare the supported Node.js range
  • 1f5e73e docs: mark ata experimental and mention $where in the skill pack
  • 48df501 fix(types): reject unknown updateEach options
  • 10179cd perf(upsert): use native conflict handling for unique keys
  • ba1d106 feat(delegate): treat { equals } filters as pinned keys
  • 187864f docs: document batch mutation results and align benchmark parity
  • e4c9b8e feat: return affected rows from updateMany and deleteMany
  • ba0dfba feat: support structured conflict filters in upsertMany
  • fef4015 fix: reject unknown select keys before executing queries
  • 784ac15 fix: escape literal wildcards in contains and startsWith filters
  • eaa1cbd docs: complete 0.3.0 upgrade notes and document cache stores
  • 0620970 refactor(ata)!: stop exporting internal helpers
  • 3809019 perf(delegate): build the delete operation spec on first use
  • 74c6cf7 feat(delegate)!: require a unique where in findUnique
  • 7e81d12 fix(delegate): lock MySQL single-row writes in a transaction
  • a306502 perf(delegate): detect composite unique keys for single-row writes
  • 5d1843c fix(zod,ata): reject string cursors in cursor args schemas
  • 9345101 docs(agents): record single-row writes and insensitive filter dialects
  • ce1a297 refactor(types)!: tighten write results, cursor tokens, and JSDoc
  • 191dbb3 fix(delegate): limit update and delete to a single row
  • 25be62b fix(query): support insensitive string filters on SQLite and MySQL
  • 2232352 ci(release): gate publishing on CI and fix the published check
  • 109e459 docs(agents): record prepared statement internals
  • aa43117 docs(prepared): document prepared statements
  • 3e5d92c perf(bench): compare prepared reads with Drizzle prepared statements
  • d07d9df test(prepared): cover prepared reads on SQLite, PostgreSQL, and MySQL
  • a7cf39f feat(cache): key prepared reads by their values
  • f212662 feat(delegate): add .prepare() to read results
  • ac02e78 feat(plugins): pass prepared values to intercepts
  • a2100f6 feat(prepared): compile reads into Drizzle prepared statements
  • 58aa710 refactor(operations): split cursor pages into build and finish steps
  • 3fc71cb feat(query): bind prepared params in the where compiler
  • d63fed4 feat(types): accept param() in filters and pagination options
  • 6393092 feat(errors): add prepared statement error codes
  • 309278a feat: add $where() method for compiling typed conditions in raw Drizzle queries
  • 700d5d2 ci: run the PostgreSQL, MySQL, and Redis suites
  • adc5bb8 docs(cache): mark the cache plugin as experimental
  • d635403 docs(multi-tenancy): warn that transforms do not isolate tenants
  • 95bfede docs(upgrading): cover 0.3.0 breaking and behavior changes
  • 21cfd3e fix(deps): cap the drizzle-orm peer below 1.0.0-rc.5
  • dc85b94 docs(cache): clarify invalidation guarantees and performance costs
  • cb411d8 perf(bench): add cache workloads with result parity checks
  • f3f180c test(cache): cover invalidation races and query identity regressions
  • d39942f feat(cache): add versioned read caching and Redis storage
  • 3271b63 fix(client): finish success callbacks despite observer failures
  • 03e2810 feat(plugins): add operation intercepts and raw option extensions
  • ca09a00 docs(cache): document the cache plugin and plugin intercepts
  • 143d1b0 test(cache): cover cache plugin, Redis store, and plugin intercepts
  • dba31b1 build(cache): register cache and cache/redis subpaths
  • c9b57aa chore(dev): add Redis service to docker compose
  • d26beb8 style: format merged and numeric-mode tests
  • 5864e7f fix(upsertMany): guard the MySQL builder method type
  • a8cabae docs(skill): drop reference to removed type test files
  • 37c6215 docs: document soft delete coverage, page inputs, and MySQL upsertMany
  • ba58421 feat(upsertMany): support MySQL with ON DUPLICATE KEY UPDATE
  • cd10154 feat(paginate): accept page and perPage
  • 1dad322 feat(soft-delete): filter every read and write with a where
  • c8a8790 docs(skill): drop resolved known issues
  • 01eeb27 fix(zod,ata): type numeric number mode as a number
  • 2fddee2 fix: unwrap Drizzle 1.x errors and keep library error codes
  • fc2aa00 chore(types): remove inline type test files
  • 861551f docs: align AI page and query options with the skill rewrite
  • 8974e21 docs(skill): rewrite skill pack for app developers
  • 1d8e642 chore(docker): remove unused Postgres init schema
  • 9effb9c chore(env): document each .env.example key
  • 1d90dc8 docs: correct JSONB shorthand and junction caveats
  • 58f6da2 test: cover Drizzle 1.x migration failure modes
  • f78e498 docs(types): drop removed schema option from JSDoc examples
  • d1562c4 docs: expand Drizzle 1.x upgrade guide and fix stale pages
  • f56a6d8 docs(agent): update migration guidance and audit notes
  • 2b4736e docs: explain the Drizzle 1.x API and upgrade path
  • 89a98da perf(bench): migrate parity suites and stabilize array samples
  • a0b6e6e fix(plugins): align timestamps and guardrails with Drizzle 1.x
  • ed05f2b fix(ata): validate Drizzle 1.x residues and overrides
  • 606b068 fix(zod): support Drizzle 1.x column schemas
  • 64af0c4 fix(types): preserve delegate inference with Drizzle 1.x
  • 9614cdc fix(core): restore query parity across Drizzle 1.x dialects
  • 8b09683 feat(core): bind Drizzle 1.x relation metadata
  • 15a81a7 fix(jsonb): typecheck upsertMany paths and document mutations
  • 27678db fix(jsonb): harden path mutations and add parity coverage
  • 63a924c fix(orderBy): handle nullable cursor ordering
  • 134d156 feat(jsonb): add typed JSONB path mutations via jsonb_set
  • b129c9f feat(orderBy): add null directions
  • 0000afe feat(jsonb): add dotted path filter shorthand
  • 57f2b19 fix(security): resolve codeql findings
  • 2123019 docs: document atomic updates
  • f29295a bench(writes): measure atomic update parity
  • cd69e4f test(core): cover atomic write operations
  • d1916f2 feat(validation): support atomic update envelopes
  • 6b3c4c1 feat(hooks): expose compiled atomic expressions
  • 6c87e8a feat(core): compile atomic scalar mutations
  • 3194d6d feat(types): add atomic scalar update inputs
  • 03bab62 docs(ata): align navigation icon with zod
  • 322fe81 fix(ata): validate relation-aware operations
  • 1715289 test(ata): benchmark validation as a share of a real operation
  • 3cb9a0a feat(ata): array columns and their filters
  • 9599064 test(ata): benchmark the two validation plugins on one table
  • d9482b0 chore(ata): require the ata-validator release the where clause needs
  • 077dc0d feat(ata): the plugin, its registry and the operation schemas
  • e236826 feat(ata): where clause schema
  • ecdb8b4 chore(ata): ata-validator as an optional peer, like zod
  • 9041ec1 feat(ata): column mapping and row validation
  • e90c0f8 docs(agents): document array predicate compilation
  • 2447d2c docs(guides): add common query recipes
  • 2769b85 docs(arrays): explain element predicate semantics
  • e716815 perf(arrays): benchmark element predicate parity
  • 254561c test(arrays): cover element predicate edge cases
  • 0e5e9ab feat(zod): validate array element predicates
  • 77bfb7b feat(query): compile array element predicates
  • 598d33f feat(types): expose array element predicates
  • 587b769 docs(guides): add typing, testing, observability, and troubleshooting guides
  • fa4b450 docs(querying): add a null and undefined page
  • bcf7a2d docs(arrays): document GIN indexing and helper usage
  • a2e5b56 chore: note array filter constraints in AGENTS.md
  • 0e5ab13 feat(arrays): refine PostgreSQL array filters, zod schemas, and types
  • 7d9de7c docs(arrays): improve raw SQL examples
  • f196cc3 docs(agents): record array filter constraints
  • 2e6c2c8 docs(arrays): document PostgreSQL array filters
  • 84583f7 perf(arrays): add PostgreSQL parity benchmark
  • dbbfb77 test(arrays): cover typed PostgreSQL array filters
  • c6b2b24 feat(zod): validate PostgreSQL array filters
  • f5b829c feat(arrays): compile PostgreSQL array predicates
  • 17b91a7 feat(arrays): add typed array filters
  • b20db3f perf(web): use cnfast
  • 9705f39 perf(web): skip twoslash hovers on object keys and Drizzle tabs
  • 53f0406 style(web): restore oxfmt formatting
  • 5001150 chore(deps): sync lockfile with twoslash dependencies
  • 5a214f8 feat(doc): add twoslash support
  • 736b898 docs(guides): expand frameworks, extensions, repositories, parity, and limitations
  • d25e22f docs(plugins): document hooks, eslint rule ids, and plugin options
  • 9e752e6 docs(reference): complete errors, model API, query options, and client pages
  • 5fcee33 feat(plugins): honor optional and type in requires.columns
  • 86667cb fix(rules): check $executeRaw in raw rules and drop unused limit keys
  • fd84f34 fix(types): accept only cursor objects in cursor() after/before
  • 337cf06 fix(cursor): page by primary key ascending when orderBy is omitted
  • 35d4137 fix(types): type create() as nullable when skipDuplicates is set
  • 834a2b7 fix(web): theme-aware sponsors, light logo, and mobile grid overflow
  • 90dfa9e style: replace em dashes with plain hyphens
  • dcd5c96 chore: note sidebar and example conventions in AGENTS.md
  • ec99046 docs: destructure results in pagination, write, and explain examples
  • ccb2d70 docs: replace the comparison page with Why better-drizzle?
  • f9286b3 docs: reorder the sidebar into a basic-to-advanced learning path
  • 4ee204b feat(web): slim docs header that hides on scroll down
  • 97f4be7 fix(web): highlight docs code with the oniguruma engine
  • a420e77 docs(rules): document every rule, preset level, and option
  • a92a7ce feat(rules)!: replace mergeRules with merge({ extends, rules })
  • 0bd18b5 feat(web): highlight text selection with the brand color
  • 0f8e9a6 docs(README): fix broken links and make it better
  • ee941b3 chore(deps): sync lockfile optional peer metadata
  • a2e468a docs: republish benchmark numbers from a single measured run
  • ed71127 feat(benchmark): add an interleaved overhead report
  • d58c894 fix(benchmark): repair the JSONB suite and assert plan parity
  • c133b4d fix(benchmark): compute hasPrevious on the raw cursor side
  • a7b38a5 perf(cursor): resolve navigation metadata in one indexed query
  • ce4326a chore(oxlint): fix error ""core.plugins is possibly undefined""
  • a623279 feat(web): improve SEO metadata across the docs site
  • 1ef96c1 feat(web): add robots.txt and sitemap
  • c0ceef0 fix(soft-delete): serialize text deletion timestamps
  • b4f3652 docs(agents): document docs code block conventions
  • 33178d3 docs(web): add raw Drizzle comparison tabs to API examples
  • 8edd825 docs(web): expand JSONB filters guide with examples and semantics
  • 9e7b373 feat(web): copy docs code blocks on click with a shine effect
  • eb0a633 feat(query): support in, notIn and insensitive mode on JSONB paths
  • 23527ae docs(web): update documentation site for v0.2.0
  • 058dd28 fix(version): align version constants with 0.2.0

Full changelog: https://github.com/almeidazs/better-drizzle/compare/v0.2.0...v0.3.0

0.2.0 ships as one package: the official plugins move from scoped @better-drizzle/* packages to subpath exports of better-drizzle. It also adds relation reads and writes, cursor pagination, row locks, .explain(), and the rules, eslint, and zod plugins.

Breaking change for plugin imports. Follow Upgrading to 0.2 before you bump.

Breaking changes

  • One package, plugin subpaths. Import plugins from better-drizzle/rules, better-drizzle/zod, better-drizzle/soft-delete, better-drizzle/timestamps, and better-drizzle/eslint. The scoped @better-drizzle/* packages are discontinued. better-drizzle/plugins is the new plugin authoring entry point. Every entry point ships ESM, CommonJS, and declarations. (0858c46, a066b56)

Features

  • Relation reads. Nested include and select with where, orderBy, take, skip, and cursor at any depth, loaded by a batched loader (one query per relation node). (42184d3)
  • Relation writes. connect on create, and connect, disconnect, and set on update, in an implicit transaction. (42184d3, 64964a5)
  • Many-to-many. Simple junction tables are inferred. (5942400)
  • include._count. Relation totals as correlated subqueries, without extra round trips. (997f471)
  • cursor() pagination. before / after cursors with hasNext and hasPrevious; paginate() stays offset-based. (37b6c8d)
  • Row locks. lock on reads for PostgreSQL and MySQL, with skipLocked, noWait, and locks.transactionsOnly. (b64c100)
  • .explain(). Every read returns a lazy thenable with a cross-dialect .explain(); relation stages are reported as deferred. (5c451f9, ce0e3e4)
  • extends(). Client-level helpers that survive $withContext() and transaction clients. (290e1cb)
  • PostgreSQL JSONB filters. Typed path filters on JSONB columns. (7dd1cf9)
  • better-drizzle/rules. Runtime guardrails (for example, writes without where). (c7ae257)
  • better-drizzle/eslint. The statically checkable rules as an ESLint plugin. (2c0fe7b)
  • better-drizzle/zod. Generated Zod schemas on db.<table>.$zod and opt-out runtime validation. (28822b0)
  • Agent skill. A first-party skill pack for AI coding agents. (cca8f2e)

Fixes

  • To-many relation filters correlate on the parent row. (#27)
  • Relation filters under include reference the aliased parent table. (421d320)
  • The zod plugin keeps relation commands during validation. (07b0e34)

Tooling

  • Builds move to tsdown; linting and formatting move to Ultracite (oxlint and oxfmt). (0c6216f, 369d7b7)
  • A real-database integration suite and a full parity benchmark suite. (0a433ac, c705d68)

Contributors

Full changelog: https://github.com/almeidazs/better-drizzle/compare/better-drizzle@0.1.1...v0.2.0

Adds batch updates with updateEach and scoped metadata with $withContext(). @better-drizzle/timestamps@0.1.1 and @better-drizzle/soft-delete@0.1.1 were released with it.

Features

  • updateEach. Updates many rows with different values in one UPDATE ... SET column = CASE ... END statement. Takes by, data, update, an optional where, optional scalar select, and onEmpty, and rejects duplicate by values. (#12)
  • $withContext(meta). Returns a client that merges default meta into every operation, raw SQL call, and transaction lifecycle payload. (68f3f9a)

Packaging

  • engines.node is >=18. (#13)
  • The package is built with tsc, and the drizzle-orm peer range starts at 0.30.0. (bb76ca6)

Documentation

Full changelog: https://github.com/almeidazs/better-drizzle/commits/better-drizzle@0.1.1

0.1.0

2026-06-26

First public release of better-drizzle: a repository-style API on top of an existing Drizzle client, for PostgreSQL, MySQL, and SQLite.

Highlights

  • Table delegates. findMany, findFirst, findUnique, create, createMany, update, updateMany, delete, deleteMany, upsert, count, exists, paginate, and repository(name) lookup.
  • Duplicate handling. create and createMany accept skipDuplicates (true or a list of columns). (#10)
  • upsertMany. Native-first batch upsert with conflict targets, update strategies, chunking, and optional select. It fails fast on unsupported dialects instead of falling back to per-row loops. (#11)
  • Transactions. Nested savepoints, retries, explicit rollback, afterCommit, and afterRollback.
  • Raw SQL. Safe $raw and $executeRaw with tagged templates, plus $rawUnsafe behind an explicit option.
  • Plugins. Typed operationArgs, transforms, hooks, client extensions, and model extensions. (#1)
  • Errors. BetterDrizzleError with code, status, and structured metadata.

Official plugins

The first plugins shipped as separate scoped packages (merged into better-drizzle in 0.2.0):

  • @better-drizzle/timestamps
  • @better-drizzle/soft-delete

Documentation

Contributors