Upgrading to 0.90
0.90 is a correctness release. Most of its breaking changes are refusals: a query shape that used to return the wrong rows, write the wrong value, or drop a filter now throws a typed error that names the fix. Those are easy to find, because your tests fail on them.
A smaller set changes results with no error at all. They come first on this page, ordered by how likely they are to affect you. Read that section even if your suite passes.
Before you start#
- No schema change is required by the library. Three upgrades can touch your data or DDL: the SQLite
daterewrite, JSON rows written as{}, and anydefineSchemakey the new validation rejects. - Regenerate, then type-check.
turbine generateemits stricter types in 0.90 (see Generated types), andtsc --noEmitthen finds those call sites and the column reference change on its own. - Run your tests outside production mode. Several checks below are development warnings, silent under
NODE_ENV=production(see Diagnostics replaces NODE_ENV). - Search for the error codes. A refusal is
ValidationError(TURBINE_E003), an engine gap isUnsupportedFeatureError(TURBINE_E017), and a connection problem isConnectionError(TURBINE_E004). Every message names the fix. Middleware that rewrites its arguments gets a plainTypeError, see Middleware cannot change the arguments.
Results that change without an error#
Pathless JSON equals compares the whole document#
On a json / jsonb column, equals without a path used to compile to containment (@>), so it also matched every document that held the operand plus other keys. It is now exact document equality: "meta"::jsonb = $1::jsonb on PostgreSQL (key order and whitespace do not matter), meta = CAST(? AS JSON) on MySQL. A deleteMany with the old meaning deleted the supersets too.
// Stored: { plan: 'pro' } and { plan: 'pro', seats: 5 }
await db.accounts.findMany({ where: { meta: { equals: { plan: 'pro' } } } });
// 0.81: both rows. 0.90: only { plan: 'pro' }.
// Meant containment? Say so:
await db.accounts.findMany({ where: { meta: { contains: { plan: 'pro' } } } });On a JSON column, not: <document> and not: { equals: <document> } now mean "not this exact document" on PostgreSQL and MySQL. For "does not contain", wrap contains in NOT:
await db.accounts.findMany({ where: { NOT: { meta: { contains: { plan: 'pro' } } } } });SQL Server now refuses a pathless equals with TURBINE_E017, since it stores the document as text and whitespace or key order would decide the match. SQLite already refused it.
Find it: nothing warns on PostgreSQL or MySQL. Search for equals and not operands that are objects, and check which of those columns are JSON:
grep -rnE "(equals|not):\s*\{" src/globalFilters keyed by accessor now apply#
globalFilters keys are matched to tables. A key spelled as the client accessor (blogPosts for the blog_posts table), the spelling the configuration docs showed, matched nothing and applied no filter. Both spellings now resolve to the table, so a multi-word table's soft-delete or tenant filter starts filtering.
const db = turbine({
globalFilters: {
blogPosts: { deletedAt: null }, // 0.81: ignored. 0.90: applied to blog_posts.
},
});A key that names no table, two keys for one table, and a static filter naming a column the table lacks now throw TURBINE_E003 at construction, with a did-you-mean.
Find it: list your globalFilters keys. Any key whose accessor differs from its table name (any multi-word table) was inert before.
OR: [] matches no row#
An empty OR list used to add no predicate, so where: { OR: [] } returned every row and deleteMany({ where: { OR: [] } }) was refused by the empty-where guard. It now matches no row, as in Prisma, on reads and mutations alike. AND: [], NOT: {} and NOT: [] still add no predicate.
const picked = selectedRoles.map((role) => ({ role }));
// 0.81: every user when nothing was picked. 0.90: no users.
await db.users.findMany({ where: { OR: picked } });
// To keep "nothing picked means everyone", leave OR out:
await db.users.findMany({ where: picked.length ? { OR: picked } : {} });In a groupBy having, NOT: [a, b] now means NOT a AND NOT b, as it does in where; it used to mean NOT (a AND b). Write NOT: { AND: [a, b] } for the old meaning.
Find it: grep -rnE "OR:\s*[a-zA-Z_]" src/ for lists built at runtime, and grep -rn "having" src/ for NOT arrays.
An OR branch with no condition matches every row#
A branch that applies no condition, such as {} or { AND: [] }, makes its OR true for every row, as OR: [{}] already was. A branch emptied by undefined ({ email: undefined }) is still removed from the list before this rule applies. The branch used to be dropped, so OR: [{ id: 1 }, {}] matched only row 1, its NOT matched every other row, and a deleteMany over that NOT deleted most of the table. A mutation whose where is always true this way is now refused (see below). The rule holds on every engine, PowDB included. OR: [{}] now compiles to (1 = 1), which shows in logged or snapshotted SQL.
const branches = [{ id: 1 }, filterFor(input)]; // filterFor returns {} for a blank input
// 0.81: users with id 1. 0.90: every user.
await db.users.findMany({ where: { OR: branches } });
// Leave the empty branch out of the list:
await db.users.findMany({ where: { OR: branches.filter((b) => Object.keys(b).length > 0) } });Find it: OR lists whose members are built by helpers that can return {}.
MySQL compares a number with a text column as text#
On MySQL a number or boolean compared with a text column binds as text, in where, in in / notIn lists and in having _min / _max. MySQL compared the two as numbers, so { resetToken: 0 } matched every token that does not start with a digit, and having: { token: { _min: 0 } } matched a group whose smallest token was 'abc'. Pass the value with the column's type ('0' for a text column).
JSON path keys with special characters find their value#
On MySQL, SQLite and SQL Server a JSON path key holding a backslash, a quote or a newline is written as a JSON string literal. Such a key used to find nothing.
Generated clients carry pii and updatedAt from your schema file#
turbine generate introspects the live database, which carries no code-first tags, so a client generated that way returned pii: true columns and never stamped updatedAt columns. generate now reads your defineSchema file (schemaFile, default ./turbine/schema.ts) and applies pii, updatedAt and maxLength to the introspected schema.
After regenerating, a PII-tagged column is absent from default reads, and update / updateMany stamp updatedAt columns.
// email is tagged pii: true in turbine/schema.ts
const user = await db.users.findFirst({ where: { id: 1 } });
user.email; // 0.81 (generated client): the address. 0.90: undefined.Find it: turbine generate prints applied column tags from <file>: N pii, N updatedAt. The generated entity type marks each PII field optional, so tsc flags code that assumed it is present. To read the value, see PII in select.
JSON path equals compares JSON values#
{ path, equals } used to compare the text extracted at the path, so equals: 5 matched the string "5" too and equals: null matched only the string "null". It now compares JSON to JSON on PostgreSQL ((col #> path)::jsonb = $v::jsonb) and on MySQL (JSON_EXTRACT(...) = CAST(? AS JSON)).
// Stored: { n: 5 } and { n: "5" }
await db.docs.findMany({ where: { data: { path: ['n'], equals: 5 } } });
// 0.81: both rows. 0.90: only { n: 5 }.
await db.docs.findMany({ where: { data: { path: ['n'], equals: null } } });
// 0.90: matches a stored JSON null. A missing key is not a JSON null.Arrays and objects at a path compare as JSON on PostgreSQL and MySQL. On SQL Server, numbers compare as numbers and strings case-sensitively, and an array or object operand throws TURBINE_E017. SQLite refuses null, arrays, objects and booleans with TURBINE_E017, because it extracts JSON true as the integer 1.
Find it: pass the value with the JSON type you stored. Search for path: filters that bind a number against string data or a string against numeric data.
not takes an operator object#
not: { lt: 4 } used to bind the object {"lt":4} as a value, so the filter matched nearly every row. It now compiles to NOT ("score" < $1), as in Prisma.
await db.reviews.deleteMany({ where: { score: { not: { lt: 4 } } } });
// 0.81: deleted almost every row. 0.90: deletes rows with score >= 4.A row whose column is NULL matches neither the filter nor its negation (SQL three-valued logic). Add OR: [{ score: null }, ...] if you want those rows too.
Find it: grep -rnE "not:\s*\{" src/ and read each operand.
A cursor page is always ordered#
A findMany with a cursor now always emits ORDER BY: your orderBy plus the primary key (or, with no primary key, the first unique NOT NULL key) as a tiebreaker, unless the order already contains a unique NOT NULL key. A cursor with no orderBy is ordered by the primary key. It used to run unordered, so pages could skip and repeat rows.
await db.posts.findMany({ cursor: { id: 40 }, limit: 20 });
// 0.81: WHERE "id" > $1 LIMIT $2 (no ORDER BY)
// 0.90: WHERE "id" > $1 ORDER BY "id" ASC LIMIT $2A cursor that names a row by its key under another order (orderBy: { createdAt: 'desc' }, cursor: { id }) used to seek on id while sorting by createdAt, which returned the wrong rows. It now reads the cursor row's createdAt and seeks from there. implicitPkOrdering no longer orders a single-field cursor by its own field: every cursor page uses the order above, flag or not.
Find it: nothing warns. Pages now come back in a stable order, which can differ from the order your code happened to see.
A paginated findMany gets the same tiebreaker#
The first page gets the cursor rule too. A findMany with limit / take / offset / skip whose orderBy can tie now ends its ORDER BY with the primary key, in the last term's direction, so page 1 and the cursor pages after it share one order and an offset walk visits every row once.
await db.users.findMany({ orderBy: { name: 'desc' }, take: 20 });
// 0.81: ORDER BY "name" DESC LIMIT $1
// 0.90: ORDER BY "name" DESC, "id" DESC LIMIT $1An orderBy that already contains a unique NOT NULL key is unchanged, and so are findFirst without offset, unpaginated queries, distinct, and an order by vector distance (an HNSW or IVFFlat index serves only ORDER BY <distance> LIMIT k, so a key after the distance would turn the index scan into a full sort).
Find it: nothing warns. Rows that tie on the sort key can come back in a different order than before. An index on the sort column alone may no longer cover the ORDER BY; an index on (sort column, id) does.
The extra key can cost time when a sort column has few distinct values: it turns an index scan that stopped after limit rows into an incremental sort over the whole first group. Check explain() on those queries after upgrading.
Enum arrays read as arrays#
A column whose type is an array of a Postgres enum used to come back from a top-level read as the raw array text, '{draft,live}', while the same column read through a relation was an array. It is now ['draft', 'live'] everywhere. Nested reads also decode BC timestamps, offsets with seconds, years past 9999, and float NaN / Infinity exactly as top-level reads do.
Find it: search for code that parses {...} text from an enum-array column, and remove it.
A timed-out query is cancelled on the server#
A per-query timeout used to reject on the client while the statement kept running, so a timed-out write could still commit, and a retry applied it twice. On PostgreSQL through a node-postgres pool, outside a transaction, Turbine now sends the server a cancel request and waits for the statement to stop. TimeoutError gains outcome: 'not-executed' when the statement made no change, 'unknown' when it may still complete (on the other engines, or when the server did not confirm in time). Inside $transaction on PostgreSQL the statement is cancelled too, and the transaction is rolled back, see Transactions. A statement that finishes while Turbine waits returns its result instead of a timeout. See Per-query timeout.
Find it: retry logic around TimeoutError. Retry a write on outcome === 'not-executed'; check the database first on 'unknown'.
MySQL single-row writes run in a transaction#
MySQL has no RETURNING, so update, delete and upsert write and then read the row back. The two statements now run in one transaction with the row locked. Of several concurrent deletes of one row, exactly one returns it; an update that changes a column its where names returns the updated row; a timed-out write is rolled back. Each such call now takes a pooled connection for its transaction, so a fetch-style pool with no connect() cannot run it.
Find it: MySQL code that counted on a delete returning the row to every caller.
SQLite, SQL Server and MySQL values match PostgreSQL#
Several engines returned a different value than PostgreSQL for the same column and the same query. They now agree.
- SQLite:
booleanandjsoncolumns decode. A column your schema declaresboolean,jsonorjsonbreads back as a boolean or a parsed value on every read path,withand raw queries included. It used to read as1/0and as JSON text. Needs Node 22.16+ or 23.11+; older builds keep the stored form. - SQLite: integers bind as
INTEGER.{ divide: 2 }on an integer column truncates like PostgreSQL (75 / 2is37, was37.5), and an integer written to a text column is stored as'3', not'3.0'. - SQL Server: a
Dateis sent asDATETIME2. Milliseconds are stored exactly.23:59:59.999used to be stored as the next day, and awherecomparison at millisecond precision could miss. - SQL Server:
_avgover an integer column is fractional (56.4, was56), in results and inhaving. - MySQL:
_avgover an integer column keeps full precision. An average of4 / 3read1.3333. - MySQL: integer
dividetruncates like PostgreSQL (75 / 2is37, was38). - SQL Server:
jsoncolumns read back parsed. A column your schema declaresjsonorjsonbreads back as the parsed value, at top level and insidewithrows. It used to read back as JSON text. - MySQL: a bare JSON document in a
whereis compared as a document.{ data: { a: 1 } }used to be compared as a string, so it matched nothing and itsNOTmatched every row. - MySQL:
inandnotInmatch whatequalsandnotmatch. The list was compared as JSON values, so a boolean, a date or time, a binary value, a decimal or bigint written as a string, and text under a case-insensitive collation never matched, andnotInmatched every row:deleteMany({ where: { published: { notIn: [true] } } })deleted the published rows too. - SQLite and SQL Server: list elements bind as the scalar does. SQLite takes a bigint past 2^53 (it threw a
TypeError) and binary values (they matched nothing). SQL Server takes bigint andDateelements, but not binary ones, see SQL Server. - MySQL and SQLite:
batchedrelations with binary or bigint keys attach children to the right parent, as the join strategy does. They went to the wrong parent or to none. - MySQL:
_avgover adecimalorbigintcolumn has the same value as PostgreSQL with a different number of digits (the input's scale plus 4, against PostgreSQL's 16 significant digits), and rounds a repeating quotient at a different digit. This is not changed in 0.90; compare averages numerically. - MySQL: LIKE filters work on a pool you pass in.
contains,startsWith,endsWithand the JSON substring filters emittedESCAPE '\', which MySQL reads as an unterminated string unless the session runs withNO_BACKSLASH_ESCAPES, so on amysql2pool in the server's default mode every one of them was a syntax error. MySQL now escapes with!(ESCAPE '!'); values stay bound, and your session's mode is never changed. Anything that matches emitted MySQL SQL text sees the new clause. AJSON_OBJECTkey holding a backslash is written as its UTF-8 bytes, so it reads back the same in either mode.
Find it: SQLite code that compares a boolean to 1 / 0 or calls JSON.parse on a JSON column, the same JSON.parse on SQL Server, code that rounds or compensates for _avg on SQL Server or MySQL, and MySQL divide on integer columns.
A Date on a date column is its UTC day on every engine#
On SQLite, MySQL and SQL Server, a Date written to or compared with a date column now binds its UTC day (YYYY-MM-DD), as on PostgreSQL. MySQL used to round 23:59:59.999 into the next day. SQLite stored the full ISO text.
SQLite upgrade step. Rows written before 0.90 hold the full ISO text, so an equality on that column stops matching them. Rewrite them once:
UPDATE events SET run_on = substr(run_on, 1, 10) WHERE length(run_on) > 10;Find it: SQLite date columns written by 0.81 or earlier, and MySQL date values written near midnight UTC.
MySQL writes target the row where names#
MySQL has no RETURNING, so a write reads its row back. Two shapes read back or wrote the wrong row:
- An
upsertwhosecreatealso sets another unique key could update a different row, the one that collided on that key. It now looks the row up bywherefirst and updates that row. The batched form of this shape throwsTURBINE_E017. - An
updateorupsertthat moves its row out of its ownwhere(a status change under aNOT, a soft delete under a global filter, an integer change of the key) threwNotFoundError. It now returns the written row, at the cost of one extraSELECT ... FOR UPDATE.
Find it: MySQL upsert calls whose create sets a unique column other than the where key, and code catching NotFoundError after such an update.
findFirst with offset breaks ties by the key#
findFirst({ orderBy, offset }) now appends the primary key to an orderBy that can tie, as a paginated findMany does, so the row it returns no longer depends on the engine's choice among tied rows. findFirst without offset is unchanged.
NotFoundError.where is not enumerable in safe mode#
Under the default errorMessages: 'safe', err.where is still readable but no longer enumerable, so JSON.stringify(err), { ...err } and loggers that walk own properties stop carrying the lookup values.
Find it: code that spreads or serializes a NotFoundError and reads where from the copy. Read err.where directly.
Streams and with honour every pagination key#
findManyStream({ limit: 3 }) and findManyStreamBatches streamed every matching row whenever the result fit one batch. They now stop at limit / take and start at offset.
Inside with, take, skip and offset were ignored, and only limit applied. They now page each parent's related rows, on the join and batched strategies alike.
await db.users.findMany({ with: { posts: { orderBy: { id: 'asc' }, take: 5, skip: 5 } } });
// 0.81: every post per user (take and skip were ignored). 0.90: posts 6 to 10 per user.offset or skip on a to-one relation throws TURBINE_E003: there is nothing to skip.
Find it: search for findManyStream calls that pass limit or take, and for skip inside with.
upsert stamps updatedAt on every path#
The single-statement upsert path (INSERT ... ON CONFLICT DO UPDATE) left updatedAt columns unchanged. Its conflict UPDATE now stamps them, as update and the lookup path already did. An explicit value in update still wins.
Find it: upsert calls on tables with an updatedAt column.
belongsTo nested writes follow to-one rules#
A belongsTo relation is one row the parent points at, and nested writes on it now behave that way.
| Shape | 0.81 | 0.90 |
|---|---|---|
delete: true (or {}) | did nothing | clears the foreign key, then deletes the connected row |
disconnect: false | disconnected | does nothing |
disconnect: { id: 7 } | disconnected without checking | must match the connected row, else NotFoundError (TURBINE_E001) |
connectOrCreate inside update() | ignored | connects or creates |
set | accepted | TURBINE_E003: use connect or disconnect |
two of create / connect / connectOrCreate / disconnect / delete | one ran on create; they ran in sequence on update | TURBINE_E003 |
| an array of several items | the first ran | TURBINE_E003; a one-item array is still accepted |
disconnect and delete need a nullable foreign key. A NOT NULL one throws TURBINE_E003 naming connect as the fix.
With nothing connected, disconnect: true is a no-op, as in Prisma, while delete: true and a disconnect naming fields throw NotFoundError (TURBINE_E001).
Find it: grep -rnE "(disconnect|delete):\s*(false|true|\{)" src/ inside update data.
Writes inside $transaction are all-or-nothing per call#
A nested write, or an upsert that looks the row up first, is several statements. Inside your $transaction, each such call now runs in its own savepoint, so a failure you catch no longer leaves half of it behind.
A writing pipeline() (transactional by default) holds its COMMIT until every result has been checked. A findUniqueOrThrow miss or an optimistic-lock conflict inside the batch now rolls the whole batch back, where it used to commit the writes and then reject. Such a batch takes two round trips; a read-only batch still takes one.
$transaction([...], options) now applies isolationLevel, sessionContext and timeout. They were ignored on the array form.
Find it: array-form $transaction calls that pass options, and try / catch around nested writes inside a transaction.
JSON arrays are stored as arrays#
A JS array written to a json / jsonb column was sent as a Postgres array literal: [] stored {}, ['a'] failed, and createMany inserted one extra row per array element. Arrays are now stored as JSON arrays on every write path.
A string written to a JSON column still means raw JSON text, so JSON.stringify(value) keeps working. To store a JSON string or a JSON null, use jsonValue:
import { jsonValue } from 'turbine-orm';
await db.docs.create({ data: { data: [1, 2, 3] } }); // stores [1, 2, 3]
await db.docs.create({ data: { data: '{"a":1}' } }); // stores { "a": 1 }
await db.docs.create({ data: { data: jsonValue('hello') } }); // stores the string "hello"
await db.docs.create({ data: { data: jsonValue(null) } }); // stores JSON null, not SQL NULLFind it: rows written with array values before the upgrade may hold {}. Check them with jsonb_typeof(col).
{ increment } on a JSON column is a document#
In update data, { increment: 1 } (and decrement, multiply, divide) on a json / jsonb column is now the JSON document it looks like. Only { set: value } is unwrapped there, which is also how to store a document shaped like { set: ... }. On PostgreSQL the old form failed; on SQLite it silently stored the number 1.
PowDB: filters reach every relation#
On PowDB, global filters now apply on every relation load path (nested projections, native joins, batched loaders, link hops), and skipGlobalFilters reaches relations. A relation could return another tenant's rows before.
A global filter holding a relation filter (users: { posts: { some: { ... } } }, posts: { user: { is: { ... } } }, function filters too) applies on every read, write and relation load. Every query on the table threw TURBINE_E003. A batched or join read whose select or omit leaves out a foreign key returns the related row instead of null, and nested set works instead of throwing TURBINE_E003.
The relation filters is, isNot, a bare to-one filter, is: null and isNot: null now filter. They matched every row, updateMany and deleteMany included. none, every and not: { ... } follow SQL NULL semantics, in / notIn on a float column match whole numbers, startsWith / endsWith treat %, _ and \ literally, and _min / _max return the column's own type.
SQLite: other callers wait for an open transaction#
SQLite has one handle. A statement from outside an open $transaction used to run inside it, could read its uncommitted rows, and was rolled back with it. It now waits for the transaction to end, for up to busyTimeoutMs, then fails with a retryable SerializationFailureError (TURBINE_E013).
Shapes that now throw#
Column references are built with col()#
A column-to-column comparison used to be the plain object { col: 'x' }. That is exactly what JSON.parse produces from a request body, so request data could name a column: delete({ where: { id: body.id } }) with {"equals":{"col":"id"}} compiled to WHERE "id" = "id". References now carry a symbol brand, and only col() builds one.
// 0.81
where: { currentVersionId: { not: { col: 'publishedVersionId' } } }
// 0.90
import { col } from 'turbine-orm';
where: { currentVersionId: { not: col('publishedVersionId') } }Find it: tsc --noEmit rejects the plain object literal. At runtime it throws TURBINE_E003, naming col().
where operands must fit the column#
On a non-JSON column, an object, an array or {} at a value position, gt / gte / lt / lte: null, a non-string contains / startsWith / endsWith, a non-array in / notIn, a Map or Set, and an unknown mode now throw TURBINE_E003. They were bound as JSON text and matched nothing, or the wrong rows.
await db.users.findMany({ where: { id: [1, 2] } }); // TURBINE_E003: use { in: [1, 2] }
await db.users.findMany({ where: { score: { gt: null } } }); // TURBINE_E003: use equals: null or not: nullCombinators and filter keys the query cannot read#
Each of these used to compile to no condition at all, which inside a relation filter or a global filter matched every row. They now throw TURBINE_E003 naming the fix:
ORgiven an object instead of an array (wrap it:OR: [{ ... }]), or any combinator givennull, a scalar or a non-object member.ANDandNOTstill take one object or an array.- An operator object naming no operator,
{ name: { mode: 'insensitive' } }. One emptied byundefined,{ contains: search, mode: 'insensitive' }withsearchundefined, is still no filter. - A relation filter key other than
some,every,none,isandisNotbeside them,{ posts: { some: { ... }, published: true } }. - A quantifier that is not a where object:
some: null,none: null,every: null, a scalar or an array, at every depth. Writesome: {}for "has a related row" andnone: {}for "has none".is/isNottake a where object ornull; a scalar or an array throws. - On a JSON column, a
notwhose object has only filter-operator keys (not: { gt: 1 }). It was compared as the literal document{"gt":1}and matched nearly every row. WriteNOT: { data: { ... } }to negate a filter, ornot: jsonValue({ ... })to compare against such a document. - A
distinctelement that is not a field-name string. - A
__proto__key arriving from parsed input, which used to replace the argument object's prototype and drop the filter. is/isNoton a to-many relation. Writesome,everyornone; the message names them.- An Invalid Date anywhere in a
where, on every engine. - In an array filter, an unknown key or an
isEmptythat is not a boolean. - In a relation's
withoptions,lock(a locking read locks the queried table's rows only), and a_countrelation entry that is not a boolean (such as{ where }). An unknownwithoption is named once in the advice log. - A groupBy
bythat is missing, a string, an empty array or holdsnull. Writeby: ['name']; for one group over every row, useaggregate().
A PII column in select needs includePii: UNSAFE#
Naming a pii: true column in select used to be the opt-in that returned it. select often comes from request data, so that was a way for a request to read PII. A read now returns a PII column only with includePii: UNSAFE, at the top level and at every with depth.
import { UNSAFE } from 'turbine-orm';
await db.users.findMany({ select: { id: true, email: true } });
// TURBINE_E003: "email" is tagged PII
await db.users.findMany({ select: { id: true, email: true }, includePii: UNSAFE });A write never returns a PII column. A write select that names one throws TURBINE_E003; read the row back with includePii: UNSAFE. omit, where, orderBy and having on PII columns are unchanged. The rule applies on every engine, PowDB included, and through prisma-compat (pass includePii: UNSAFE as a native option).
Find it: TURBINE_E003 with the text is tagged PII.
Global filters fail closed#
A function filter that returned undefined, or an object holding undefined at any depth, was dropped, so a missing tenant id read and wrote every tenant's rows. It now throws TURBINE_E003 naming the table.
globalFilters: { orders: () => ({ tenantId: currentTenant() }) }
// currentTenant() is undefined outside a request:
// 0.81: no filter, every tenant's orders. 0.90: TURBINE_E003.
// No filter on purpose: return null or {}.
globalFilters: { orders: () => (isAdminJob() ? null : { tenantId: currentTenant() }) }null and {} are the explicit "no filter" values, static or returned. For a query that must see every row, pass skipGlobalFilters: UNSAFE.
Find it: TURBINE_E003 with the text Global filter for.
Three more globalFilters shapes throw at construction. A static OR: { ... } (an object where an array belongs) or a combinator holding a scalar or null throws TURBINE_E003; write OR: [{ ... }]. A static relation filter that is not a where object (author: { is: 5 }, posts: { some: null }) throws there too, instead of on the first query. A skipGlobalFilters: [UNSAFE, ...] entry that names no table throws TURBINE_E003 with a suggestion. Entries may be table names (blog_posts) or accessors (blogPosts).
Each static filter is also compiled at construction, so anything a query would refuse in it throws from the constructor, naming globalFilters["key"]: a bad operand, a plain { col } object, an operator object with no operator, a misspelled column inside a relation filter, or an undefined value. So does a static filter that reaches its own table through relation filters, directly or through another table's filter; the message names the chain. Such a filter used to make every query on the table throw RangeError. An app that builds a client with a broken static filter and never queries that table now fails to start. Function filters that reach each other apply once per chain.
Mutations refuse a where that is always true#
update, updateMany, delete and deleteMany refuse a where that is always true with the same TURBINE_E003 as an empty where. These compiled to SQL that matched every row:
await db.users.deleteMany({ where: { id: { notIn: excludedIds } } }); // excludedIds = []: TURBINE_E003
await db.users.deleteMany({ where: { NOT: { OR: [] } } }); // TURBINE_E003
await db.users.deleteMany({ where: { OR: [{ id: 1 }, {}] } }); // TURBINE_E003
await db.users.deleteMany({ where: { email: { contains: '' } } }); // TURBINE_E003, email is NOT NULLThe guard reads which columns are NOT NULL. On such a column contains: '' (and startsWith / endsWith), not: null, a comparison of the column with itself and hasEvery: [] are always true, and so is a bare to-one relation filter ({ author: {} }) over a NOT NULL foreign key, except on SQLite and when the target table has a global filter. Reads with these shapes are unchanged.
An always-false where ({ id: { in: [] } }, OR: []) is not refused; it matches nothing.
Find it: notIn built from a list that can be empty, in a mutation. Guard the empty case, or pass allowFullTableScan: UNSAFE if every row is meant.
findFirst refuses a where emptied by undefined#
findFirst({ where: { resetToken: token } }) with token undefined used to return an arbitrary row. findFirst and findFirstOrThrow now throw TURBINE_E003 when every key of a non-empty where was undefined. findMany and count keep treating an all-undefined where as no filter.
const user = await db.users.findFirst({ where: { resetToken: req.query.token } });
// 0.81, token missing: some user. 0.90: TURBINE_E003.To ask for any row on purpose, omit where or pass where: {}. Mutations already refused an empty where; they now also refuse one emptied inside a relation filter, such as deleteMany({ where: { user: { email: undefined } } }).
One-row lookups take a plain value or { equals }#
findUnique, update, delete and upsert identify a row by a unique key pinned to a plain value or { equals: value }. A column reference, mode: 'insensitive', a list, a range, or another operator beside equals in the same key object can match many rows, and is now refused with TURBINE_E003.
await db.users.findUnique({ where: { email: { equals: input, mode: 'insensitive' } } }); // TURBINE_E003
await db.users.findFirst({ where: { email: { equals: input, mode: 'insensitive' } } }); // use thisA cursor must place the page#
A cursor must carry a value for every column of the page order (your orderBy plus the tiebreaker), or name one row by a unique key. Anything else throws TURBINE_E003 naming the missing fields and the keys that would work. A cursor beside a relation, JSON-path or vector orderBy throws too; page those with offset.
// 0.90: TURBINE_E003, the cursor has no value for "id"
await db.posts.findMany({ orderBy: { createdAt: 'desc' }, cursor: { createdAt: last.createdAt }, limit: 20 });
// Either of these works:
await db.posts.findMany({ orderBy: { createdAt: 'desc' }, cursor: { createdAt: last.createdAt, id: last.id }, limit: 20 });
await db.posts.findMany({ orderBy: { createdAt: 'desc' }, cursor: { id: last.id }, limit: 20 });Each cursor value is a plain value or { equals: value }. An operator object, an array or a column reference throws TURBINE_E003. A cursor whose every field is undefined throws too, because it would restart at the first row. So do cursor: null, cursor: {}, a cursor that is not an object, and null for a NOT NULL or primary-key column. Leave cursor out, or pass undefined, for the first page. cursor: {} and cursor: null used to mean no cursor, and that first page was ordered without the tiebreaker later pages used, so a pager written as cursor: last ? { id: last.id } : {} could skip rows.
await db.posts.findMany({ cursor: { id: req.query.after }, take: 20 });
// `after` missing: TURBINE_E003, rather than page 1 againoptimisticLock outside update#
upsert, updateMany, delete and deleteMany ignored optimisticLock, so stale writes and deletes went through. All four now throw TURBINE_E003. Only update takes it.
// updateMany: put the version check in the query itself
await db.posts.updateMany({
where: { id: { in: ids }, version: expected },
data: { status: 'archived', version: { increment: 1 } },
});
// The returned count is the number of rows still at that version.
// upsert: read, then update with optimisticLock or create, in one transaction
await db.$transaction(async (tx) => {
const row = await tx.posts.findUnique({ where: { slug } });
if (row) {
await tx.posts.update({ where: { slug }, data, optimisticLock: { field: 'version', expected: row.version } });
} else {
await tx.posts.create({ data: { slug, ...data } });
}
});The upsert refusal also covers an upsert whose create or update carries nested relation data, which runs on another code path and used to skip the check.
For a delete, put the expected version in where beside the key:
await db.posts.delete({ where: { id, version: expected } });
// A row at another version is not deleted, and delete throws NotFoundError.
await db.posts.deleteMany({ where: { authorId, version: expected } });
// The count is the number of rows that were still at that version.Middleware cannot change the arguments#
params.args is now a plain copy of the caller's arguments. A middleware that changes anything under it, or passes next() an args object that differs from the caller's, makes next() throw a TypeError naming the first path that differs, for example params.args.where.deletedAt. Assigning params.args = ... throws at once. The query is compiled from the caller's arguments, so such a change never reached the SQL (or reached some statements of a nested write and not others).
db.$use(async (params, next) => {
params.args.where = { ...params.args.where, deletedAt: null };
return next(params); // 0.90: TypeError naming params.args.where.deletedAt
});Put the condition in the query, or use globalFilters for a filter every query needs. Reading params.args, timing, and transforming the result of next() are unchanged, and JSON.stringify, structuredClone and spreading params.args now work (structuredClone used to throw). Passing the unchanged copy on, or a structurally equal one, still works.
Find it: $use callbacks that assign under params.args or build a new args for next().
updateMany and deleteMany refuse paging options#
limit, take, offset and skip on updateMany or deleteMany throw TURBINE_E003 on every engine. They were ignored, so every matching row was written.
await db.jobs.deleteMany({ where: { status: 'done' }, take: 1000 }); // 0.81: deleted every done job
// 0.90: select the ids, then write by id
const ids = (await db.jobs.findMany({ where: { status: 'done' }, select: { id: true }, orderBy: { id: 'asc' }, take: 1000 })).map((j) => j.id);
await db.jobs.deleteMany({ where: { id: { in: ids } } });buildCreateMany past the bind-parameter limit#
buildCreateMany returns the one-statement form of a createMany, the kind a pipeline or $transaction([...]) runs. When that statement would carry more bind parameters than the engine takes, it now throws TURBINE_E003 naming the limit. It used to hand the statement to the driver, which failed with a driver or protocol error. MySQL and SQLite also name a chunk size that fits. SQL Server names its limits of 1,000 rows and 2,098 parameters per statement.
// MySQL: 70,000 rows of one column need 70,000 bind parameters, over the 65,535 limit
const rows = Array.from({ length: 70_000 }, (_, i) => ({ email: `user${i}@example.com` }));
await db.pipeline(db.users.buildCreateMany({ data: rows }));
// 0.81: a driver error from the server. 0.90: TURBINE_E003 naming the limit.
await db.users.createMany({ data: rows }); // inserts in chunks inside one transactioncreateMany() itself does not hit the limit: past it, the rows are inserted in chunks inside one transaction. To batch the insert with other statements, split the rows yourself and pass one buildCreateMany per chunk.
Find it: buildCreateMany calls whose row count can grow with your data.
timeout and relationLoadStrategy are validated#
A per-query timeout must be a positive number of milliseconds up to 2,147,483,647, or 0 for no timeout, and relationLoadStrategy one of 'join', 'batched', 'auto' or 'flatten', on the client and per query. Anything else (a negative number, NaN, a string) throws TURBINE_E003. An invalid timeout used to be clamped by Node to 1 ms or ignored. timeout: 0 keeps its old meaning, no timeout, on queries and on $transaction alike.
Find it: timeouts read from configuration or environment variables, which arrive as strings. Wrap them in Number(...).
defineSchema refuses unknown keys#
A column, table, index, references, check, manyToMany or options key that defineSchema does not know throws TURBINE_E003 naming the key and the nearest valid one. In an object literal it is also a compile error.
defineSchema({ users: { id: { type: 'serial', primaryKey: true }, role: { type: 'text', defaultValue: "'member'" } } });
// TURBINE_E003: unknown key "defaultValue" in column "users.role". Did you mean "default"?defineSchema also refuses a table or column named __proto__ and an index that declares both columns and docField.
It also refuses names it cannot resolve, each with TURBINE_E003:
- a
referencesthat is nottable.column(references: 'users'emitted no foreign key), or that names a column its target table in the schema does not have; - an index naming a column the table does not have;
- two fields that map to one database column (
userIdanduser_id), and two tables that map to one table name.
A references to a table outside the schema is not checked. A default may now be a number or a boolean (default: 0, default: false); it used to crash with a TypeError.
Such keys used to be ignored, which silently changed the DDL: a misspelt default or unique emitted a column without it, and uniq on an index emitted a non-unique index. Rename the key the error names, then run turbine push --dry-run or turbine migrate create --auto and review the difference, because the database may lack what the schema now says.
A custom dialect states capabilities#
Dialect.capabilities is a new required field: an EngineCapabilities object with 33 keys that says what the engine can do. Does it have DISTINCT ON? Where does NULL sort? How many bind parameters does one statement take? Does a failed statement abort the transaction? Turbine used to answer these by comparing dialect.name. Only code that builds a Dialect by hand is affected. The five shipped engines are unchanged.
- A dialect with no
capabilitiesno longer type-checks, and aTurbineClientbuilt with one throwsTURBINE_E003at construction. At runtime Turbine checks only thatcapabilitiesis an object. TypeScript is what requires every key. - A dialect spread from
postgresDialectfor an engine that is not PostgreSQL now inherits PostgreSQL's answers, because the spread copiescapabilities. Before, a name other thanpostgresqlgot the non-PostgreSQL answer for about sixty decisions. A wrongtrueis a broken query at runtime, so state your own values. - A dialect spread from an engine dialect under a new name keeps that engine's behavior. A PostgreSQL-compatible dialect under its own name used to lose
DISTINCT ON, the positional JSON encoding, enum casts,READ ONLYtransactions, server-side cancel and the raw-SQL transaction-control check. It now keeps them.
import { postgresDialect, type Dialect } from 'turbine-orm';
// 0.81: the spread alone was a complete dialect.
// 0.90: it still compiles, and it claims PostgreSQL's answers for your engine.
export const myDialect: Dialect = {
...postgresDialect,
name: 'myengine',
capabilities: {
...postgresDialect.capabilities,
distinctOn: false,
positionalJson: false,
readOnlyTransactions: false,
// ...and every other key your engine answers differently
},
};Two things still follow the engine name, so they are exceptions to "keeps that engine's behavior". A dialect under any name other than postgresql, mysql, sqlite or mssql does not get them from capabilities, even when it is spread from a shipped engine dialect:
- The update expressions
expr.now(),expr.greatest()andexpr.least()throwTURBINE_E017. Every otherexpr.*followscapabilities. schemaToSQLspells columnDEFAULTclauses by engine name, as before. A dialect under another name gets the PostgreSQL spelling.
Find it: every object typed Dialect, and every spread of postgresDialect or of another shipped dialect. The dialects page lists all 33 keys with PostgreSQL's value.
postgresDialect.introspector is removed#
introspect() now reads the PostgreSQL catalog itself when a dialect has no introspector, so introspecting PostgreSQL gives the same result. postgresDialect.introspector is undefined, and so is the field in a dialect that spread it. The SQLite, MySQL and SQL Server dialects keep their own.
import { introspect, postgresDialect } from 'turbine-orm';
await postgresDialect.introspector!.introspect({ connectionString }); // 0.81
await introspect({ connectionString }); // 0.90Find it: grep -rn "\.introspector" src/ for code that reads the field. A dialect that only spreads postgresDialect needs no change.
Error classes that changed#
- A write in a read-only transaction (PostgreSQL
25006, MySQL error 1792) isReadOnlyError(TURBINE_E018) with the newreason: 'transaction'. An exhaustiveswitchoverreasonneeds the case. - A value the server could not read as its column's type at bind time, such as a
Bufferbound to a column that is notbytea, isValidationError(TURBINE_E003). It wasConnectionError(TURBINE_E004), though the connection was fine. - PostgreSQL
42883(no operator for these argument types, for example abigintcolumn compared with text) isValidationError(TURBINE_E003), keeping the type names. - pg-pool's checkout timeout, on a pool you supplied or one Turbine owns, is
ConnectionError(TURBINE_E004). It was pg-pool's plainError. - A malformed
defineSchemadeclaration isValidationError(TURBINE_E003) in every case. Nine refusals threw a plainError: an unknown column type, an enum with noenumName, a vector with nodimensions, anindexes/manyToMany/checks/primaryKeyoption that is not an array, a composite primary key naming no column, and an unknown type on the deprecatedcolumnproxy. TimeoutErrorcarriesoutcome, see above.- A lock wait that hit the server's lock timeout (MySQL error 1205, SQL Server error 1222) is
LockNotAvailableError. It was a plainSerializationFailureError. The code (TURBINE_E013),isRetryableandinstanceof SerializationFailureErrorare unchanged; only a check oferr.nameorerr.constructorsees the difference. - A transaction PostgreSQL rolled back instead of committing is
TransactionRolledBackError(TURBINE_E019), new in 0.90, see Transactions. - A failed SQL-cache self-check is
InvariantError(TURBINE_E020), new in 0.90, withinvariantset to'sql-cache-lockstep'or'sql-cache-param-count'. It wasValidationError(TURBINE_E003). It marks a Turbine bug, not a bad argument; report it. Two cheap forms of the check now run in every mode, production included (see Observability). A relation payload the database returned that does not parse as JSON, and an internal where-scope misuse, wereTURBINE_E003too; they areInvariantErrorwithinvariant'relation-payload-json'and'where-scope-reference'. On PowDB, three internal checks in the PowQL generator wereTURBINE_E003with aninternal:prefix; they areInvariantErrorwithinvariant'powql-relation-filter-unresolved','powql-json-operator'and'powql-json-filter-empty'. No argument can reach them. - A session PostgreSQL ended for sitting idle in an open transaction (SQLSTATE
25P03) isConnectionError(TURBINE_E004), with a hint naming the limit. It was an unmapped driver error.
An object written to a scalar column#
A plain object or an array written to a built-in scalar column now throws TURBINE_E003. It used to be stored in some form without a word.
- PostgreSQL: text, numeric, boolean, temporal, uuid and similar columns. It stored JSON text or a Postgres array literal. Domains, extension types and array columns are not affected.
- MySQL: numeric, boolean, date and time, binary and text columns.
- SQLite and SQL Server: numeric, boolean, date and time, uuid and binary columns. A text column still takes an object as JSON text, because that is how those engines store
json. - Every engine: a
Mapor aSetthrows on every column,jsonincluded. It was written as{}. Convert it first:Object.fromEntries(map),[...set].
await db.users.create({ data: { settings: { theme: 'dark' } } });
// settings is text: TURBINE_E003
await db.users.create({ data: { settings: JSON.stringify({ theme: 'dark' }) } }); // or jsonValue(...)create({ data: { name: { set: 'x' } } }) is refused for the same reason: atomic operators belong in update data.
Transactions#
A rolled-back COMMIT rejects. When a statement inside a transaction fails and your callback catches the error, PostgreSQL ends the transaction with a ROLLBACK when you COMMIT. $transaction (callback form), $withSession and transaction() used to resolve as if they had committed. They now reject with TransactionRolledBackError (TURBINE_E019), the failed statement on cause. A deadlock or serialization failure keeps its retryable class (TURBINE_E012 / TURBINE_E013), so withRetry retries it. A lock-not-available failure (LockNotAvailableError, new in 0.90, TURBINE_E013) keeps its class too.
import { UniqueConstraintError } from 'turbine-orm';
// 0.81: resolved, and nothing was saved. 0.90: rejects.
await db.$transaction(async (tx) => {
await tx.audit.create({ data: entry });
try {
await tx.users.create({ data: { email } }); // unique violation
} catch {}
});
// Recover from an expected failure in its own savepoint:
await db.$transaction(async (tx) => {
await tx.audit.create({ data: entry });
try {
await tx.$transaction(async (inner) => {
await inner.users.create({ data: { email } });
});
} catch (err) {
if (!(err instanceof UniqueConstraintError)) throw err;
}
});The statement after a failure is refused. On PostgreSQL, once a statement inside the transaction fails, the next one is not sent. It rejects with the first failure's class when that was a deadlock, serialization conflict or unavailable lock, so withRetry retries it, and with TransactionRolledBackError (TURBINE_E019, the failure on cause) otherwise. It used to reach the server and come back as an untyped 25P02 error, which lost the first failure and was never retried. A nested tx.$transaction that recovers a failure still works.
A nested block has its own handle. The callback of tx.$transaction(fn) receives its own handle, not the outer tx. A statement through the outer tx while that block is open throws TURBINE_E003. It used to run inside the block's savepoint, so a failing block silently undid writes the caller thought were outside it. Use the handle the block's callback receives.
await db.$transaction(async (tx) => {
await tx.$transaction(async (inner) => {
await inner.audit.create({ data: entry }); // not tx.audit
});
});Transaction-control SQL is refused inside a transaction. COMMIT, ROLLBACK, BEGIN, SAVEPOINT, RELEASE, ROLLBACK TO, SET TRANSACTION and the like, sent through tx.raw, tx.rawUnsafe, tx.sql or tx.$connection().query, throw TURBINE_E003. A hand-written COMMIT ended the transaction early, and every later write autocommitted. Return from the callback to commit, throw to roll back, and use tx.$transaction for a savepoint. The raw transaction() client still accepts SAVEPOINT, RELEASE and ROLLBACK TO. tx.$connection() also refuses end() and connect() and no longer exposes the driver's .connection.
A settled tx throws. A tx used after its transaction committed, rolled back or timed out throws TURBINE_E003 instead of running on a connection that may belong to another request. The usual causes are a missing await, an async callback passed to forEach (use for ... of), or a tx stored for later.
Un-awaited work rolls the transaction back. A $transaction or transaction() callback that returns while a query it started is still running now rolls back and rejects with TURBINE_E003. It used to commit whatever part of the work had finished. Await every query (for ... of, await Promise.all(...)).
The raw transaction() client closes. The client transaction(fn) passes in refuses statements after the callback settles, and refuses release() at any time (TURBINE_E003). Finish the work inside the callback, and never release the client.
On PostgreSQL, a transaction timeout stops the work on the server. Through node-postgres, on expiry, $transaction cancels the running statement and rolls the transaction back before it rejects, so its locks are released at that moment. On other engines and pools a running statement cannot be cancelled, so the connection is destroyed, as before. timeout must be a positive number of milliseconds below 2^31, or 0 for none; anything else throws TURBINE_E003.
A per-query timeout inside $transaction rolls the transaction back. On PostgreSQL the statement is cancelled on the transaction's connection and the call rejects with TimeoutError (outcome: 'not-executed'). A callback that catches it and returns gets TransactionRolledBackError; in a nested tx.$transaction, only that block rolls back. Retry the whole transaction, or put the timed statement in its own nested block. A statement sent after the caught TimeoutError is refused with TransactionRolledBackError, the timeout on cause.
sessionContext names need a dot. A name without one (role, search_path, statement_timeout) is one of PostgreSQL's own settings and throws TURBINE_E003. Namespace custom settings (app.tenant), or list a built-in setting your code sets on purpose with allowBuiltinSettings: ['search_path'].
sessionContext values are strings, numbers or booleans. undefined, null and objects throw TURBINE_E003 before a connection is taken. undefined used to reach your row-level security policies as the text 'undefined'.
A Promise in $transaction([...]) or pipeline([...]) throws TURBINE_E003. The query in it already ran on its own. Pass build*() results (db.users.buildCreate(...)), or use the callback form.
Nested blocks started together run one after another. Promise.all([tx.$transaction(a), tx.$transaction(b)]) shared one connection before, so the blocks were never parallel; they now run in order instead of interleaving.
Connections#
A stream idle for five minutes is closed. A findManyStream / findManyStreamBatches that opened a cursor and whose consumer asks for nothing for streamIdleTimeoutMs (default 300,000 ms) is closed: the cursor is closed, its connection released, stream-idle-timeout logged, and the next pull throws TimeoutError (TURBINE_E002). A consumer that pauses longer between rows on purpose sets idleTimeoutMs on the call, or streamIdleTimeoutMs: 0 on the client to turn it off. On PostgreSQL the stream's transaction also gets a transaction-local idle_in_transaction_session_timeout of the timeout plus 10 seconds. A session the server ends that way (SQLSTATE 25P03) is ConnectionError (TURBINE_E004). See Abandoned streams close themselves.
turbineSqlite(path).disconnect() closes the database. It used to leave the handle Turbine opened open, and the client kept answering queries. After disconnect() a query now throws ConnectionError (TURBINE_E004). A DatabaseSync you pass in stays open.
An exhausted pool is ConnectionError (TURBINE_E004). A checkout that timed out used to reject with pg-pool's plain Error: timeout exceeded when trying to connect. It now names the pool size, the waiting callers, and the usual cause: a query on db inside a $transaction callback, which needs a second connection. Catch ConnectionError or err.code === 'TURBINE_E004'.
A client with no database configured rejects its first query. With no connectionString, pool, host settings, DATABASE_URL or PG* variables, Turbine used to connect to postgres@localhost. The client still constructs and builds SQL, but every query rejects with TURBINE_E004 explaining that Turbine does not read .env files (node --env-file=.env, or process.loadEnvFile()). An empty connectionString is named as the cause.
A pooler connection string sends unnamed statements. When the connection string, host or port names a pooler (a pooler or pgbouncer host, or port 6432 or 6543), preparedStatements now defaults to false. If your pooler tracks prepared statements (PgBouncer 1.21+ with max_prepared_statements above 0), set preparedStatements: true. A named statement that still reaches a transaction pooler, and fails because the server connection never prepared it (26000) or already holds it (42P05), is now ConnectionError (TURBINE_E004) with sqlstate set and advice naming the three fixes: the direct endpoint, preparedStatements: false, or pooler support for prepared statements. It was an untyped driver error; read err.sqlstate instead of the driver's .code.
Pools Turbine opens retire old connections. maxLifetimeSeconds defaults to 1800: a connection is closed once it is 30 minutes old, when it is next idle. Set maxLifetimeSeconds: 0 to keep the old behaviour.
A pool you supply reads like a pool Turbine opens. On a PostgreSQL pool passed as pool, Turbine's own queries now read bigint as a number and zone-less timestamp / date (and their arrays) as UTC. The queries your own code sends on that pool are unchanged. utcTimestamps: false keeps the driver's temporal readings.
A pool without connect() gets TURBINE_E017. $transaction, transaction(), a pipeline sent as one batch, cursor streams, a chunked createMany and a MySQL write need a connection of their own. On a stateless HTTP driver they now throw UnsupportedFeatureError naming the alternative, instead of a TypeError.
Raw SQL takes only a tagged template. db.raw, tx.raw and db.sql throw TURBINE_E003 when passed an array (db.raw([text])), because its text reached the SQL unescaped. Use db.rawUnsafe(text, ...values) with $1..$n placeholders. On PostgreSQL a Date passed to raw SQL now binds as its UTC fields under utcTimestamps (it bound as local time for timestamp columns), and an Invalid Date throws TURBINE_E003.
$on and $off check the event name. A name other than 'query', 'poolError' or 'connectionLost' throws TURBINE_E003.
PGHOST, PGPORT, PGDATABASE and PGUSER are honoured. Turbine's own localhost / postgres defaults used to override them. PGHOST and PGPORT also count when Turbine decides whether it is talking to a pooler, so a pooler named only by the environment gets unnamed statements too. If your shell sets PGUSER for psql and the app relied on the postgres default, set user explicitly.
prisma-compat#
take,skipandcursoroncountandaggregatethrowTURBINE_E017. They were ignored, so the number covered every matching row. Fetch the page withfindManyand compute from it.count({ select })returns Prisma's per-field object ({ _all, title }) instead of a number.- Every cursor shape now matches Prisma: the page starts at the cursor row in the query's order and
skip: 1drops it. The shapes that threwTURBINE_E017before now work, andcursorplusskipunder anorderByon another field returns the right rows. skipin a nestedincludemaps to the relation offset. A nestedcursorthrowsTURBINE_E017; it was dropped. A nesteddistinctthrowsTURBINE_E017; it returned duplicate rows.- A filtered relation count (
_count: { select: { posts: { where } } }) throwsTURBINE_E017. It returned the unfiltered count. Count through the related model. - An option the adapter does not translate throws
TURBINE_E003instead of being ignored: an unknown key on an included relation (including Turbine'slimit,offsetandwith), an unknown key under_count, an unknown$transactionoption, and an unread key in a nested write payload. See what it translates. createManywith nodata, ordatathat is neither an object nor an array, throwsTURBINE_E003. It inserted nothing and returnedcount: 0.- The array form
$transaction([...], options)appliesisolationLevelandtimeout. They were ignored. - A cursor always reads the row it names, so a cursor whose row was deleted since the last page gives an empty page, as in Prisma.
SQLite#
- Calling the client (not
tx) from inside its own$transactioncallback, or opening a nesteddb.$transactionthere, throwsTURBINE_E017instead of deadlocking. Use thetxargument. SQLITE_BUSYandSQLITE_LOCKEDareSerializationFailureError(TURBINE_E013, retryable) on every path,db.pool.queryanddb.transaction()included.isolationLevelis accepted. SQLite transactions are serializable, and it used to produce a syntax error.- A string that is not a number, written to a numeric or boolean column, throws
TURBINE_E003, as on PostgreSQL. SQLite stored it as text, so{ flag: true }never matched a'true'written to a boolean column. Surrounding whitespace is accepted, as SQLite converts it (' 12 'is stored as12). - A whole JSON document as a
wherevalue, andnot: <document>, throwTURBINE_E017, as a pathlessequalsalready did. SQLite compared JSON text, so key order decided the match. SQL Server refuses the same shapes. rawUnsafeplaceholders are$1,$2and so on, on SQLite, MySQL and SQL Server alike. A?placeholder, or a$npast the values given, throwsTURBINE_E003. SQLite read$1as a parameter named$1, MySQL rejected it, and SQL Server read it as a money literal.
MySQL#
NaN,Infinityand an Invalid Date as a bound value throwTURBINE_E003. MySQL stored them as0.- An
in/notInlist that takes the statement past MySQL's 65,535 placeholders throwsTURBINE_E003, because each element is now its own placeholder. Split the list across several queries. - Data errors (too long, out of range, bad value, bad date, division by zero, invalid JSON) are
ValidationErrorwithcolumnandsqlstate, instead of a plain driver error. createon a table whose new key the engine cannot report back (a UUID or other default the server does not number, or no primary key) throwsTURBINE_E017before theINSERT. It used to re-read by a guessed key and could return another row, another tenant's included. Pass the key indata(crypto.randomUUID()for a UUID key), or make itAUTO_INCREMENT.- A single-row
update,deleteorupsertwhose re-read or lock matches more than one row (a number compared with a text key) throwsTURBINE_E017and is rolled back. Pass key values with the column's type. - A
createManywithonConflict(new in 0.90) whose rows share the conflict key only under the column's collation ('bob'and'BOB') throwsTURBINE_E003, across the statements of a chunked call too. Every engine raises the same error, SQLite (COLLATE NOCASE) included, see Multi-row upsert.
SQL Server#
- An
in/notInlist holding a binary value (Buffer,Uint8Array) throwsTURBINE_E017. The list reaches SQL Server as JSON text, which cannot carry bytes. Write the list asORbranches:{ OR: [{ hash: a }, { hash: b }] }.
PowDB#
These used to return wrong rows and now throw:
containswith%or_throwsTURBINE_E017: PowQL'slikehas no escape character.startsWithandendsWithmatch literally.- Comparisons, ordering, aggregates and arithmetic on a
numeric/decimalcolumn (stored as text) throwTURBINE_E017. Reads, writes and null checks are unchanged. - A relation filter matching more than 10,000 distinct keys throws
TURBINE_E017. - A malformed relation filter shape throws
TURBINE_E003. - Embedded: calling the client from inside its own
$transactioncallback throwsTURBINE_E017namingtx. A statement from outside an open transaction waits its turn. optimisticLockonupsert/updateManyand a PII column inselectfollow the SQL engines' rules above.- A malformed
AND/OR/NOTthrowsTURBINE_E003,OR: []matches no row,AND: { ... }is honoured, andNOT: [a, b]meansNOT a AND NOT b, as on the SQL engines. They threw aTypeError, matched every row, or were ignored. - An operator object naming no operator (
{ mode: 'insensitive' }) and a non-stringdistinctelement throwTURBINE_E003. A badtimeoutorrelationLoadStrategythrowsTURBINE_E003, middleware cannot rewrite arguments, andupdateMany/deleteManyrefuse paging options, as on the SQL engines.
These change results on PowDB without an error:
- A top-level
NOTand a global filter'sNOTfollow SQLNULLsemantics: a row whose compared value isNULLmatches neither the condition nor itsNOT. To include those rows, addOR: [{ col: null }, ...]. _sumover anint8/bigintcolumn returns exact decimal text ('9007199254740996'), as on PostgreSQL. Wrap it inNumber()orBigInt()where you expected a number.- An unknown relation in
withisRelationError(TURBINE_E005), as on the SQL engines. It wasTURBINE_E003, and it is now refused even when the base query matches no row. - An object written to a
jsoncolumn byupdate,updateManyorupsertis stored as the document. It was read as an atomic operator and refused. Only{ set: ... }is an operator on ajsoncolumn. with: { _count: { rel: true } }returns relation counts. It threwUnknown relation _count.- An
ORwith a branch that applies no condition (OR: [{}, x]) matches every row, and itsNOTmatches none, as on the SQL engines.NOT: { OR: [x, {}] }matched every row butx. - A bigint default past 2^53 keeps its digits.
These also changed on PowDB in this release:
- A number, bigint or boolean compared with or written to a
Datecolumn throwsTURBINE_E003, as on PostgreSQL. It was bound as raw microseconds, so a filter matched nothing and a write stored the wrong instant. Passnew Date(ms)or ISO text. - A whole JSON document in a
where({ meta: { a: 1 } },not: doc) throwsTURBINE_E017, as a pathlessequalsalready did. It wasTURBINE_E003. - A
cursorthat names no row throwsTURBINE_E003with the SQL engines' message. A valid cursor is stillTURBINE_E017. - An
upsertonto a key a global filter hides throwsNotFoundError(TURBINE_E001), as on PostgreSQL, and writes nothing. It wasTURBINE_E008. - Integer text such as
'9007199254740993'is accepted for an integer column in writes and filters; other text isTURBINE_E003naming the column. - An enum column is a
strholding its label; it was declaredjson, and every write of a label failed. A value that is not a label isTURBINE_E003, ranges compare in declaration order,contains/mode: 'insensitive'/_sum/_avgon an enum areTURBINE_E003, and ordering by one (orderBy,_min/_max, groupBy ordering) isTURBINE_E017. See PowDB. - A groupBy
bythat is missing, a string, empty or names something it cannot group by throwsTURBINE_E003with the SQL engines' message. It was aTypeErroror an engine error. updateMany/deleteManywhosewhereisnot: null(orNOT: { col: null }) on a NOT NULL column is refused withoutallowFullTableScan: UNSAFE. It wrote every row.- The no-limit warning and the unknown-option advice go through the client's
loggerand followdiagnostics. They printed to the console whatever the client said. db.pipeline(...),db.$transaction([...])and prisma-compat's array form throwUnsupportedFeatureError(TURBINE_E017) naming the interactive$transaction, before anything runs. They threw aTypeError("is not a function"), because a PowDB table had nobuild*methods.cacheStats()on a PowDB table returns zeros; it was not defined there. Run the calls insidedb.$transaction(async (tx) => ...). Find it:pipeline(and$transaction([calls on a PowDB client.
Logging and diagnostics#
Diagnostics replaces NODE_ENV#
A new client option, diagnostics, decides whether Turbine prints per-query advice and runs its SQL-cache self-check. When you do not set it, it follows NODE_ENV as before, with two differences:
- It is read once, when the client is constructed. Changing
NODE_ENVafterwards does not affect that client. - A runtime with no
processdefaults to production mode. A runtime that has aprocessbut noNODE_ENV(some workers) defaults to development mode, so setdiagnostics: 'production'there.
Construction warnings are always on#
These warnings used to be silent under NODE_ENV=production. They now fire once per process in every mode, because each describes a production hazard:
- an unknown key in the client config (code
unknown-config-key); planCacheModeon a pool Turbine does not own (codeplan-cache-mode-ignored);- prisma-compat's check that the map is older than
schema.prisma(codeprisma-map-stale).
The warnings new in 0.90 about prepared statements sent through a pooler (prepared-statements-on-pooler) and a connection option an external pool ignores (connection-option-ignored) are always on too.
Fix the cause, or route Turbine's output with the new logger option. logger: {} silences everything.
Smaller output changes#
- A
$listenconnection that is reconnecting is logged atwarn(console.warn), noterror. The final loss is stillerror. - Every line now carries a stable
codewhen it goes through alogger. Match on the code; the message text is not stable.
Tooling#
turbine generate#
-
A
tsTypeorcolumnTypesvalue must be one TypeScript type on one line. A comment, a top-level;or,, unbalanced brackets, a template literal type or an unexpected character throwsTURBINE_E003naming the text. Give anything more complex a type alias and writeimport('./x.js').T. -
A declared
tsTypeno longer changes runtime metadata. It used to replace the column's mapped type in the metadata the engines read, so embedded PowDB created integer,timestamptzand boolean columns as strings, and MySQL read a nested boolean as1. The metadata now keeps the mapped type, and the declared type rides indeclaredTsType, read only byturbine generate. Code that readtsTypeback from the metadata to get the declared type should readdeclaredTsType. -
With
includeset, a misspeltcolumnTypestable is refused with a suggestion, instead of being skipped as ifincludehad left it out. -
It needs
tsxwhenturbine/schema.tsexists, because it now loads the schema file to read its tags. Install it as a dev dependency. -
It fails rather than generating an untagged client. A named
--schema-fileor configschemaFilethat is missing or cannot be loaded exits 1, and so does apiiorupdatedAttag on a table or column the database lacks. Runturbine pushfirst, or fix the tag. A project with no./turbine/schema.tsis unaffected. -
A table whose accessor would be
constructoror__proto__is refused, with the fix named. -
introspect()returns null-prototype objects fortables,enums, the column maps and relations. Anassert.deepEqualagainst a plain object needs a spread.
Generated types are stricter#
Regenerate your client, then run tsc. These are compile-time changes only; nothing changes at runtime, and most calls the new types reject already failed at runtime.
createrequires every required column. A NOT NULL column with no default must be indata. A requiredbelongsTois satisfied by its foreign key or by the relation (connect/create/connectOrCreate), and a nested create under a parent does not ask for the keys the parent supplies. Before, the call compiled and failed at runtime withTURBINE_E010.findUnique,findUniqueOrThrow,upsertand nestedconnecttake a unique key (the generatedXWhereUnique, such as{ id } | { email }). Extra predicates beside the key are still allowed. For a non-unique lookup usefindFirst.- A
belongsTorelation whose foreign key is NOT NULL is typed non-null when loaded. It is nullable again when the relation'swithhas awhere. If a global filter on the target table can hide that row, keep treating it as nullable: the type cannot see the filter. belongsTonested writes take the to-one operations.connect,createandconnectOrCreatetake one object,disconnectanddeletetaketrue(or a unique key),updatetakes{ data }, and there is noset. Replace array forms with a single object._count: trueis one number per to-many relation ({ posts: number }), notRecord<string, number>.- Accessors are
QueryInterface<T, TRelations, TInputs>, and the generated constructor andturbine()takeTypedTurbineConfig, whoseglobalFiltersis checked against your tables and columns. A hand-writtenQueryInterface<T, R>keeps working.
Also new, and not breaking: nested select / omit narrow the related rows' type, aggregate results follow their arguments, update fields accept { set } and the atomic operators, the generated client compiles under exactOptionalPropertyTypes and passes @typescript-eslint/no-unsafe-declaration-merging, and json columns can be typed with tsType in the schema file or columnTypes in turbine.config.ts.
Generated row types follow the driver#
Regenerate your client. A few column types were declared as string while the driver returns something else:
intervalis the driver's parsed object,{ years?, months?, days?, hours?, minutes?, seconds?, milliseconds? }.pointis{ x, y }andcircleis{ x, y, radius }.bigint[]isstring[]: the driver's array parser does not apply the scalarint8rule.macaddr8,bit,varbit, ranges and multiranges staystring.
Writes to int8 also accept a string or bigint, and writes to numeric a number. Code that treated an interval or point as a string now gets a compile error where it used to get an object at runtime.
Migrations#
migrate create --autoand--from-diffrefuse while migrations are pending. The diff reads the live database, so it would repeat what the unapplied files do. Runturbine migrate upfirst, or pass--allow-pendingwhen the pending files do not touch what the schema file describes.migrate createrefuses to replace a file. A secondmigrate createwith the same name in the same second used to overwrite the first file, edits included. It now fails withMigrationError(TURBINE_E006); wait a second, or pick another name.- Migration filenames are stamped in UTC. Existing local-time files keep working; the runner reads any 14-digit prefix. A file created after upgrading can sort before one a colleague created earlier the same day in a zone ahead of UTC, which the runner applies as an out-of-order migration.
- Lossless widenings are no longer destructive.
inttobigint, a longervarcharand similar changes apply without--allow-destructive. A narrowing is still refused. - New CI flags.
push --dry-run --exit-codeandmigrate status --exit-codeexit 2 on drift, andmigrate deploy --wait[=seconds]waits for a concurrent deploy's lock. See Checking for drift in CI.pushalso exits 2 when the diff carries a warning (a declaredUNIQUEindex over a non-unique one of that name, a removed enum label, an undeclared index on a table that manages its indexes), andmigrate statuswhen the directory holds a misnamed.sqlfile or one with an upper-case.SQLextension, which the runner never runs. migrate up,migrate deployandmigrate downrefuse a transaction-pooler URL (apoolerorpgbouncerhostname, or port 6543 or 6432), because the session-level advisory lock can be taken on one backend and released on another. Use the direct endpoint, or pass--allow-pooler. Neon and Supabase projects often run migrations through the pooledDATABASE_URL, so expect this on the first deploy after upgrading.--dry-run,statusandcreateare not refused.
turbine init and migrate-from-prisma#
turbine init --urlcreates.env,.env.exampleand.gitignoreexclusively: a file is created only if the name is still free at that moment, and an existing one is handled as before. A symlink at one of those names is refused instead of written through, and a.envthat appears while the command runs is appended to rather than truncated.migrate-from-prismareads a string literal's escapes in one pass. A datasource URL,@mapname or default holding an escaped backslash beforenort("C:\\new") was read as a newline or a tab.
Generated DDL for MySQL, SQLite and SQL Server#
schemaToSQL and schemaPush used to map a column type twice on the other engines, losing what the dialect's own mapping said. The PostgreSQL output is unchanged. Elsewhere, regenerate the DDL and review it before applying it to an existing database:
AUTO_INCREMENT(MySQL),AUTOINCREMENT(SQLite) andIDENTITY(SQL Server) stay on serial keys, and declaredVARCHARlengths stay.- SQL Server
textandjsonstayNVARCHAR(MAX)instead of being cut to 255. - SQL Server
timestampandtimestamptzcolumns areDATETIMEOFFSET. Both wereDATETIME2. IndefineSchema,timestampmeanstimestamptz, so every code-first timestamp column changes type. Existing tables keepDATETIME2until you alter them. - MySQL foreign keys are emitted at table level, so they now exist. MySQL 8 parses and ignores an inline
REFERENCES. - Text in a key position (primary key, unique, foreign key, declared index) is
VARCHAR(255)on MySQL andNVARCHAR(255)on SQL Server. A longer key needs an explicitvarcharwithmaxLength. - Defaults are translated per dialect (
now(),gen_random_uuid(),true/false,current_date). On MySQL a non-NULLdefault on aTEXT,BLOBorJSONcolumn is written as an expression (DEFAULT ('member')), a clock default on aDATEorTIMEcolumn as(CURRENT_DATE)or(CURRENT_TIME), and aDATETIME(n)default follows the column's precision. MySQL rejected the old forms. - Enum columns are declared on every engine.
schemaToSQLemitted PostgreSQL'sCREATE TYPE ... AS ENUMeverywhere. MySQL now getsENUM(...), SQLite aTEXTcolumn with aCHECKover the labels, and SQL Server anNVARCHARcolumn with the sameCHECK. Off PostgreSQL the enum must be declared indefineSchema'senumsand have at least one label (TURBINE_E003otherwise), an enum array isTURBINE_E017, and a MySQL label holding a backslash isTURBINE_E003. - A
byteakey isVARBINARY(255)on MySQL and SQL Server (VARBINARY(n)withmaxLength).BLOBandVARBINARY(MAX)cannot be a key, so such a table could not be created. - Defaults accept
'it''s'and a PostgreSQL cast ('x'::text, the cast is dropped) on every engine. A defaultschemaToSQLcannot write isValidationError(TURBINE_E003) instead of a plainError. On MySQL aTEXT,BLOBorJSONdefault holding a quote or a backslash is written as hex bytes; as a literal it created fine and then failed every insert through Turbine's connections. A backslash in any other MySQL default isTURBINE_E003. - MySQL timestamps are
DATETIME(3), with aCURRENT_TIMESTAMP(3)default, so milliseconds are stored instead of rounded away. Existing tables keep bareDATETIMEuntil you runALTER TABLE ... MODIFY col DATETIME(3).
The destructive-migration guard flags more#
migrate up, migrate down and push now also stop on an UPDATE whose WHERE is constant-true (WHERE true, WHERE 1=1, x OR true), a destructive statement inside PREPARE ... AS, INSERT ... ON CONFLICT DO UPDATE with no restricting WHERE (overwrite-on-conflict), lo_unlink (large-object-unlink), DROP VIEW ... CASCADE, and quoted table names such as UPDATE "t" that were missed. migrate deploy still proceeds and prints a notice. For an idempotent upsert, add WHERE t.col IS DISTINCT FROM EXCLUDED.col.
Studio and Observe#
The URL Studio and Observe print carries a one-time ticket, not the session credential. The first visit exchanges it for a cookie; a second use gets 403, or a redirect to / when you are already signed in. Scripts that reused the printed URL as a token must use the cookie. Both servers answer 403 to a Host that is not loopback or the bound host. Studio also answers 413 to a request body over 1 MiB and 400 to malformed JSON, returns at most 1,000 rows from the Query tab, and keeps at most 500 saved queries.
MCP server#
The secret-name rule now splits camelCase, PascalCase and kebab-case names, so apiKey, accessToken and PasswordHash are hidden from sample_rows and refused in explain_query alongside the underscore forms.
New in 0.90#
None of these change existing code. Each is opt-in:
- Row locks:
lock: 'update'and the other modes onfindMany/findFirst/findUnique, withnoWaitandskipLocked, inside$transaction. - Percentiles:
_percentileonaggregateandgroupBy(PostgreSQL). - Update expressions:
expr.add(col('views'), 1),expr.greatest,expr.case,expr.now()and more in update data. They store the same value on every engine: arithmetic between integers is integer arithmetic, and a non-integer result is refused on an integer column. - Logging: a
loggeroption for every line Turbine writes, each with a stablecode. - Multi-row upsert:
createMany({ data, onConflict: { target, update } }), andselect/omitoncreateMany. - Client options:
statementTimeoutMs(PostgreSQL),maxLifetimeSeconds, and on$transaction,readOnlyandallowBuiltinSettings. - Inside a transaction:
tx.sql,tx.rawUnsafeandtx.$connection(). - SQL comments:
sqlCommentappends sqlcommenter key/values (static pairs, the model and method, the$taglabel, per-statement values such as a trace'straceparent) to every statement a model call sends. Refused on PowDB; itsdynamicoption is refused on MySQL. - Operations:
db.$ping(),disconnect({ timeoutMs }), and the'poolError'and'connectionLost'events, see Client escape hatches. - Types:
pipeline([...], options)returns a typed tuple, as the rest-argument form does.
See also#
- Changelog, the full list of fixes and additions in 0.90.
- API Reference, the rules above in context.
- Typed Errors, every error code.