Upgrading
Move to Drizzle ORM 1.x with defineRelations, or migrate from scoped plugin packages to the unified package.
Moving to Drizzle ORM 1.x
better-drizzle 0.3.0 supports only Drizzle ORM 1.x: the drizzle-orm
peer range is >=1.0.0-rc.4 <1.0.0-rc.5, and the release is tested against
1.0.0-rc.4. Drizzle 1.x replaces the relations(...) helpers with
defineRelations(...) (relational queries v2). better-drizzle now reads
tables and relations from the Drizzle instance, so better() no longer takes
a schema option.
Staying on drizzle-orm 0.x
The 0.2.x line is the last one that works with drizzle-orm 0.x (peer
^0.30.0). It keeps the better(db, { schema }) API. Pin
better-drizzle@~0.2.0 until you upgrade Drizzle itself. There is no
release that supports both major versions of Drizzle.
At a glance
0.2.x (Drizzle 0.x) | 0.3.x (Drizzle 1.x) | |
|---|---|---|
drizzle-orm peer | ^0.30.0 | >=1.0.0-rc.4 <1.0.0-rc.5 |
| Relations | relations(table, ...) per table | one defineRelations(schema, ...) call |
| Drizzle instance | drizzle(client, { schema }) | drizzle({ client, relations }) |
| Client | better(db, { schema, ...options }) | better(db, options?) |
| Where tables come from | the schema option | db._.relations |
| Type parameter | typeof schema | typeof relations |
| Many-to-many | inferred junctions + relations.manyToMany | explicit .through() relations |
Plugin / hook ctx.schema | the schema module | the relations config |
| Driver errors | thrown as-is | wrapped in DrizzleQueryError, driver error on cause |
The delegate API keeps its methods, arguments, and result shapes, apart from
the type changes and
behavior changes listed below. That covers findMany, cursor, upsertMany,
include._count, connect / set, locks, $withContext, raw SQL,
transactions, and every plugin option. The migration touches the lines that
build the client and the type annotations that name the schema.
1. Upgrade drizzle-orm
npm install drizzle-orm@1.0.0-rc.4 better-drizzle@^0.3.0Follow Drizzle's own 1.x migration notes for driver and schema changes. The steps below cover what better-drizzle needs.
2. Move relations to defineRelations
Keep tables in schema.ts and declare every relation in one
defineRelations call:
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));import { defineRelations } from 'drizzle-orm';
import * as schema from './schema';
export const relations = defineRelations(schema, (r) => ({
users: { posts: r.many.posts() },
posts: { author: r.one.users({ from: r.posts.authorId, to: r.users.id }) },
}));Use defineRelations(schema) without a callback when the schema has no
relations. When two relations connect the same pair of tables, give both sides
the same alias. The old relationName option did this job.
3. Pass relations to drizzle() and drop schema from better()
import { schema } from './schema';
const db = drizzle(pool, { schema });
export const client = better(db, { schema, plugins });
import { relations } from './relations';
const db = drizzle({ client: pool, relations });
export const client = better(db, { plugins });better() reads tables and relations from db. Every other option -
plugins, hooks, raw, locks, transaction - is unchanged, and the
options argument is now optional: better(db). A Drizzle instance created
without relations has no tables to read, so better() throws
No tables found on the Drizzle instance. Pass your relations to drizzle(), e.g. drizzle({ client, relations: defineRelations(schema) }).
Only tables that appear in the relations config get a delegate. Pass the whole
schema module to defineRelations(schema, ...), not only the tables that have
relations.
4. Switch type generics to typeof relations
The client's type parameter is now the relations config:
type Db = BetterDrizzleClient<typeof schema>;
type Key = BetterTableKey<typeof schema>;
type UserWhere = WhereArg<typeof schema, 'users'>;
type Db = BetterDrizzleClient<typeof relations>;
type Key = BetterTableKey<typeof relations>;
type UserWhere = WhereArg<typeof relations, 'users'>;The same applies to explicit better<...>() calls and to every exported
helper type. The schema field of plugin setup and hook contexts is the
relations config as well. It is keyed by table key, and each entry is
{ table, name, relations }, so plugin code that iterated the schema module
and checked isTable(...) should read ctx.schema[key].table instead.
5. Replace many-to-many config with .through()
The relations: { inferManyToMany, manyToMany } option is removed, and
junction tables are no longer inferred. Declare many-to-many relations with
.through():
const client = better(db, {
schema,
relations: {
manyToMany: [
{ through: 'userGroups', left: { relation: 'user' }, right: { relation: 'group' } },
],
},
});
users: {
groups: r.many.groups({
from: r.users.id.through(r.userGroups.userId),
to: r.groups.id.through(r.userGroups.groupId),
}),
},Relation filters, include / select, _count, and connect /
disconnect / set work through .through() relations. A junction you
relied on through inference now needs an explicit .through() relation.
Without one, the relation does not exist and using it is a type error. See
relations.
6. Check error handling and plugin column requirements
- Drizzle 1.x wraps driver errors in
DrizzleQueryError(Failed query: ...) with the driver error ascause.BetterDrizzleError.from(...),getDatabaseErrorInfo, andisUniqueViolation/isForeignKeyViolation/isNotNullViolation/isCheckViolationread the wrapped driver error, so codes and constraint names still resolve. Code that inspects raw driver errors directly, such aserror.code === '23505'in acatcharound raw Drizzle calls, should readerror.cause. - Column
dataTypeis now"<type> <constraint>"(for examplenumber int32,object date,string uuid). A plugin'sconfig.requires.columns[].typematches the column'scolumnType(PgTimestamp), its fulldataType, or one part of it ('date','string'). Your own plugin code that comparescolumn.dataType === 'string'must compare the first part instead:column.dataType.startsWith('string'). - PostgreSQL array columns are now the element column with
dimensions > 0; Drizzle 1.x has noPgArraycolumn class orbaseColumn. Plugin code that detected arrays withinstanceof PgArrayshould checkcolumn.dimensions. - JSON path filters require
jsonb. At the type level Drizzle 1.x no longer tellsjsonfromjsonb, so a path filter on ajsoncolumn type-checks but throws at runtime:JSON path filters require a jsonb column; json columns only support whole-document filters. - On Bun SQLite, Drizzle 1.x requires raw
db.transactioncallbacks to be synchronous. better-drizzle'sclient.transaction(async (tx) => ...)still accepts async callbacks.
7. Typecheck
npx tsc --noEmitLeftover schema options, typeof schema generics, and manyToMany config
fail the typecheck.
The delegate argument types are also stricter in 0.3.0. Unknown keys are
rejected at the top level and inside where, data, create, update,
select, include, and orderBy, and so is select combined with
include at the same level. Previously TypeScript let them through. A typo such as { where: { emial: '...' } } is now a compile error.
When a check fails, remove the key or fix its name.
Other type changes the typecheck surfaces:
OrderTypewas removed frombetter-drizzleandbetter-drizzle/zod. Write'asc'/'desc'instead ofOrderType.Asc/OrderType.Desc.cursor()after/beforeaccept only a cursor object ornull. String cursors are a type error; the runtime already rejected them.nextCursorandpreviousCursorare now typed as that cursor object (ornull), so you can pass them straight back without a cast. See cursor pagination.create({ skipDuplicates })is typed nullable. WhenskipDuplicatesis set (and notfalse), the result type includesnull, matching the runtime result for a skipped insert. Handle thenullcase.update()anddelete()returnThrowingWriteResult. The type no longer exposes.explain(), which writes never supported at runtime..throw()is unchanged.
Breaking plugin API changes
better-drizzle/rules:mergeRuleswas removed. Usemerge({ extends, rules }).extendstakes one config or an array applied in order, andrulesholds overrides applied last. Falsy entries inextendsare skipped. See rules.
rules(mergeRules(safe(), { noRawMutation: 'warn' }));
rules(merge({ extends: [safe()], rules: { noRawMutation: 'warn' } }));Behavior changes to review
These do not show up in the typecheck, but they can change what your code observes at runtime:
-
softDelete()filters more operations. In0.2.xonlyfindMany,findFirst,count, andexistshid deleted rows. NowfindUnique,findOne,paginate,cursor,update,updateMany,updateEach,delete, anddeleteManydo too. Anupdate({ where: { id } })that hits a soft-deleted row now matches nothing; passdeleted: '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 emptywherestay no-ops, andmode: 'hard'still matches deleted rows. TherequireTenantOnDelete,noHardDeleteOnSoftDeleteModel, andrequireHardDeleteReasonrules now also checkdeleteMany. See soft delete. -
Reads are lazy.
findMany,findFirst,findOne,findUnique,count,exists,paginate, andcursorreturn lazy thenables, like Drizzle's own query builders. The query and its hooks run when the result is awaited (or passed tothen,catch,finally, orPromise.all), and at most once. A read that is created but never awaited no longer runs..explain()alone never runs the read. Bun'sexpect(...).resolves/.rejectsonly accept native promises, so wrap reads withPromise.resolve(...)there. See testing. -
.explain()output. Cursor probes that depend on a row from the data query are reported underdeferredProbesinstead of being executed, and a statement that only runs under a condition carries aconditionstring. See explain. -
Text-backed timestamps.
timestamps()andsoftDelete()write ISO 8601 strings to columns whosedataTypeis a string (such as SQLitetextcolumns) andDateobjects to native date columns. -
Failures after
COMMIT. When anafterCommitcallback or anafterTransactionCommithook throws, better-drizzle no longer runs rollback handling. There is noROLLBACKon SQLite and noafterRollbackoronTransactionErrorhook. The original error is rethrown as-is, and the data stays committed. -
mode: 'insensitive'on equality.equals,in,notIn, and a scalarnotused to ignoremodeand stayed case-sensitive. They now comparelower(column)withlower(value)on every dialect, including JSONB paths and array element predicates. Aparam()list inin/notInstays PostgreSQL-only. -
MySQL
upsert. The nativeON DUPLICATE KEY UPDATEpath 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
upserton unique keys. Awherethat holds only the columns of one unique key, with the same values increate, now runs oneON CONFLICT ... DO UPDATEstatement on PostgreSQL and SQLite (andON DUPLICATE KEY UPDATEon MySQL when that key is the only one that can match). Awherewithout the primary key no longer takes the primary key path whencreateomits it too: it used to insert without checkingwhere, and now reads first. -
update()anddelete()change at most one row. In0.2.xthey ran theirwhereagainst every matching row and returned only the first. Now only one row changes, on every dialect: awherethat pins the primary key or a unique key runs as a plain statement, and any otherwhereis limited to one row (aLIMIT 1subquery on PostgreSQL and SQLite; on MySQL, aSELECT ... FOR UPDATEand a write by primary key in the active or an implicit transaction). Code that relied onupdate/deletetouching several rows must useupdateMany/deleteMany. Theupsertread-then-write path also updates the row it found. See update and delete. -
cursor()withoutorderBypages by primary key ascending. Before, the default direction depended on the call (descending, or ascending withbefore), so the first page and later pages could walk different orders. Pass an explicitorderByto keep a descending order. See cursor pagination. -
Zod and ATA cursor schemas reject string cursors.
$zodand$atacursor args schemas accept only the cursor object ornull, matching the types, so a stringafter/beforefails validation instead of reaching the runtime. -
Zod and ATA type numeric "number mode" columns as
number.numeric(..., { mode: 'number' })anddecimal(..., { 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 toILIKEeverywhere, which is a syntax error outside PostgreSQL. SQLite and MySQL now compile tolower(column) LIKE lower(pattern); PostgreSQL keepsILIKE. See string filters. -
Raw SQL rules also check
$executeRaw.requireRawComment,requireRawTimeout,noRawMutation, andnoRawWithoutTransactionused to check only$raw. Existing$executeRawcalls can now report violations. See rules. -
Library error codes are kept. Operation, raw, and transaction wrappers keep the
codeof aBetterDrizzleErrorthrown inside them (for example from a hook or a nested call) instead of replacing it withOPERATION_ERROR. Checkcatchblocks that matchedOPERATION_ERRORfor those cases. See errors. -
Invalid arguments throw
INVALID_ARGS. Input validation failures, such as unknown columns or relations,selectwithinclude,pagewithskip,beforewithafter, an invalidbatchSize, duplicateupdateEachbyvalues, malformed atomic, array, or JSONB mutations, relation command errors, and Zod plugin parse failures, usedOPERATION_ERROR(status 500). They now useINVALID_ARGS(status 400). Updatecatchblocks that matchedOPERATION_ERRORfor them. See errors. -
Success callbacks all run. Every
afterCommitcallback, everyafterTransactionCommithook, and every pluginafterRawhook now runs even when an earlier one throws; the first error is still rethrown. Previously the first failure skipped the rest. -
findUniquerequires a uniquewhere. Thewheremust pin one row: an equality on every primary key column, on a unique column, or on every column of a composite unique key (aparam()counts). Any otherwhere, including one where anundefinedvalue dropped the key, such as{ id: undefined }, throwsUNIQUE_WHERE_REQUIREDbefore any SQL. UsefindFirstfor lookups by non-unique columns.
Also new in 0.3.0
- Prepared statements: mark values
with
param(name), call.prepare(name?)onfindUnique,findFirst,findOne,findMany,count,exists,paginate, orcursor, and run it withexecute(values). Param and result types come from the read (PreparedParams/PreparedResult). Plugins andbeforeQueryrun 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 atbetter-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 tobetter-drizzle/zod. The entrypoint exportsata(default and named), its types, andversion; the registry, column, row,where, and query-schema builders it uses internally are not exported.$where(): every delegate compiles a typedwhereinto a DrizzleSQLcondition, so the same filter works in rawdb.select()queries, joins, and subqueries.- Atomic updates:
increment,decrement,multiply,divide, andseton number columns, andtoggleon boolean columns. - PostgreSQL arrays: typed array filters,
some/every/noneelement predicates, andappend,prepend,remove,replace, andaddUniquemutations. - JSONB paths: dotted path filters such as
{ metadata: { 'profile.age': { gte: 18 } } },in/notInon string and number paths,mode: 'insensitive'on string paths (examples), and path mutations inupdate,updateMany,updateEach,upsert, andupsertManythat compile tojsonb_set(...). NULLordering:orderByaccepts{ direction, nulls: 'first' | 'last' }. See reads.paginate()acceptspageandperPage.createManybatchSize: splits large inserts into one statement per chunk, likeupsertMany. SeecreateMany.upsertManyon MySQL throughON DUPLICATE KEY UPDATE. SeeupsertManydetails.- Plugin intercepts:
intercept(ctx)wraps execution after transforms, so a plugin can serve a result without SQL or annotate after hooks. - Timestamps
nowandmodelsoptions: an injectable clock for deterministic tests, and per-model column names orfalseto turn the plugin off for a model.
Not supported with Drizzle 1.x
better-drizzle cannot load or filter by relations with a relation-level
where, or by one relations declared through a junction. Loading one
throws Relation "x" on "t" cannot be loaded: ..., and filtering through one
throws ... cannot be filtered: .... They still work
with Drizzle's own db.query API on the same db. See
limitations.
Plugin authors
ctx.schemainsetup, hooks, transforms, and extensions is the relations config. See step 4.- Declare typed per-table model extensions with an interface extending
ModelExtensionTypeResolver. Generic function resolvers still work, but with Drizzle 1.x types they can hitType instantiation is excessively deep(TS2589). See writing plugins. config.requires.columnsnow enforcesoptionalandtype. Anoptionalcolumn may be missing (it used to fail bootstrap anyway), and atypethat does not match the column fails bootstrap withPLUGIN_REQUIRED_COLUMN_TYPE;typeused to be informational. See setup and fail-fast requirements.- Plugins can add typed raw SQL options by augmenting the
RawOptionsExtensionsinterface ofbetter-drizzle. Raw hooks receive unknown options unchanged inrawOptions; the cache plugin'scache: { invalidate }raw option uses this. better-drizzle/zodneeds a Zod version with Zod 4-compatible schema types. The peer range is^3.25.0 || ^4.0.0.
Upgrading to 0.2
0.2.0 collapses the plugin packages into the main package. The runtime API
did not change - only where you install and import the official plugins from.
What changed
Before 0.2.0, each official plugin was published on its own:
npm install better-drizzle @better-drizzle/rules @better-drizzle/zodFrom 0.2.0 on there is exactly one published package. The plugins are
subpath exports of it:
npm install better-drizzleBefore (0.1.x) | After (0.2.x) |
|---|---|
@better-drizzle/eslint | better-drizzle/eslint |
@better-drizzle/rules | better-drizzle/rules |
@better-drizzle/soft-delete | better-drizzle/soft-delete |
@better-drizzle/timestamps | better-drizzle/timestamps |
@better-drizzle/zod | better-drizzle/zod |
| - | better-drizzle/plugins (new - plugin authoring API) |
Every entrypoint ships ESM, CommonJS, and its own declaration files. The
subpaths are declared in exports, so bundlers and Node resolve them
without a paths alias or any build-step configuration.
Migrating
1. Drop the scoped packages
npm uninstall @better-drizzle/eslint @better-drizzle/rules \
@better-drizzle/soft-delete @better-drizzle/timestamps @better-drizzle/zod
npm install better-drizzle@^0.2.02. Rewrite the imports
Only the module specifier changes - the exported names are identical:
import { recommended, rules } from '@better-drizzle/rules';
import { softDelete } from '@better-drizzle/soft-delete';
import { timestamps } from '@better-drizzle/timestamps';
import { recommended, rules } from 'better-drizzle/rules';
import { softDelete } from 'better-drizzle/soft-delete';
import { timestamps } from 'better-drizzle/timestamps';
import { better } from 'better-drizzle';
export const client = better(db, {
schema,
plugins: [rules(recommended()), timestamps(), softDelete()],
});A one-shot rewrite across the codebase:
grep -rl "@better-drizzle/" src \
| xargs sed -i "s|'@better-drizzle/|'better-drizzle/|g"3. Update the ESLint config
The ESLint plugin moved the same way:
import betterDrizzle from '@better-drizzle/eslint';
import betterDrizzle from 'better-drizzle/eslint';
export default [betterDrizzle.configs.recommended];4. Reinstall and typecheck
npm install && npx tsc --noEmitAny remaining @better-drizzle/* specifier will fail to resolve, which makes
the typecheck the complete checklist for this migration.
What did not change
better(db, options)and every option it accepts- every delegate method and its result shape
- plugin factories, their options, and the typed operation args they add
- hooks, transforms, transactions, raw SQL, and error shapes
- the
drizzle-orm@^0.30.0andtypescript@^5peer ranges
If your code compiled on 0.1.x, changing the import specifiers is the
entire upgrade.
Versioning from here
The single package means a single version line. better-drizzle and every
plugin entrypoint always report the same version:
import { version } from 'better-drizzle';
import { version as zodVersion } from 'better-drizzle/zod';
version === zodVersion; // trueSee stability & semver for what the 0.3.x line
guarantees.